Skip to content

Commit e96b939

Browse files
committed
feat: add system.which bin probe
1 parent e479c87 commit e96b939

6 files changed

Lines changed: 96 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
2020
- Status: restore usage summary line for current provider when no OAuth profiles exist.
2121
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
22+
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
2223
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
2324
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
2425
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.

apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
164164
]
165165

166166
if SystemRunPolicy.load() != .never {
167+
commands.append(ClawdbotSystemCommand.which.rawValue)
167168
commands.append(ClawdbotSystemCommand.run.rawValue)
168169
}
169170

apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
5555
return try await self.handleScreenRecordInvoke(req)
5656
case ClawdbotSystemCommand.run.rawValue:
5757
return try await self.handleSystemRun(req)
58+
case ClawdbotSystemCommand.which.rawValue:
59+
return try await self.handleSystemWhich(req)
5860
case ClawdbotSystemCommand.notify.rawValue:
5961
return try await self.handleSystemNotify(req)
6062
default:
@@ -494,6 +496,33 @@ actor MacNodeRuntime {
494496
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
495497
}
496498

499+
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
500+
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
501+
let bins = params.bins
502+
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
503+
.filter { !$0.isEmpty }
504+
guard !bins.isEmpty else {
505+
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
506+
}
507+
508+
let searchPaths = CommandResolver.preferredPaths()
509+
var matches: [String] = []
510+
var paths: [String: String] = [:]
511+
for bin in bins {
512+
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
513+
matches.append(bin)
514+
paths[bin] = path
515+
}
516+
}
517+
518+
struct WhichPayload: Encodable {
519+
let bins: [String]
520+
let paths: [String: String]
521+
}
522+
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
523+
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
524+
}
525+
497526
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
498527
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
499528
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)

apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
2121
#expect(response.ok == false)
2222
}
2323

24+
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
25+
let runtime = MacNodeRuntime()
26+
let params = ClawdbotSystemWhichParams(bins: [])
27+
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
28+
let response = await runtime.handleInvoke(
29+
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
30+
#expect(response.ok == false)
31+
}
32+
2433
@Test func handleInvokeRejectsEmptyNotification() async throws {
2534
let runtime = MacNodeRuntime()
2635
let params = ClawdbotSystemNotifyParams(title: "", body: "")

apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22

33
public enum ClawdbotSystemCommand: String, Codable, Sendable {
44
case run = "system.run"
5+
case which = "system.which"
56
case notify = "system.notify"
67
}
78

@@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
3940
}
4041
}
4142

43+
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
44+
public var bins: [String]
45+
46+
public init(bins: [String]) {
47+
self.bins = bins
48+
}
49+
}
50+
4251
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
4352
public var title: String
4453
public var body: String

src/infra/skills-remote.ts

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ function supportsSystemRun(commands?: string[]): boolean {
3737
return Array.isArray(commands) && commands.includes("system.run");
3838
}
3939

40+
function supportsSystemWhich(commands?: string[]): boolean {
41+
return Array.isArray(commands) && commands.includes("system.which");
42+
}
43+
4044
function upsertNode(record: {
4145
nodeId: string;
4246
displayName?: string;
@@ -136,6 +140,27 @@ function buildBinProbeScript(bins: string[]): string {
136140
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
137141
}
138142

143+
function parseBinProbePayload(payloadJSON: string | null | undefined): string[] {
144+
if (!payloadJSON) return [];
145+
try {
146+
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
147+
if (Array.isArray(parsed.bins)) {
148+
return parsed.bins
149+
.map((bin) => String(bin).trim())
150+
.filter(Boolean);
151+
}
152+
if (typeof parsed.stdout === "string") {
153+
return parsed.stdout
154+
.split(/\r?\n/)
155+
.map((line) => line.trim())
156+
.filter(Boolean);
157+
}
158+
} catch {
159+
return [];
160+
}
161+
return [];
162+
}
163+
139164
export async function refreshRemoteNodeBins(params: {
140165
nodeId: string;
141166
platform?: string;
@@ -146,7 +171,9 @@ export async function refreshRemoteNodeBins(params: {
146171
}) {
147172
if (!remoteBridge) return;
148173
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
149-
if (!supportsSystemRun(params.commands)) return;
174+
const canWhich = supportsSystemWhich(params.commands);
175+
const canRun = supportsSystemRun(params.commands);
176+
if (!canWhich && !canRun) return;
150177

151178
const workspaceDirs = listWorkspaceDirs(params.cfg);
152179
const requiredBins = new Set<string>();
@@ -158,31 +185,30 @@ export async function refreshRemoteNodeBins(params: {
158185
}
159186
if (requiredBins.size === 0) return;
160187

161-
const script = buildBinProbeScript([...requiredBins]);
162-
const payload = {
163-
command: ["/bin/sh", "-lc", script],
164-
};
165188
try {
166-
const res = await remoteBridge.invoke({
167-
nodeId: params.nodeId,
168-
command: "system.run",
169-
paramsJSON: JSON.stringify(payload),
170-
timeoutMs: params.timeoutMs ?? 15_000,
171-
});
189+
const binsList = [...requiredBins];
190+
const res = await remoteBridge.invoke(
191+
canWhich
192+
? {
193+
nodeId: params.nodeId,
194+
command: "system.which",
195+
paramsJSON: JSON.stringify({ bins: binsList }),
196+
timeoutMs: params.timeoutMs ?? 15_000,
197+
}
198+
: {
199+
nodeId: params.nodeId,
200+
command: "system.run",
201+
paramsJSON: JSON.stringify({
202+
command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)],
203+
}),
204+
timeoutMs: params.timeoutMs ?? 15_000,
205+
},
206+
);
172207
if (!res.ok) {
173208
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
174209
return;
175210
}
176-
const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : "";
177-
const parsed =
178-
raw && raw.trim().length > 0
179-
? (JSON.parse(raw) as { stdout?: string })
180-
: ({ stdout: "" } as { stdout?: string });
181-
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
182-
const bins = stdout
183-
.split(/\r?\n/)
184-
.map((line) => line.trim())
185-
.filter(Boolean);
211+
const bins = parseBinProbePayload(res.payloadJSON);
186212
recordRemoteNodeBins(params.nodeId, bins);
187213
await updatePairedNodeMetadata(params.nodeId, { bins });
188214
bumpSkillsSnapshotVersion({ reason: "remote-node" });

0 commit comments

Comments
 (0)