@phuetz/code-buddy 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.fr.md +2 -2
  2. package/README.md +3 -2
  3. package/codebuddy-runtime.json +4 -4
  4. package/dist/agent/execution/agent-executor.js +16 -5
  5. package/dist/agent/execution/tool-selection-strategy.d.ts +7 -0
  6. package/dist/agent/execution/tool-selection-strategy.js +13 -0
  7. package/dist/codebuddy/fleet-tool-defs.d.ts +1 -0
  8. package/dist/codebuddy/fleet-tool-defs.js +31 -0
  9. package/dist/codebuddy/providers/provider-openai-compat.d.ts +7 -0
  10. package/dist/codebuddy/providers/provider-openai-compat.js +28 -0
  11. package/dist/commands/handlers/missing-handlers.js +8 -0
  12. package/dist/commands/mcp.d.ts +13 -0
  13. package/dist/commands/mcp.js +62 -21
  14. package/dist/config/model-tools.js +99 -0
  15. package/dist/doctor/integrations.d.ts +18 -0
  16. package/dist/doctor/integrations.js +14 -0
  17. package/dist/errors/crash-handler.js +2 -0
  18. package/dist/mcp/client.d.ts +11 -0
  19. package/dist/mcp/client.js +62 -3
  20. package/dist/mcp/mcp-oauth-constants.d.ts +8 -0
  21. package/dist/mcp/mcp-oauth-constants.js +9 -0
  22. package/dist/mcp/mcp-oauth-provider.d.ts +80 -0
  23. package/dist/mcp/mcp-oauth-provider.js +241 -0
  24. package/dist/mcp/mcp-oauth.d.ts +60 -1
  25. package/dist/mcp/mcp-oauth.js +241 -65
  26. package/dist/mcp/transports.d.ts +4 -0
  27. package/dist/mcp/transports.js +52 -3
  28. package/dist/services/prompt-builder.js +3 -1
  29. package/dist/services/runtime-settings-context.d.ts +8 -0
  30. package/dist/services/runtime-settings-context.js +11 -1
  31. package/dist/tools/metadata.js +9 -0
  32. package/dist/tools/peer-tool-invoke-tool.d.ts +61 -0
  33. package/dist/tools/peer-tool-invoke-tool.js +396 -0
  34. package/dist/tools/registry/fleet-tools.d.ts +12 -3
  35. package/dist/tools/registry/fleet-tools.js +116 -4
  36. package/dist/tools/registry/index.d.ts +1 -1
  37. package/dist/tools/registry/index.js +2 -2
  38. package/dist/utils/config-validation/schema.d.ts +2 -2
  39. package/dist/utils/graceful-shutdown.d.ts +8 -0
  40. package/dist/utils/graceful-shutdown.js +23 -7
  41. package/package.json +1 -1
@@ -0,0 +1,396 @@
1
+ /**
2
+ * peer_tool_invoke tool — wraps `peer.tool.invoke` for the local agent.
3
+ *
4
+ * Lets the LLM read/search on a connected fleet peer (view_file,
5
+ * list_directory, search) without going through `peer.chat` (which has
6
+ * no tools). The three security gates stay on the remote peer
7
+ * (`peer-tool-bridge.ts`): allowlist, fleetSafe, workspace root.
8
+ *
9
+ * Extra tool names from `peer.describe` are trusted only when
10
+ * `CODEBUDDY_PEER_TRUST_DESCRIBE=true`. B still enforces its own
11
+ * allowlist even then.
12
+ *
13
+ * This side (A) does not interpret paths and does not add new remote
14
+ * capabilities. Failures always return `success: false`.
15
+ *
16
+ * @module src/tools/peer-tool-invoke-tool
17
+ */
18
+ import { getFleetRegistry } from '../fleet/fleet-registry.js';
19
+ import { redactSecrets } from '../fleet/privacy-lint.js';
20
+ import { logger } from '../utils/logger.js';
21
+ export const DEFAULT_PEER_TOOL_INVOKE_TOOLS = [
22
+ 'view_file',
23
+ 'list_directory',
24
+ 'search',
25
+ ];
26
+ export const DEFAULT_TIMEOUT_MS = 15_000;
27
+ export const MIN_TIMEOUT_MS = 1_000;
28
+ export const MAX_TIMEOUT_MS = 120_000;
29
+ export const MAX_ARGS_BYTES = 64 * 1024;
30
+ export const MAX_OUTPUT_BYTES = 256 * 1024;
31
+ export const MAX_PEER_ID_LENGTH = 128;
32
+ export const MAX_TOOL_NAME_LENGTH = 64;
33
+ /** Imperative tool description with a concrete call example for local models. */
34
+ export const PEER_TOOL_INVOKE_DESCRIPTION = 'Read or search a file on a connected fleet peer (read-only, allowlist on that peer). ' +
35
+ 'Always pass peer and tool; never call with empty arguments. ' +
36
+ 'Example: {"peer":"B","tool":"view_file","args":{"path":"oracle.txt"}}. ' +
37
+ 'Allowed tools: view_file, list_directory, search. Do not use local view_file for files that live on another peer.';
38
+ export const PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS = {
39
+ peer: 'Required. Connected peer id from list_peers (the --name of /fleet listen). Example: "B".',
40
+ tool: 'Required. Read-only tool on that peer. Default set: view_file, list_directory, search. Extra names from peer.describe are accepted only when CODEBUDDY_PEER_TRUST_DESCRIBE=true. Example: "view_file".',
41
+ args: 'Object of arguments for the remote tool. For view_file use {"path":"oracle.txt"} or {"file_path":"oracle.txt"}. For list_directory use {"path":"."}. For search use {"query":"TODO","path":"src"}. Paths are relative to the peer workspace and are not resolved on this host.',
42
+ timeoutMs: `Optional timeout in milliseconds. Default ${DEFAULT_TIMEOUT_MS}. Min ${MIN_TIMEOUT_MS}. Max ${MAX_TIMEOUT_MS}.`,
43
+ };
44
+ const DESCRIBE_TIMEOUT_MS = 3_000;
45
+ export const PEER_ID_RE = /^[a-zA-Z0-9._-]{1,128}$/;
46
+ const TOOL_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
47
+ const ARG_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
48
+ const DENIED_TOOL_PREFIXES = ['peer_', 'fleet_', 'agent_', 'delegate_'];
49
+ const DENIED_ARG_KEYS = new Set(['constructor', 'prototype', '__proto__']);
50
+ export function clampPeerToolInvokeTimeout(raw) {
51
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) {
52
+ return DEFAULT_TIMEOUT_MS;
53
+ }
54
+ const n = Math.floor(raw);
55
+ if (n < MIN_TIMEOUT_MS)
56
+ return MIN_TIMEOUT_MS;
57
+ return Math.min(n, MAX_TIMEOUT_MS);
58
+ }
59
+ function isDeniedToolName(name) {
60
+ return DENIED_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix));
61
+ }
62
+ function trustDescribeExtras() {
63
+ return process.env.CODEBUDDY_PEER_TRUST_DESCRIBE === 'true';
64
+ }
65
+ export function isFlatToolArgs(value) {
66
+ if (value === undefined)
67
+ return true;
68
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
69
+ return false;
70
+ }
71
+ const record = value;
72
+ for (const key of Object.keys(record)) {
73
+ if (DENIED_ARG_KEYS.has(key) || !ARG_KEY_RE.test(key))
74
+ return false;
75
+ const item = record[key];
76
+ if (item === null || item === undefined)
77
+ continue;
78
+ const t = typeof item;
79
+ if (t !== 'string' && t !== 'number' && t !== 'boolean')
80
+ return false;
81
+ }
82
+ return true;
83
+ }
84
+ function argsByteLength(args) {
85
+ try {
86
+ return Buffer.byteLength(JSON.stringify(args), 'utf8');
87
+ }
88
+ catch {
89
+ return MAX_ARGS_BYTES + 1;
90
+ }
91
+ }
92
+ const POSIX_ABS_PATH = /(^|[^A-Za-z0-9._~-])((?:\/[A-Za-z0-9._-]+)+)/g;
93
+ const WIN_ABS_PATH = /[A-Za-z]:\\[^\s"'`,;)\]]+/g;
94
+ const FILE_URL_PATH = /file:\/\/\/?[^\s"'`,;)\]]+/g;
95
+ const HOME_TILDE_PATH = /(^|[^A-Za-z0-9._-])(~\/[^\s"'`,;)\]]*)/g;
96
+ /**
97
+ * Strip absolute paths and secrets from a peer error before it reaches the
98
+ * model. Known refusal codes are mapped to path-free sentences; this covers
99
+ * the unrecognized fallback (and peer.describe failures).
100
+ */
101
+ export function redactPeerToolInvokeError(text) {
102
+ let out = text;
103
+ out = out.replace(WIN_ABS_PATH, '[redacted-path]');
104
+ out = out.replace(FILE_URL_PATH, '[redacted-path]');
105
+ out = out.replace(POSIX_ABS_PATH, (_full, prefix) => `${prefix}[redacted-path]`);
106
+ out = out.replace(HOME_TILDE_PATH, (_full, prefix) => `${prefix}[redacted-path]`);
107
+ out = redactSecrets(out);
108
+ return out.replace(/\s+/g, ' ').trim();
109
+ }
110
+ function truncateOutput(text) {
111
+ if (Buffer.byteLength(text, 'utf8') <= MAX_OUTPUT_BYTES) {
112
+ return { output: text, truncated: false };
113
+ }
114
+ let end = text.length;
115
+ let slice = text;
116
+ while (end > 0 && Buffer.byteLength(slice, 'utf8') > MAX_OUTPUT_BYTES) {
117
+ end = Math.max(0, end - Math.ceil((Buffer.byteLength(slice, 'utf8') - MAX_OUTPUT_BYTES) / 2) - 1);
118
+ slice = text.slice(0, end);
119
+ }
120
+ return { output: `${slice}\n…[truncated by peer_tool_invoke at ${MAX_OUTPUT_BYTES} bytes]`, truncated: true };
121
+ }
122
+ function extractAdvertisedPeerTools(describe) {
123
+ const names = new Set();
124
+ const add = (value) => {
125
+ if (typeof value === 'string') {
126
+ const name = value.trim();
127
+ if (TOOL_NAME_RE.test(name) && !isDeniedToolName(name)) {
128
+ names.add(name);
129
+ }
130
+ return;
131
+ }
132
+ if (Array.isArray(value)) {
133
+ for (const item of value)
134
+ add(item);
135
+ }
136
+ };
137
+ if (!describe || typeof describe !== 'object')
138
+ return [];
139
+ const payload = describe;
140
+ add(payload.peerTools);
141
+ add(payload.tools);
142
+ add(payload.peerToolAllowlist);
143
+ if (payload.capabilities && typeof payload.capabilities === 'object') {
144
+ const caps = payload.capabilities;
145
+ add(caps.peerTools);
146
+ add(caps.tools);
147
+ }
148
+ return [...names];
149
+ }
150
+ async function isToolAllowedOnPeer(toolName, request) {
151
+ if (DEFAULT_PEER_TOOL_INVOKE_TOOLS.includes(toolName)) {
152
+ return { allowed: true, advertised: [...DEFAULT_PEER_TOOL_INVOKE_TOOLS] };
153
+ }
154
+ if (isDeniedToolName(toolName)) {
155
+ return { allowed: false, advertised: [...DEFAULT_PEER_TOOL_INVOKE_TOOLS] };
156
+ }
157
+ if (!trustDescribeExtras()) {
158
+ return { allowed: false, advertised: [...DEFAULT_PEER_TOOL_INVOKE_TOOLS] };
159
+ }
160
+ try {
161
+ const described = await request('peer.describe', {}, { timeoutMs: DESCRIBE_TIMEOUT_MS });
162
+ const advertised = extractAdvertisedPeerTools(described);
163
+ return { allowed: advertised.includes(toolName), advertised };
164
+ }
165
+ catch (err) {
166
+ return {
167
+ allowed: false,
168
+ advertised: [...DEFAULT_PEER_TOOL_INVOKE_TOOLS],
169
+ describeError: err instanceof Error ? err.message : String(err),
170
+ };
171
+ }
172
+ }
173
+ function mapRemoteError(peer, err, timeoutMs) {
174
+ const message = err instanceof Error ? err.message : String(err);
175
+ const code = err.code ?? '';
176
+ const combined = `${code} ${message}`;
177
+ if (code === 'ROLE_LEAF' || combined.includes('ROLE_LEAF')) {
178
+ return {
179
+ success: false,
180
+ error: `Peer "${peer}" refused: it is configured as a leaf peer and cannot accept outbound peer.tool.invoke from this caller.`,
181
+ };
182
+ }
183
+ if (code === 'MAX_DEPTH_EXCEEDED' || combined.includes('MAX_DEPTH_EXCEEDED')) {
184
+ return {
185
+ success: false,
186
+ error: `Peer "${peer}" refused: call chain depth exceeded (MAX_DEPTH_EXCEEDED).`,
187
+ };
188
+ }
189
+ if (code === 'REQUEST_TIMEOUT' || combined.includes('REQUEST_TIMEOUT')) {
190
+ return {
191
+ success: false,
192
+ error: `Peer "${peer}" did not respond to peer.tool.invoke within ${timeoutMs}ms.`,
193
+ };
194
+ }
195
+ if (code === 'DISCONNECTED' || combined.includes('DISCONNECTED')) {
196
+ return {
197
+ success: false,
198
+ error: `Peer "${peer}" disconnected during peer.tool.invoke.`,
199
+ };
200
+ }
201
+ if (code === 'NOT_AUTHENTICATED' || code === 'NOT_OPEN') {
202
+ return {
203
+ success: false,
204
+ error: `Peer "${peer}" is not currently connected. Use list_peers to check connection status.`,
205
+ };
206
+ }
207
+ if (combined.includes('TOOL_NOT_ALLOWED_FOR_PEER_INVOKE')) {
208
+ return {
209
+ success: false,
210
+ error: `Peer "${peer}" refused: tool is not in the peer-invoke allowlist.`,
211
+ };
212
+ }
213
+ if (combined.includes('TOOL_NOT_FLEET_SAFE')) {
214
+ return {
215
+ success: false,
216
+ error: `Peer "${peer}" refused: tool is not fleetSafe.`,
217
+ };
218
+ }
219
+ if (combined.includes('PEER_WORKSPACE_NOT_CONFIGURED')) {
220
+ return {
221
+ success: false,
222
+ error: `Peer "${peer}" refused: peer workspace is not configured for remote tool invoke.`,
223
+ };
224
+ }
225
+ if (combined.includes('PATH_OUTSIDE_PEER_WORKSPACE')) {
226
+ return {
227
+ success: false,
228
+ error: `Peer "${peer}" refused: path is outside the peer workspace.`,
229
+ };
230
+ }
231
+ if (combined.includes('UNKNOWN_PEER_TOOL')) {
232
+ return {
233
+ success: false,
234
+ error: `Peer "${peer}" refused: unknown peer tool.`,
235
+ };
236
+ }
237
+ if (combined.includes('PEER_SCOPE_DENIED')) {
238
+ return {
239
+ success: false,
240
+ error: `Peer "${peer}" refused: peer scope does not permit this tool.`,
241
+ };
242
+ }
243
+ if (combined.includes('METHOD_NOT_FOUND')) {
244
+ return {
245
+ success: false,
246
+ error: `Peer "${peer}" refused: peer.tool.invoke is not available (METHOD_NOT_FOUND).`,
247
+ };
248
+ }
249
+ if (combined.includes('INVALID_PARAMS')) {
250
+ return {
251
+ success: false,
252
+ error: `Peer "${peer}" refused: invalid peer.tool.invoke params.`,
253
+ };
254
+ }
255
+ if (combined.includes('RATE_LIMITED')) {
256
+ return {
257
+ success: false,
258
+ error: `Peer "${peer}" refused: rate limited.`,
259
+ };
260
+ }
261
+ const safe = redactPeerToolInvokeError(message);
262
+ return {
263
+ success: false,
264
+ error: `Peer "${peer}" failed: ${safe || 'unrecognized error'}.`,
265
+ };
266
+ }
267
+ async function withLocalTimeout(work, timeoutMs) {
268
+ let timer;
269
+ const timeout = new Promise((_, reject) => {
270
+ timer = setTimeout(() => {
271
+ const err = new Error(`peer.invoke REQUEST_TIMEOUT: peer.tool.invoke did not respond within ${timeoutMs}ms`);
272
+ err.code = 'REQUEST_TIMEOUT';
273
+ reject(err);
274
+ }, timeoutMs);
275
+ timer.unref?.();
276
+ });
277
+ try {
278
+ return await Promise.race([work, timeout]);
279
+ }
280
+ finally {
281
+ if (timer)
282
+ clearTimeout(timer);
283
+ }
284
+ }
285
+ export async function executePeerToolInvoke(params) {
286
+ if (process.env.CODEBUDDY_PEER_ROLE === 'leaf') {
287
+ return {
288
+ success: false,
289
+ error: 'This Code Buddy is configured as a leaf peer (CODEBUDDY_PEER_ROLE=leaf) ' +
290
+ 'and cannot invoke tools on other peers. Ask a non-leaf peer to run peer_tool_invoke.',
291
+ };
292
+ }
293
+ if (!params.peer || typeof params.peer !== 'string') {
294
+ return { success: false, error: 'peer_tool_invoke: "peer" parameter is required (string).' };
295
+ }
296
+ if (params.peer.length > MAX_PEER_ID_LENGTH || !PEER_ID_RE.test(params.peer)) {
297
+ return {
298
+ success: false,
299
+ error: 'peer_tool_invoke: "peer" must be 1–128 characters matching [A-Za-z0-9._-].',
300
+ };
301
+ }
302
+ if (!params.tool || typeof params.tool !== 'string') {
303
+ return { success: false, error: 'peer_tool_invoke: "tool" parameter is required (string).' };
304
+ }
305
+ if (params.tool.length > MAX_TOOL_NAME_LENGTH || !TOOL_NAME_RE.test(params.tool)) {
306
+ return {
307
+ success: false,
308
+ error: `peer_tool_invoke: "tool" must be a lowercase identifier up to ${MAX_TOOL_NAME_LENGTH} characters ` +
309
+ `(got ${JSON.stringify(params.tool.slice(0, 80))}). ` +
310
+ `Known read-only tools: ${DEFAULT_PEER_TOOL_INVOKE_TOOLS.join(', ')}.`,
311
+ };
312
+ }
313
+ if (params.args !== undefined && !isFlatToolArgs(params.args)) {
314
+ return {
315
+ success: false,
316
+ error: 'peer_tool_invoke: "args" must be a flat object of string/number/boolean values ' +
317
+ 'with safe keys (no nested objects/arrays, no __proto__/constructor). ' +
318
+ 'Paths are forwarded as given (not resolved on this host).',
319
+ };
320
+ }
321
+ const timeoutMs = clampPeerToolInvokeTimeout(params.timeoutMs);
322
+ const args = params.args ? { ...params.args } : {};
323
+ if (argsByteLength(args) > MAX_ARGS_BYTES) {
324
+ return {
325
+ success: false,
326
+ error: `peer_tool_invoke: "args" is too large (max ${MAX_ARGS_BYTES} bytes).`,
327
+ };
328
+ }
329
+ const reg = getFleetRegistry();
330
+ if (reg.size() === 0) {
331
+ return {
332
+ success: false,
333
+ error: 'No fleet peers connected. Ask the user to run /fleet listen <ws-url> --name <id> first ' +
334
+ 'to add a peer Code Buddy to the fleet.',
335
+ };
336
+ }
337
+ const entry = reg.get(params.peer);
338
+ if (!entry) {
339
+ const ids = reg.ids().join(', ') || '(none)';
340
+ return {
341
+ success: false,
342
+ error: `Peer "${params.peer}" not found. Connected peers: ${ids}. ` +
343
+ `Use list_peers to see details.`,
344
+ };
345
+ }
346
+ if (typeof entry.listener.invokeTool !== 'function') {
347
+ return {
348
+ success: false,
349
+ error: `Peer "${params.peer}" listener has no invokeTool (peer.tool.invoke unavailable). ` +
350
+ `Use list_peers and try a peer whose listener supports remote read-only tools.`,
351
+ };
352
+ }
353
+ const allowed = await isToolAllowedOnPeer(params.tool, (method, requestParams, options) => entry.listener.request(method, requestParams, options));
354
+ if (!allowed.allowed) {
355
+ const extra = allowed.describeError
356
+ ? ` peer.describe failed: ${redactPeerToolInvokeError(allowed.describeError) || 'unrecognized error'}.`
357
+ : trustDescribeExtras()
358
+ ? ` Advertised extra tools: ${allowed.advertised.filter((n) => !DEFAULT_PEER_TOOL_INVOKE_TOOLS.includes(n)).join(', ') || '(none)'}.`
359
+ : ' Extra names from peer.describe require CODEBUDDY_PEER_TRUST_DESCRIBE=true.';
360
+ return {
361
+ success: false,
362
+ error: `peer_tool_invoke: tool "${params.tool}" is not in the local read-only set ` +
363
+ `(${DEFAULT_PEER_TOOL_INVOKE_TOOLS.join(', ')}) and was not advertised by peer.describe.${extra}`,
364
+ };
365
+ }
366
+ const t0 = Date.now();
367
+ try {
368
+ // keep listener this — call invokeTool on the listener object, never unbound.
369
+ const payload = await withLocalTimeout(entry.listener.invokeTool(params.tool, args, { timeoutMs }), timeoutMs);
370
+ const elapsedMs = Date.now() - t0;
371
+ const rawOutput = typeof payload?.output === 'string' ? payload.output : JSON.stringify(payload ?? {});
372
+ const { output, truncated } = truncateOutput(rawOutput);
373
+ const data = {
374
+ peer: params.peer,
375
+ tool: payload?.tool ?? params.tool,
376
+ output,
377
+ durationMs: payload?.durationMs ?? elapsedMs,
378
+ truncated: truncated || payload?.truncated === true,
379
+ elapsedMs,
380
+ };
381
+ return {
382
+ success: true,
383
+ output: [`[peer: ${params.peer}] [tool: ${params.tool}] [${elapsedMs}ms]`, output].join('\n'),
384
+ data,
385
+ };
386
+ }
387
+ catch (err) {
388
+ logger.debug('[peer-tool-invoke-tool] peer.tool.invoke error', {
389
+ peer: params.peer,
390
+ tool: params.tool,
391
+ message: err instanceof Error ? err.message : String(err),
392
+ });
393
+ return mapRemoteError(params.peer, err, timeoutMs);
394
+ }
395
+ }
396
+ //# sourceMappingURL=peer-tool-invoke-tool.js.map
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Fleet Tool Adapters — Phase (d).17.
3
3
  *
4
- * ITool-compliant wrappers for `peer_delegate` and `list_peers`.
5
- * Both tools are explicitly NOT fleetSafe — they're outbound from the
6
- * caller; inbound peers run their own gating via the A2A executor.
4
+ * ITool-compliant wrappers for `peer_delegate`, `peer_tool_invoke`,
5
+ * `list_peers`, and `fleet_room`. Fleet tools are explicitly NOT fleetSafe —
6
+ * they're outbound from the caller; inbound peers run their own gating.
7
7
  */
8
8
  import type { ToolResult } from '../../types/index.js';
9
9
  import type { ITool, ToolSchema, IToolMetadata, IValidationResult } from './types.js';
@@ -16,6 +16,15 @@ export declare class PeerDelegateTool implements ITool {
16
16
  getMetadata(): IToolMetadata;
17
17
  isAvailable(): boolean;
18
18
  }
19
+ export declare class PeerToolInvokeTool implements ITool {
20
+ readonly name = "peer_tool_invoke";
21
+ readonly description: string;
22
+ execute(input: Record<string, unknown>): Promise<ToolResult>;
23
+ getSchema(): ToolSchema;
24
+ validate(input: unknown): IValidationResult;
25
+ getMetadata(): IToolMetadata;
26
+ isAvailable(): boolean;
27
+ }
19
28
  export declare class ListPeersTool implements ITool {
20
29
  readonly name = "list_peers";
21
30
  readonly description: string;
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Fleet Tool Adapters — Phase (d).17.
3
3
  *
4
- * ITool-compliant wrappers for `peer_delegate` and `list_peers`.
5
- * Both tools are explicitly NOT fleetSafe — they're outbound from the
6
- * caller; inbound peers run their own gating via the A2A executor.
4
+ * ITool-compliant wrappers for `peer_delegate`, `peer_tool_invoke`,
5
+ * `list_peers`, and `fleet_room`. Fleet tools are explicitly NOT fleetSafe —
6
+ * they're outbound from the caller; inbound peers run their own gating.
7
7
  */
8
8
  import { executePeerDelegate } from '../peer-delegate-tool.js';
9
9
  import { executePeerChain } from '../peer-chain-tool.js';
@@ -11,6 +11,7 @@ import { executeListPeers } from '../list-peers-tool.js';
11
11
  import { executeFleetRoom, fleetRoomInputSchema } from '../fleet-room-tool.js';
12
12
  import { FLEET_ROOM_TOOL_DEF } from '../../codebuddy/fleet-tool-defs.js';
13
13
  import { executeRoutePeer } from '../route-peer-tool.js';
14
+ import { PEER_TOOL_INVOKE_DESCRIPTION, PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS, executePeerToolInvoke, isFlatToolArgs, } from '../peer-tool-invoke-tool.js';
14
15
  import { FLEET_DISPATCH_PROFILES, FLEET_DISPATCH_PROFILE_GUIDANCE_TEXT, isFleetDispatchProfile, } from '../../fleet/dispatch-profile.js';
15
16
  const DISPATCH_PROFILE_PARAMETER_DESCRIPTION = 'Optional Fleet dispatch profile. When set, Code Buddy carries the operating posture ' +
16
17
  'through peer.chat and returns peer-side policy metadata when supported. Selection guide: ' +
@@ -128,6 +129,110 @@ export class PeerDelegateTool {
128
129
  return true;
129
130
  }
130
131
  }
132
+ export class PeerToolInvokeTool {
133
+ name = 'peer_tool_invoke';
134
+ description = PEER_TOOL_INVOKE_DESCRIPTION;
135
+ async execute(input) {
136
+ return executePeerToolInvoke({
137
+ peer: typeof input.peer === 'string' ? input.peer : '',
138
+ tool: typeof input.tool === 'string' ? input.tool : '',
139
+ args: input.args,
140
+ timeoutMs: typeof input.timeoutMs === 'number' ? input.timeoutMs : undefined,
141
+ });
142
+ }
143
+ getSchema() {
144
+ return {
145
+ name: this.name,
146
+ description: this.description,
147
+ parameters: {
148
+ type: 'object',
149
+ properties: {
150
+ peer: {
151
+ type: 'string',
152
+ minLength: 1,
153
+ description: PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS.peer,
154
+ },
155
+ tool: {
156
+ type: 'string',
157
+ minLength: 1,
158
+ description: PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS.tool,
159
+ },
160
+ args: {
161
+ type: 'object',
162
+ description: PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS.args,
163
+ properties: {
164
+ path: {
165
+ type: 'string',
166
+ description: 'Peer-relative path, e.g. "oracle.txt" (accepted by view_file and list_directory).',
167
+ },
168
+ file_path: {
169
+ type: 'string',
170
+ description: 'Alias of path for view_file.',
171
+ },
172
+ query: {
173
+ type: 'string',
174
+ description: 'Search query when tool is search.',
175
+ },
176
+ },
177
+ },
178
+ timeoutMs: {
179
+ type: 'number',
180
+ description: PEER_TOOL_INVOKE_PARAM_DESCRIPTIONS.timeoutMs,
181
+ },
182
+ },
183
+ required: ['peer', 'tool'],
184
+ },
185
+ };
186
+ }
187
+ validate(input) {
188
+ if (typeof input !== 'object' || input === null) {
189
+ return { valid: false, errors: ['Input must be an object'] };
190
+ }
191
+ const inp = input;
192
+ const errors = [];
193
+ const keys = Object.keys(inp).join(',') || '(none)';
194
+ if (typeof inp.peer !== 'string' || !inp.peer) {
195
+ errors.push(`peer is required (string); received keys: ${keys}`);
196
+ }
197
+ if (typeof inp.tool !== 'string' || !inp.tool) {
198
+ errors.push(`tool is required (string); received keys: ${keys}`);
199
+ }
200
+ if (inp.args !== undefined && !isFlatToolArgs(inp.args)) {
201
+ errors.push('args must be a flat object of string/number/boolean values');
202
+ }
203
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
204
+ }
205
+ getMetadata() {
206
+ return {
207
+ name: this.name,
208
+ description: this.description,
209
+ category: 'utility',
210
+ keywords: [
211
+ 'peer',
212
+ 'tool',
213
+ 'invoke',
214
+ 'fleet',
215
+ 'view_file',
216
+ 'list_directory',
217
+ 'search',
218
+ 'read',
219
+ 'remote',
220
+ 'workspace',
221
+ 'allowlist',
222
+ 'file',
223
+ 'oracle',
224
+ ],
225
+ priority: 8,
226
+ modifiesFiles: false,
227
+ makesNetworkRequests: true,
228
+ effect: 'emission',
229
+ fleetSafe: false,
230
+ };
231
+ }
232
+ isAvailable() {
233
+ return true;
234
+ }
235
+ }
131
236
  export class ListPeersTool {
132
237
  name = 'list_peers';
133
238
  description = 'List all connected fleet peers with their status (last seen, compacting, peer chat availability). ' +
@@ -500,7 +605,14 @@ export class FleetRoomTool {
500
605
  isAvailable() { return true; }
501
606
  }
502
607
  export function createFleetTools() {
503
- return [new PeerDelegateTool(), new PeerChainTool(), new ListPeersTool(), new RoutePeerTool(), new FleetRoomTool()];
608
+ return [
609
+ new PeerDelegateTool(),
610
+ new PeerToolInvokeTool(),
611
+ new PeerChainTool(),
612
+ new ListPeersTool(),
613
+ new RoutePeerTool(),
614
+ new FleetRoomTool(),
615
+ ];
504
616
  }
505
617
  export function resetFleetToolInstances() {
506
618
  // Stateless adapter classes — nothing to reset.
@@ -76,7 +76,7 @@ export { VerifyTool, createVerifyTools, resetVerifyInstances, setVerifyToolProvi
76
76
  export type { VerifyToolProvider, VerifyLlmCall, VerifyExecuteTool, } from './verify-tools.js';
77
77
  export { DelegateAgentTool, createDelegateAgentTools, resetDelegateAgentInstances, setDelegateAgentProvider, resetDelegateAgentProvider, } from './delegate-agent-tools.js';
78
78
  export type { DelegateAgentProvider, DelegateLlmCall, DelegateExecuteTool, } from './delegate-agent-tools.js';
79
- export { PeerDelegateTool, PeerChainTool, ListPeersTool, RoutePeerTool, createFleetTools, resetFleetToolInstances, } from './fleet-tools.js';
79
+ export { PeerDelegateTool, PeerToolInvokeTool, PeerChainTool, ListPeersTool, RoutePeerTool, createFleetTools, resetFleetToolInstances, } from './fleet-tools.js';
80
80
  export { CodeExplorerAskTool, createCodeExplorerTools, resetCodeExplorerInstances, } from './code-explorer-tools.js';
81
81
  export { ScreenMemoryTool, createScreenpipeTools, resetScreenpipeInstances, } from './screenpipe-tools.js';
82
82
  export { AskUserQuestionExecuteTool, createAskUserQuestionTools, resetAskUserQuestionInstances, } from './ask-user-question-tools.js';
@@ -129,8 +129,8 @@ export { AdvisorExecuteTool, createAdvisorTools, resetAdvisorInstances, setAdvis
129
129
  export { VerifyTool, createVerifyTools, resetVerifyInstances, setVerifyToolProvider, resetVerifyToolProvider, } from './verify-tools.js';
130
130
  // Tool Adapters - Delegate Agent (reaches the built-in specialized agents)
131
131
  export { DelegateAgentTool, createDelegateAgentTools, resetDelegateAgentInstances, setDelegateAgentProvider, resetDelegateAgentProvider, } from './delegate-agent-tools.js';
132
- // Tool Adapters - Fleet (peer_delegate, list_peers, route_peer)
133
- export { PeerDelegateTool, PeerChainTool, ListPeersTool, RoutePeerTool, createFleetTools, resetFleetToolInstances, } from './fleet-tools.js';
132
+ // Tool Adapters - Fleet (peer_delegate, peer_tool_invoke, list_peers, route_peer)
133
+ export { PeerDelegateTool, PeerToolInvokeTool, PeerChainTool, ListPeersTool, RoutePeerTool, createFleetTools, resetFleetToolInstances, } from './fleet-tools.js';
134
134
  // Tool Adapters - CodeExplorer
135
135
  export { CodeExplorerAskTool, createCodeExplorerTools, resetCodeExplorerInstances, } from './code-explorer-tools.js';
136
136
  // Tool Adapters - Screenpipe (screen_memory)
@@ -266,17 +266,17 @@ export declare const UserSettingsSchema: z.ZodObject<{
266
266
  theme: "default" | "minimal" | "auto" | "dark" | "light" | "neon" | "pastel" | "matrix" | "ocean" | "sunset" | "high-contrast";
267
267
  language: string;
268
268
  model?: string | undefined;
269
- shell?: string | undefined;
270
269
  baseURL?: string | undefined;
270
+ shell?: string | undefined;
271
271
  apiKey?: string | undefined;
272
272
  editor?: string | undefined;
273
273
  }, {
274
274
  provider?: "custom" | "xai" | "anthropic" | "google" | "openai" | "lmstudio" | "ollama" | "local" | "mistral" | "gemini" | "grok" | "lm-studio" | "minimax" | "moonshot" | "claude" | "vllm" | "deepseek" | "qwen" | "openrouter" | "groq" | "together" | "fireworks" | "agy-cli" | "chatgpt" | "lemonade" | "ollama-cloud" | "novita" | "zai" | "kimi-coding" | "kimi-coding-cn" | "arcee" | "gmi" | "minimax-cn" | "alibaba" | "alibaba-coding-plan" | "kilocode" | "xiaomi" | "tencent-tokenhub" | "opencode-zen" | "opencode-go" | "huggingface" | "nvidia" | "omniroute" | "ai21" | "ant-ling" | "cerebras" | "cohere" | "deepinfra" | "featherless-ai" | "friendliai" | "hyperbolic" | "inception" | "inference-net" | "internlm" | "liquid" | "longcat" | "modelscope" | "nscale" | "openadapter" | "pioneer" | "reka" | "sambanova" | "sarvam" | "scaleway" | "tokenrouter" | "typhoon" | "zenmux" | "stepfun" | "azure" | "bedrock" | "copilot" | "novita-ai" | "glm" | "kimi" | "kimi-cn" | "moonshot-cn" | "arcee-ai" | "gmi-cloud" | "gmicloud" | "dashscope" | "alibaba-coding" | "kilo-code" | "mimo" | "tencent" | "tokenhub" | "opencode" | "hf" | "nvidia-nim" | undefined;
275
275
  model?: string | undefined;
276
+ baseURL?: string | undefined;
276
277
  models?: string[] | undefined;
277
278
  shell?: string | undefined;
278
279
  defaultModel?: string | undefined;
279
- baseURL?: string | undefined;
280
280
  apiKey?: string | undefined;
281
281
  theme?: "default" | "minimal" | "auto" | "dark" | "light" | "neon" | "pastel" | "matrix" | "ocean" | "sunset" | "high-contrast" | undefined;
282
282
  editor?: string | undefined;
@@ -11,6 +11,14 @@
11
11
  * Intercepts signals: SIGINT (Ctrl+C), SIGTERM (kill), SIGHUP (terminal closed)
12
12
  */
13
13
  import { type Disposable } from './disposable.js';
14
+ /**
15
+ * Exit code after a shutdown signal. Only an explicit headless run (`-p`, loop,
16
+ * try: CODEBUDDY_HEADLESS=true) reports SIGINT as 130 (USER_CANCELLED), so a
17
+ * script can tell an interruption from success. Servers and daemons whose stdout
18
+ * is merely not a TTY (systemd) keep exit 0: a normal SIGTERM stop must not be
19
+ * reported as a failure and trigger Restart=on-failure.
20
+ */
21
+ export declare function signalExitCode(signal: string, env?: NodeJS.ProcessEnv): number;
14
22
  export interface ShutdownOptions {
15
23
  /** Maximum time to wait for shutdown in milliseconds (default: 30000) */
16
24
  timeoutMs: number;