@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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.
- package/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/supervisor.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import { BoardReads } from './board-reads.js';
|
|
2
|
+
import { takeImages } from './mentions.js';
|
|
3
|
+
import { HostBreaker, callThroughBreaker } from './host-breaker.js';
|
|
1
4
|
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
5
|
import { execFileSync } from 'node:child_process';
|
|
3
6
|
import { EventEmitter } from 'node:events';
|
|
4
|
-
import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync } from 'node:fs';
|
|
7
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, readlinkSync, rmSync } from 'node:fs';
|
|
5
8
|
import { join, resolve } from 'node:path';
|
|
6
9
|
import {
|
|
7
10
|
CAPABILITIES,
|
|
@@ -32,6 +35,7 @@ import type { ManagedWork } from './managed.js';
|
|
|
32
35
|
import type { McpWork, McpWorkerBinding } from './mcp-work.js';
|
|
33
36
|
import type { StationWork, StationBinding } from './station-work.js';
|
|
34
37
|
import type { ToolkitWork, ToolkitWorkerBinding } from './toolkit-work.js';
|
|
38
|
+
import { mergeMcpProvenance, type McpServerProvenance, type OperatorMcp } from './operator-mcp.js';
|
|
35
39
|
import type { ChildWork } from './children.js';
|
|
36
40
|
import {
|
|
37
41
|
OFFLINE_ROUTE,
|
|
@@ -81,6 +85,8 @@ export interface SupervisorOptions {
|
|
|
81
85
|
}
|
|
82
86
|
interface Worker {
|
|
83
87
|
mcp?: McpWorkerBinding;
|
|
88
|
+
operatorMcp?: McpServerProvenance[];
|
|
89
|
+
mcpServers?: McpServerProvenance[];
|
|
84
90
|
toolkit?: ToolkitWorkerBinding;
|
|
85
91
|
station?: StationBinding;
|
|
86
92
|
secrets?: string[];
|
|
@@ -105,6 +111,28 @@ interface Worker {
|
|
|
105
111
|
resolve: () => void;
|
|
106
112
|
reject: (error: Error) => void;
|
|
107
113
|
}
|
|
114
|
+
/** Errors a local tool call returns to the model as feedback; any other one fails the worker.
|
|
115
|
+
* Every code src/local-edits.ts can throw belongs here (a failed replacement is not a crash). */
|
|
116
|
+
export const LOCAL_TOOL_ERRORS = [
|
|
117
|
+
'local_busy',
|
|
118
|
+
'workspace_read_failed',
|
|
119
|
+
'unsafe_path',
|
|
120
|
+
'edit_mismatch',
|
|
121
|
+
'workspace_changed',
|
|
122
|
+
'workspace_busy',
|
|
123
|
+
'workspace_limit',
|
|
124
|
+
'workspace_conflict',
|
|
125
|
+
'not_found',
|
|
126
|
+
'binary_file',
|
|
127
|
+
'line_limit',
|
|
128
|
+
'invalid_field',
|
|
129
|
+
'request_conflict',
|
|
130
|
+
'command_conflict',
|
|
131
|
+
'command_limit',
|
|
132
|
+
'invalid_cursor',
|
|
133
|
+
'shell_binding',
|
|
134
|
+
'listen_unreachable',
|
|
135
|
+
];
|
|
108
136
|
export function processIdentity(pid: number): string | null {
|
|
109
137
|
if (!Number.isSafeInteger(pid) || pid < 1) return null;
|
|
110
138
|
try {
|
|
@@ -126,7 +154,15 @@ export function processIdentity(pid: number): string | null {
|
|
|
126
154
|
};
|
|
127
155
|
const before = stat(),
|
|
128
156
|
boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim(),
|
|
129
|
-
|
|
157
|
+
// A tools-user launcher (sudo) is root-owned; its exe link is unreadable to the service.
|
|
158
|
+
executable = (() => {
|
|
159
|
+
try {
|
|
160
|
+
return readlinkSync(path + '/exe');
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if ((error as NodeJS.ErrnoException).code === 'EACCES') return 'unreadable';
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
})(),
|
|
130
166
|
argv = readFileSync(path + '/cmdline');
|
|
131
167
|
if (
|
|
132
168
|
!/^[a-f0-9-]{36}$/.test(boot) ||
|
|
@@ -153,6 +189,33 @@ export function processIdentity(pid: number): string | null {
|
|
|
153
189
|
return null;
|
|
154
190
|
}
|
|
155
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* When a process started, keyed to this boot. Unlike processIdentity it survives exec, so it
|
|
194
|
+
* names a background task whose gated shell has since become the user's command.
|
|
195
|
+
*/
|
|
196
|
+
export function processStart(pid: number): string | null {
|
|
197
|
+
if (!Number.isSafeInteger(pid) || pid < 1) return null;
|
|
198
|
+
try {
|
|
199
|
+
if (process.platform === 'linux') {
|
|
200
|
+
const value = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
201
|
+
const fields = value
|
|
202
|
+
.slice(value.lastIndexOf(') ') + 2)
|
|
203
|
+
.trim()
|
|
204
|
+
.split(/\s+/);
|
|
205
|
+
const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
206
|
+
return /^\d+$/.test(fields[19] ?? '') ? `linux-start-v1:${pid}:${boot}:${fields[19]}` : null;
|
|
207
|
+
}
|
|
208
|
+
const started = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
211
|
+
timeout: 2000,
|
|
212
|
+
killSignal: 'SIGKILL',
|
|
213
|
+
}).trim();
|
|
214
|
+
return started ? `ps-start-v1:${pid}:${started}` : null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
156
219
|
function groupExists(pid: number): boolean {
|
|
157
220
|
try {
|
|
158
221
|
process.kill(-pid, 0);
|
|
@@ -164,10 +227,18 @@ function groupExists(pid: number): boolean {
|
|
|
164
227
|
}
|
|
165
228
|
export class Supervisor extends EventEmitter {
|
|
166
229
|
localCoding?: import('./local-coding.js').LocalCoding;
|
|
230
|
+
/** Built-in web search and fetch for interactive coding (operator ruling 2026-10-31). */
|
|
231
|
+
web?: import('./web-search.js').WebSearch;
|
|
232
|
+
/** Desk-session hooks (Phase 4b); absent means none run. */
|
|
233
|
+
hooks?: import('./hooks.js').Hooks;
|
|
167
234
|
tasks!: TaskContext;
|
|
168
235
|
private compactionJobs = new Set<Promise<void>>();
|
|
236
|
+
private readonly fetchBreaker = new HostBreaker();
|
|
169
237
|
managed?: ManagedWork;
|
|
170
238
|
mcp?: McpWork;
|
|
239
|
+
operatorMcp?: OperatorMcp;
|
|
240
|
+
/** Item 9: the host's pinned persona, given to every worker on this host. */
|
|
241
|
+
persona?: import('./persona.js').Persona;
|
|
171
242
|
toolkit?: ToolkitWork;
|
|
172
243
|
station?: StationWork;
|
|
173
244
|
children?: ChildWork;
|
|
@@ -222,8 +293,26 @@ export class Supervisor extends EventEmitter {
|
|
|
222
293
|
!!this.station,
|
|
223
294
|
this.store.localWorkspace(id),
|
|
224
295
|
this.store.localCoding(id),
|
|
296
|
+
this.declaredMcp(id),
|
|
225
297
|
);
|
|
226
298
|
}
|
|
299
|
+
/** Operator-declared MCP reaches root sessions only; children get no MCP authority. */
|
|
300
|
+
private declaredMcp(id: string): boolean {
|
|
301
|
+
return !!this.operatorMcp && !this.operatorMcp.empty && !this.children?.owned(id);
|
|
302
|
+
}
|
|
303
|
+
/** Every MCP server the session got, from both sources; persisted beside the worker. */
|
|
304
|
+
mcpServers(id: string): McpServerProvenance[] | undefined {
|
|
305
|
+
const live = this.workers.get(id)?.mcpServers;
|
|
306
|
+
if (live) return structuredClone(live);
|
|
307
|
+
try {
|
|
308
|
+
const path = join(this.options.stateDir, 'workers', id, 'mcp-servers.json');
|
|
309
|
+
return existsSync(path)
|
|
310
|
+
? (JSON.parse(readFileSync(path, 'utf8')) as McpServerProvenance[])
|
|
311
|
+
: undefined;
|
|
312
|
+
} catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
227
316
|
private childBoundary(id: string, epoch: number) {
|
|
228
317
|
const child = this.children?.owned(id);
|
|
229
318
|
return child ? this.store.workerBoundary.assert(child, epoch, this.routeFor(id)) : undefined;
|
|
@@ -255,6 +344,10 @@ export class Supervisor extends EventEmitter {
|
|
|
255
344
|
}
|
|
256
345
|
readonly bootId = randomUUID();
|
|
257
346
|
private workers = new Map<string, Worker>();
|
|
347
|
+
private boardReadsStore?: BoardReads;
|
|
348
|
+
get boardReads(): BoardReads {
|
|
349
|
+
return (this.boardReadsStore ??= new BoardReads(this.store));
|
|
350
|
+
}
|
|
258
351
|
private monitor: NodeJS.Timeout;
|
|
259
352
|
readonly bundle: string;
|
|
260
353
|
constructor(
|
|
@@ -406,6 +499,20 @@ export class Supervisor extends EventEmitter {
|
|
|
406
499
|
const child = this.children?.owned(id);
|
|
407
500
|
if (this.mcp && !child) worker.mcp = await this.mcp.ready(id);
|
|
408
501
|
if (this.toolkit && !child) worker.toolkit = await this.toolkit.ready(id);
|
|
502
|
+
if (this.declaredMcp(id)) worker.operatorMcp = this.operatorMcp!.provenance();
|
|
503
|
+
if (worker.operatorMcp || worker.mcp) {
|
|
504
|
+
// Collisions are refused at admission; nothing is silently shadowed.
|
|
505
|
+
worker.mcpServers = mergeMcpProvenance(
|
|
506
|
+
worker.operatorMcp ?? [],
|
|
507
|
+
(worker.mcp?.servers ?? []).map((server) => ({
|
|
508
|
+
name: server.name,
|
|
509
|
+
source: 'agentpod' as const,
|
|
510
|
+
transport: 'http' as const,
|
|
511
|
+
endpoint_digest: server.endpoint_digest,
|
|
512
|
+
declaration_digest: digest(server),
|
|
513
|
+
})),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
409
516
|
if (this.station && !child) worker.station = this.station.ready(id);
|
|
410
517
|
this.store.execution.assert(id);
|
|
411
518
|
if (worker.stopping || worker.invalid) throw new Error('Worker startup cancelled');
|
|
@@ -421,6 +528,10 @@ export class Supervisor extends EventEmitter {
|
|
|
421
528
|
);
|
|
422
529
|
const sessionDir = join(workerDir, 'sessions');
|
|
423
530
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
531
|
+
const provenancePath = join(workerDir, 'mcp-servers.json');
|
|
532
|
+
if (worker.mcpServers)
|
|
533
|
+
writeFileSync(provenancePath, JSON.stringify(worker.mcpServers, null, 2), { mode: 0o600 });
|
|
534
|
+
else rmSync(provenancePath, { force: true });
|
|
424
535
|
const config = {
|
|
425
536
|
socket: this.options.bridgeSocket,
|
|
426
537
|
token: worker.token,
|
|
@@ -437,8 +548,11 @@ export class Supervisor extends EventEmitter {
|
|
|
437
548
|
local: this.store.localWorkspace(id),
|
|
438
549
|
localCoding: this.store.localCoding(id),
|
|
439
550
|
...(worker.mcp ? { mcp: worker.mcp } : {}),
|
|
551
|
+
...(worker.operatorMcp ? { operator_mcp: worker.operatorMcp } : {}),
|
|
552
|
+
...(worker.mcpServers ? { mcp_servers: worker.mcpServers } : {}),
|
|
440
553
|
...(worker.toolkit ? { toolkit: worker.toolkit } : {}),
|
|
441
554
|
...(worker.station ? { station: worker.station } : {}),
|
|
555
|
+
...(this.persona ? { persona: this.persona } : {}),
|
|
442
556
|
};
|
|
443
557
|
const args = [
|
|
444
558
|
PI_CLI,
|
|
@@ -498,6 +612,21 @@ export class Supervisor extends EventEmitter {
|
|
|
498
612
|
});
|
|
499
613
|
worker.pi.on('activity', (observation: import('./pi.js').ToolObservation) => {
|
|
500
614
|
if (!worker.attempt || worker.stopping) return;
|
|
615
|
+
// PostToolUse runs after the tool and cannot change it; it is recorded like any hook.
|
|
616
|
+
if (observation.phase === 'finished' && this.hooks) {
|
|
617
|
+
const input = parsedInput(observation.input);
|
|
618
|
+
void this.hook(
|
|
619
|
+
id,
|
|
620
|
+
'PostToolUse',
|
|
621
|
+
{
|
|
622
|
+
tool_name: observation.tool,
|
|
623
|
+
tool_input: input,
|
|
624
|
+
tool_use_id: observation.tool_call_id,
|
|
625
|
+
tool_response: { output: observation.output ?? '', failed: !!observation.failed },
|
|
626
|
+
},
|
|
627
|
+
[observation.tool, ...hookAliases(observation.tool, input)],
|
|
628
|
+
).catch(() => {});
|
|
629
|
+
}
|
|
501
630
|
try {
|
|
502
631
|
this.store.request(
|
|
503
632
|
`tool-activity:${id}:${epoch}:${worker.attempt}`,
|
|
@@ -689,6 +818,81 @@ export class Supervisor extends EventEmitter {
|
|
|
689
818
|
}
|
|
690
819
|
return w;
|
|
691
820
|
}
|
|
821
|
+
/** Is this attempt a task step that has acceptance checks to decide it? */
|
|
822
|
+
/** A person's own local coding session: not a delegated child and not AgentPod-admitted work,
|
|
823
|
+
* whose strict semantics stay as they are. */
|
|
824
|
+
isInteractiveCoding(id: string): boolean {
|
|
825
|
+
return this.interactiveCoding(id);
|
|
826
|
+
}
|
|
827
|
+
/** Run this desk session's hooks for `event`; other sessions (board, station, children) run none. */
|
|
828
|
+
private async hook(
|
|
829
|
+
id: string,
|
|
830
|
+
event: import('./hooks.js').HookEvent,
|
|
831
|
+
payload: Record<string, unknown>,
|
|
832
|
+
names: readonly string[] = [],
|
|
833
|
+
): Promise<import('./hooks.js').HookOutcome | null> {
|
|
834
|
+
if (!this.hooks || !this.interactiveCoding(id)) return null;
|
|
835
|
+
return this.hooks.fire(id, event, payload, names);
|
|
836
|
+
}
|
|
837
|
+
/** A PreToolUse hook that blocks turns the tool call into an error the model reads. */
|
|
838
|
+
private async preToolUse(
|
|
839
|
+
id: string,
|
|
840
|
+
tool: string,
|
|
841
|
+
input: Record<string, unknown>,
|
|
842
|
+
toolCallId: string,
|
|
843
|
+
): Promise<void> {
|
|
844
|
+
const out = await this.hook(
|
|
845
|
+
id,
|
|
846
|
+
'PreToolUse',
|
|
847
|
+
{ tool_name: tool, tool_input: input, tool_use_id: toolCallId },
|
|
848
|
+
[tool, ...hookAliases(tool, input)],
|
|
849
|
+
);
|
|
850
|
+
if (out?.blocked)
|
|
851
|
+
throw new ServiceError('hook_blocked', `Blocked by a PreToolUse hook: ${out.blocked}`);
|
|
852
|
+
}
|
|
853
|
+
private interactiveCoding(id: string): boolean {
|
|
854
|
+
return (
|
|
855
|
+
this.store.localCoding(id) &&
|
|
856
|
+
!this.children?.owned(id) &&
|
|
857
|
+
!this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id)
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
private checkedStepAttempt(attempt: string): boolean {
|
|
861
|
+
const row = this.store.db
|
|
862
|
+
.prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
|
|
863
|
+
.get(attempt) as { checks?: string } | undefined;
|
|
864
|
+
try {
|
|
865
|
+
const checks = row?.checks ? JSON.parse(String(row.checks)) : [];
|
|
866
|
+
return Array.isArray(checks) && checks.length > 0;
|
|
867
|
+
} catch {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/** A station call refused before it runs leaves no station_calls row, so record its
|
|
872
|
+
* method, requested action and refusal code: never its path, arguments or content. */
|
|
873
|
+
private recordStationRefusal(
|
|
874
|
+
id: string,
|
|
875
|
+
method: string,
|
|
876
|
+
body: Record<string, unknown>,
|
|
877
|
+
error: unknown,
|
|
878
|
+
): void {
|
|
879
|
+
const args =
|
|
880
|
+
body.args && typeof body.args === 'object' ? (body.args as Record<string, unknown>) : {};
|
|
881
|
+
const ident = (v: unknown) =>
|
|
882
|
+
typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v) ? v : null;
|
|
883
|
+
try {
|
|
884
|
+
this.store.transaction(() =>
|
|
885
|
+
this.store.event(id, 'station.call_refused', {
|
|
886
|
+
attempt_id: this.workers.get(id)?.attempt ?? null,
|
|
887
|
+
method: ident(method),
|
|
888
|
+
action: ident(args.action),
|
|
889
|
+
code: error instanceof ServiceError ? ident(error.code) : 'error',
|
|
890
|
+
}),
|
|
891
|
+
);
|
|
892
|
+
} catch {
|
|
893
|
+
// Recording a refusal never changes the refusal itself.
|
|
894
|
+
}
|
|
895
|
+
}
|
|
692
896
|
async bridge(
|
|
693
897
|
id: string,
|
|
694
898
|
method: string,
|
|
@@ -732,8 +936,14 @@ export class Supervisor extends EventEmitter {
|
|
|
732
936
|
context_digest: textDigest(w.frame),
|
|
733
937
|
...(boundary ? { worker_binding: boundary } : {}),
|
|
734
938
|
...(w.mcp ? { mcp: w.mcp } : {}),
|
|
939
|
+
...(w.operatorMcp ? { operator_mcp: w.operatorMcp } : {}),
|
|
940
|
+
...(w.mcpServers
|
|
941
|
+
? { mcp_servers: w.mcpServers, mcp_servers_digest: digest(w.mcpServers) }
|
|
942
|
+
: {}),
|
|
735
943
|
...(w.toolkit ? { toolkit: w.toolkit } : {}),
|
|
736
944
|
...(w.station ? { station: w.station } : {}),
|
|
945
|
+
// Item 9: the worker proves it runs with the host's pinned persona.
|
|
946
|
+
...(this.persona ? { persona_digest: this.persona.digest } : {}),
|
|
737
947
|
};
|
|
738
948
|
for (const [key, value] of Object.entries(expected))
|
|
739
949
|
if (canonical(body[key]) !== canonical(value))
|
|
@@ -839,6 +1049,58 @@ export class Supervisor extends EventEmitter {
|
|
|
839
1049
|
w.secrets = [...new Set([...(w.secrets ?? []), ...credentialStrings(auth)])];
|
|
840
1050
|
return auth;
|
|
841
1051
|
}
|
|
1052
|
+
if (method.startsWith('operator_mcp.')) {
|
|
1053
|
+
if (!this.declaredMcp(id) || !w.operatorMcp || !w.attempt)
|
|
1054
|
+
throw new ServiceError('mcp_disabled', 'Worker has no declared MCP servers', 403);
|
|
1055
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
1056
|
+
if (method === 'operator_mcp.catalog')
|
|
1057
|
+
return await this.operatorMcp!.catalog(id, body.server, body.tool);
|
|
1058
|
+
if (method !== 'operator_mcp.call')
|
|
1059
|
+
throw new ServiceError('invalid_field', 'Unknown declared MCP method', 400);
|
|
1060
|
+
const attempt = w.attempt;
|
|
1061
|
+
const args = body.arguments ?? {};
|
|
1062
|
+
const server = String(body.server ?? ''),
|
|
1063
|
+
tool = String(body.tool ?? '');
|
|
1064
|
+
// A host failing again and again is paused briefly (never called, so no operator MCP call
|
|
1065
|
+
// is recorded); the agent gets a tool error and the run carries on.
|
|
1066
|
+
return await callThroughBreaker(
|
|
1067
|
+
this.fetchBreaker,
|
|
1068
|
+
id,
|
|
1069
|
+
args,
|
|
1070
|
+
(paused) =>
|
|
1071
|
+
this.store.transaction(() =>
|
|
1072
|
+
this.store.event(id, 'fetch.host_paused', { attempt_id: attempt, ...paused }),
|
|
1073
|
+
),
|
|
1074
|
+
async () => {
|
|
1075
|
+
try {
|
|
1076
|
+
const result = await this.operatorMcp!.call(id, body.server, body.tool, args);
|
|
1077
|
+
this.store.transaction(() =>
|
|
1078
|
+
this.store.event(id, 'operator_mcp.call', {
|
|
1079
|
+
attempt_id: attempt,
|
|
1080
|
+
server,
|
|
1081
|
+
tool,
|
|
1082
|
+
argument_digest: digest(args),
|
|
1083
|
+
outcome: result.is_error ? 'tool_error' : 'returned',
|
|
1084
|
+
result_digest: digest(result),
|
|
1085
|
+
}),
|
|
1086
|
+
);
|
|
1087
|
+
return result;
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
this.store.transaction(() =>
|
|
1090
|
+
this.store.event(id, 'operator_mcp.call', {
|
|
1091
|
+
attempt_id: attempt,
|
|
1092
|
+
server,
|
|
1093
|
+
tool,
|
|
1094
|
+
argument_digest: digest(args),
|
|
1095
|
+
outcome: 'failed',
|
|
1096
|
+
error: (error as ServiceError).code ?? 'mcp_server_failed',
|
|
1097
|
+
}),
|
|
1098
|
+
);
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
842
1104
|
if (method.startsWith('toolkit.')) {
|
|
843
1105
|
if (!this.toolkit || !w.toolkit || !w.attempt || this.children?.owned(id))
|
|
844
1106
|
throw new ServiceError('toolkit_disabled', 'Worker has no native Toolkit authority', 403);
|
|
@@ -923,6 +1185,19 @@ export class Supervisor extends EventEmitter {
|
|
|
923
1185
|
: {}),
|
|
924
1186
|
};
|
|
925
1187
|
}
|
|
1188
|
+
if (method === 'board.read' || method === 'board.status') {
|
|
1189
|
+
// Read-only board reads, served by the hub (operator ruling 2026-10-28, option iii).
|
|
1190
|
+
if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
|
|
1191
|
+
throw new ServiceError('station_disabled', 'Board reads need protected station work');
|
|
1192
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
1193
|
+
return method === 'board.read'
|
|
1194
|
+
? this.boardReads.request(
|
|
1195
|
+
id,
|
|
1196
|
+
string(body.tool_call_id, 'tool_call_id', 512),
|
|
1197
|
+
object(body.args),
|
|
1198
|
+
)
|
|
1199
|
+
: this.boardReads.get(id, string(body.read_id, 'read_id', 64));
|
|
1200
|
+
}
|
|
926
1201
|
if (method.startsWith('station.')) {
|
|
927
1202
|
if (!this.station || !w.station || !w.attempt || this.children?.owned(id))
|
|
928
1203
|
throw new ServiceError('station_disabled', 'Station execution is unavailable');
|
|
@@ -955,6 +1230,83 @@ export class Supervisor extends EventEmitter {
|
|
|
955
1230
|
if (method === 'station.status') return this.station.view(c.id);
|
|
956
1231
|
throw new ServiceError('invalid_field', 'Unsupported station method');
|
|
957
1232
|
}
|
|
1233
|
+
if (method === 'web.search' || method === 'web.fetch') {
|
|
1234
|
+
if (!this.web || !this.store.localCoding(id) || this.children?.owned(id) || !w.attempt)
|
|
1235
|
+
throw new ServiceError('web_denied', 'Web search is not admitted for this session', 403);
|
|
1236
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
1237
|
+
const args = object(body.args);
|
|
1238
|
+
const toolCallId = string(body.tool_call_id, 'tool_call_id', 512);
|
|
1239
|
+
await this.preToolUse(
|
|
1240
|
+
id,
|
|
1241
|
+
method === 'web.search' ? 'salaam_web_search' : 'salaam_web_fetch',
|
|
1242
|
+
args,
|
|
1243
|
+
toolCallId,
|
|
1244
|
+
);
|
|
1245
|
+
if (method === 'web.search') {
|
|
1246
|
+
const out = await this.web.search({
|
|
1247
|
+
query: string(args.query, 'query', 300),
|
|
1248
|
+
...(typeof args.kind === 'string'
|
|
1249
|
+
? { kind: args.kind as import('./web-search.js').SearchKind }
|
|
1250
|
+
: {}),
|
|
1251
|
+
...(typeof args.limit === 'number' ? { limit: args.limit } : {}),
|
|
1252
|
+
});
|
|
1253
|
+
// Recorded: kind, backends tried, which answered and how long. Never a key or the query.
|
|
1254
|
+
this.store.transaction(() =>
|
|
1255
|
+
this.store.event(id, 'web.search', {
|
|
1256
|
+
attempt_id: w.attempt,
|
|
1257
|
+
tool_call_id: toolCallId,
|
|
1258
|
+
kind: out.kind,
|
|
1259
|
+
cached: !!out.cached,
|
|
1260
|
+
results: out.results.length,
|
|
1261
|
+
tried: out.tried,
|
|
1262
|
+
}),
|
|
1263
|
+
);
|
|
1264
|
+
return out;
|
|
1265
|
+
}
|
|
1266
|
+
const url = string(args.url, 'url', 2000);
|
|
1267
|
+
let host = '';
|
|
1268
|
+
try {
|
|
1269
|
+
host = new URL(url).hostname;
|
|
1270
|
+
} catch {
|
|
1271
|
+
/* fetchPage refuses it */
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
const page = await (await import('./web-fetch.js')).fetchPage(url);
|
|
1275
|
+
this.store.transaction(() =>
|
|
1276
|
+
this.store.event(id, 'web.fetch', {
|
|
1277
|
+
attempt_id: w.attempt,
|
|
1278
|
+
tool_call_id: toolCallId,
|
|
1279
|
+
host,
|
|
1280
|
+
status: page.status,
|
|
1281
|
+
chars: page.text.length,
|
|
1282
|
+
truncated: page.truncated,
|
|
1283
|
+
}),
|
|
1284
|
+
);
|
|
1285
|
+
const { renderFetched } = await import('./web-fetch.js');
|
|
1286
|
+
return { ok: true, url: page.url, text: renderFetched(page) };
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
const message = String((error as Error).message ?? error).slice(0, 300);
|
|
1289
|
+
this.store.transaction(() =>
|
|
1290
|
+
this.store.event(id, 'web.fetch', {
|
|
1291
|
+
attempt_id: w.attempt,
|
|
1292
|
+
tool_call_id: toolCallId,
|
|
1293
|
+
host,
|
|
1294
|
+
error: message,
|
|
1295
|
+
}),
|
|
1296
|
+
);
|
|
1297
|
+
return { ok: false, url, error: message };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (method.startsWith('bgtask.')) {
|
|
1301
|
+
if (!this.localCoding || !this.interactiveCoding(id) || !w.attempt)
|
|
1302
|
+
throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
|
|
1303
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
1304
|
+
if (method === 'bgtask.list') return { tasks: this.localCoding.tasks(id) };
|
|
1305
|
+
const task = string(body.task_id, 'task_id', 64);
|
|
1306
|
+
if (method === 'bgtask.output') return this.localCoding.taskOutput(id, task);
|
|
1307
|
+
if (method === 'bgtask.stop') return this.localCoding.stopTask(id, task);
|
|
1308
|
+
throw new ServiceError('invalid_field', 'Unsupported background task method');
|
|
1309
|
+
}
|
|
958
1310
|
if (method.startsWith('local.') && method !== 'local.read') {
|
|
959
1311
|
if (
|
|
960
1312
|
!this.localCoding ||
|
|
@@ -964,14 +1316,26 @@ export class Supervisor extends EventEmitter {
|
|
|
964
1316
|
)
|
|
965
1317
|
throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
|
|
966
1318
|
this.assertActive(id, w.epoch, w.attempt);
|
|
967
|
-
if (method === 'local.prepare')
|
|
968
|
-
|
|
1319
|
+
if (method === 'local.prepare') {
|
|
1320
|
+
const toolCall = string(body.tool_call_id, 'tool_call_id', 512);
|
|
1321
|
+
const args = object(body.args);
|
|
1322
|
+
// A retried prepare returns the recorded call; its hooks already ran.
|
|
1323
|
+
const fresh = !this.localCoding.hasCall(id, w.epoch, toolCall);
|
|
1324
|
+
if (fresh) await this.preToolUse(id, 'salaam_local', args, toolCall);
|
|
1325
|
+
const prepared = await this.localCoding.prepareReviewed(
|
|
969
1326
|
id,
|
|
970
1327
|
w.epoch,
|
|
971
1328
|
w.attempt,
|
|
972
|
-
|
|
973
|
-
|
|
1329
|
+
toolCall,
|
|
1330
|
+
args,
|
|
974
1331
|
);
|
|
1332
|
+
if (fresh && prepared.approval?.state === 'pending')
|
|
1333
|
+
void this.hook(id, 'Notification', {
|
|
1334
|
+
notification_type: 'permission_prompt',
|
|
1335
|
+
message: `Salaam needs your approval for a ${String(args.action ?? 'local')} request`,
|
|
1336
|
+
}).catch(() => {});
|
|
1337
|
+
return prepared;
|
|
1338
|
+
}
|
|
975
1339
|
const c = this.localCoding.get(string(body.call_id, 'call_id'));
|
|
976
1340
|
if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
|
|
977
1341
|
throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
|
|
@@ -985,6 +1349,12 @@ export class Supervisor extends EventEmitter {
|
|
|
985
1349
|
this.assertActive(id, w.epoch, w.attempt);
|
|
986
1350
|
this.store.taskGrantCheck?.(id);
|
|
987
1351
|
const args = object(body.args);
|
|
1352
|
+
await this.preToolUse(
|
|
1353
|
+
id,
|
|
1354
|
+
'salaam_workspace',
|
|
1355
|
+
args,
|
|
1356
|
+
string(body.tool_call_id, 'tool_call_id', 512),
|
|
1357
|
+
);
|
|
988
1358
|
const result = readLocalWorkspace(this.store.session(id).cwd, args);
|
|
989
1359
|
this.store.transaction(() =>
|
|
990
1360
|
this.store.event(id, 'workspace.read', {
|
|
@@ -1211,6 +1581,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1211
1581
|
return { ok: true };
|
|
1212
1582
|
} catch (error) {
|
|
1213
1583
|
this.store.taskBudgetDenied?.(id, error);
|
|
1584
|
+
if (method.startsWith('station.')) this.recordStationRefusal(id, method, body, error);
|
|
1214
1585
|
if (
|
|
1215
1586
|
method.startsWith('station.') &&
|
|
1216
1587
|
error instanceof ServiceError &&
|
|
@@ -1225,6 +1596,18 @@ export class Supervisor extends EventEmitter {
|
|
|
1225
1596
|
].includes(error.code)
|
|
1226
1597
|
)
|
|
1227
1598
|
throw error;
|
|
1599
|
+
if (
|
|
1600
|
+
method.startsWith('operator_mcp.') &&
|
|
1601
|
+
error instanceof ServiceError &&
|
|
1602
|
+
[
|
|
1603
|
+
'invalid_field',
|
|
1604
|
+
'invalid_mcp_server',
|
|
1605
|
+
'invalid_mcp_tool',
|
|
1606
|
+
'invalid_mcp_arguments',
|
|
1607
|
+
'mcp_server_failed',
|
|
1608
|
+
].includes(error.code)
|
|
1609
|
+
)
|
|
1610
|
+
throw error;
|
|
1228
1611
|
if (
|
|
1229
1612
|
(method.startsWith('mcp.') || method.startsWith('toolkit.')) &&
|
|
1230
1613
|
error instanceof ServiceError &&
|
|
@@ -1258,26 +1641,13 @@ export class Supervisor extends EventEmitter {
|
|
|
1258
1641
|
].includes(error.code)
|
|
1259
1642
|
)
|
|
1260
1643
|
throw error;
|
|
1644
|
+
// A hook that blocked a tool call is the person's policy speaking, not a broken control: the
|
|
1645
|
+
// model reads the reason as the tool's result (Phase 4b).
|
|
1646
|
+
if (error instanceof ServiceError && error.code === 'hook_blocked') throw error;
|
|
1261
1647
|
if (
|
|
1262
1648
|
(method.startsWith('coding.') || method.startsWith('local.')) &&
|
|
1263
1649
|
error instanceof ServiceError &&
|
|
1264
|
-
|
|
1265
|
-
'local_busy',
|
|
1266
|
-
'workspace_read_failed',
|
|
1267
|
-
'unsafe_path',
|
|
1268
|
-
'workspace_changed',
|
|
1269
|
-
'workspace_busy',
|
|
1270
|
-
'workspace_limit',
|
|
1271
|
-
'workspace_conflict',
|
|
1272
|
-
'not_found',
|
|
1273
|
-
'binary_file',
|
|
1274
|
-
'line_limit',
|
|
1275
|
-
'invalid_field',
|
|
1276
|
-
'request_conflict',
|
|
1277
|
-
'command_conflict',
|
|
1278
|
-
'command_limit',
|
|
1279
|
-
'invalid_cursor',
|
|
1280
|
-
].includes(error.code)
|
|
1650
|
+
LOCAL_TOOL_ERRORS.includes(error.code)
|
|
1281
1651
|
)
|
|
1282
1652
|
throw error;
|
|
1283
1653
|
if (
|
|
@@ -1329,6 +1699,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1329
1699
|
const state = {
|
|
1330
1700
|
...this.store.session(id),
|
|
1331
1701
|
provider_route: this.store.providerRoute(id) ?? undefined,
|
|
1702
|
+
project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
|
|
1332
1703
|
provider_capabilities:
|
|
1333
1704
|
cap === undefined
|
|
1334
1705
|
? this.providerCapabilities()
|
|
@@ -1339,6 +1710,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1339
1710
|
? null
|
|
1340
1711
|
: 'No provider-enforced output cap; tasks must explicitly disable token budgeting',
|
|
1341
1712
|
},
|
|
1713
|
+
...(this.mcpServers(id) ? { mcp_servers: this.mcpServers(id) } : {}),
|
|
1342
1714
|
};
|
|
1343
1715
|
const w = this.workers.get(id);
|
|
1344
1716
|
if (w?.invalid) return { ...state, state: 'unhealthy', capabilities: [], error: w.invalid };
|
|
@@ -1608,16 +1980,53 @@ export class Supervisor extends EventEmitter {
|
|
|
1608
1980
|
if (candidate.stopReason !== 'end_turn') outcome = candidate;
|
|
1609
1981
|
}
|
|
1610
1982
|
}
|
|
1611
|
-
|
|
1983
|
+
// A checked task step is decided by its checks, not by a tool error the model went on
|
|
1984
|
+
// to recover from (operator decision, 2026-10-23). The error stays in the journal
|
|
1985
|
+
// (tool.finished failed=true). A delegated child turn, whose denied probes are its
|
|
1986
|
+
// boundary proof, and any attempt outside a checked step still fail on it, except an
|
|
1987
|
+
// interactive coding turn: at a desk a failed command is feedback, and the turn is
|
|
1988
|
+
// decided by how it ends (step 0, 2026-10-31).
|
|
1989
|
+
if (
|
|
1990
|
+
value.type === 'tool_execution_end' &&
|
|
1991
|
+
value.isError === true &&
|
|
1992
|
+
(this.children?.owned(id) ||
|
|
1993
|
+
(!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id)))
|
|
1994
|
+
)
|
|
1612
1995
|
outcome ??= {
|
|
1613
1996
|
version: 1,
|
|
1614
1997
|
stopReason: 'error',
|
|
1615
|
-
failure: acpFailure(new ServiceError('tool_failed',
|
|
1998
|
+
failure: acpFailure(new ServiceError('tool_failed', toolFailureMessage(value.toolName))),
|
|
1616
1999
|
};
|
|
1617
2000
|
};
|
|
1618
2001
|
w.pi.on('record', record);
|
|
1619
2002
|
try {
|
|
1620
|
-
|
|
2003
|
+
// Interactive sessions pick up AGENTS.md edits at the start of each turn (recorded as a new
|
|
2004
|
+
// instructions.loaded revision). A discovery failure never blocks the turn.
|
|
2005
|
+
try {
|
|
2006
|
+
this.tasks.instructions?.refresh(id);
|
|
2007
|
+
} catch {
|
|
2008
|
+
/* the previously loaded guidance stays in force */
|
|
2009
|
+
}
|
|
2010
|
+
// The folder as it was before this turn, so /rewind can return to it.
|
|
2011
|
+
if (this.interactiveCoding(id)) this.tasks.checkpoints?.capture(id, work.attempt.id);
|
|
2012
|
+
// UserPromptSubmit hooks may stop the prompt or add context to it (Phase 4b).
|
|
2013
|
+
let message = work.submission.text;
|
|
2014
|
+
const gate = await this.hook(id, 'UserPromptSubmit', { prompt: message });
|
|
2015
|
+
if (gate?.blocked) {
|
|
2016
|
+
this.store.finish(
|
|
2017
|
+
work.attempt.id,
|
|
2018
|
+
'failed',
|
|
2019
|
+
null,
|
|
2020
|
+
'Blocked by a UserPromptSubmit hook: ' + gate.blocked,
|
|
2021
|
+
);
|
|
2022
|
+
return true;
|
|
2023
|
+
}
|
|
2024
|
+
if (gate?.context) message += '\n\n<hook-context>\n' + gate.context + '\n</hook-context>';
|
|
2025
|
+
const images = takeImages(this.store.db, work.submission.id);
|
|
2026
|
+
const response = await w.pi.command('prompt', {
|
|
2027
|
+
message,
|
|
2028
|
+
...(images.length ? { images } : {}),
|
|
2029
|
+
});
|
|
1621
2030
|
const disposition = object(response.data ?? {}).disposition;
|
|
1622
2031
|
if (disposition !== 'started') throw new Error('Pi did not start the dispatched prompt');
|
|
1623
2032
|
this.store.running(work.attempt.id);
|
|
@@ -1634,6 +2043,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1634
2043
|
{ epoch: w.epoch, ...entries },
|
|
1635
2044
|
outcome,
|
|
1636
2045
|
);
|
|
2046
|
+
void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => {});
|
|
1637
2047
|
} catch (error) {
|
|
1638
2048
|
const message = redactFailureText((error as Error).message, w.secrets);
|
|
1639
2049
|
if (!w.stopping && !this.store.cancelled(work.attempt.id))
|
|
@@ -1684,6 +2094,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1684
2094
|
try {
|
|
1685
2095
|
this.managed?.invalidateWorker(w.id, w.epoch);
|
|
1686
2096
|
this.cancelMcpWorker(w);
|
|
2097
|
+
void this.operatorMcp?.stopSession(w.id).catch(() => {});
|
|
1687
2098
|
this.children?.workerLost(w.id, 'Worker stopped before child dispatch');
|
|
1688
2099
|
} catch {
|
|
1689
2100
|
/* shutdown still owns and stops the worker */
|
|
@@ -1711,3 +2122,29 @@ export class Supervisor extends EventEmitter {
|
|
|
1711
2122
|
throw new AggregateError(errors, 'Worker shutdown could not persist all final states');
|
|
1712
2123
|
}
|
|
1713
2124
|
}
|
|
2125
|
+
|
|
2126
|
+
/** Names the failing tool (never its arguments or output), so a failed step says which call
|
|
2127
|
+
* failed it. Only a plain tool identifier is echoed. */
|
|
2128
|
+
export function toolFailureMessage(toolName: unknown): string {
|
|
2129
|
+
return typeof toolName === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(toolName)
|
|
2130
|
+
? `Tool execution failed (tool ${toolName})`
|
|
2131
|
+
: 'Tool execution failed';
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/** Claude Code names for Salaam's tools, so a `Bash` or `Edit|Write` matcher works unchanged. */
|
|
2135
|
+
export function hookAliases(tool: string, input: Record<string, unknown>): string[] {
|
|
2136
|
+
if (tool === 'salaam_local')
|
|
2137
|
+
return input.action === 'command' ? ['Bash'] : input.action === 'edit' ? ['Edit', 'Write'] : [];
|
|
2138
|
+
if (tool === 'salaam_web_search') return ['WebSearch'];
|
|
2139
|
+
if (tool === 'salaam_web_fetch') return ['WebFetch'];
|
|
2140
|
+
if (tool === 'salaam_workspace') return ['Read'];
|
|
2141
|
+
return [];
|
|
2142
|
+
}
|
|
2143
|
+
function parsedInput(input: string | undefined): Record<string, unknown> {
|
|
2144
|
+
try {
|
|
2145
|
+
const value = JSON.parse(input ?? '{}');
|
|
2146
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
2147
|
+
} catch {
|
|
2148
|
+
return {};
|
|
2149
|
+
}
|
|
2150
|
+
}
|