@superjackfruitlabs/salaam 0.0.4 → 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 +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- 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/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -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/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 +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -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/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/dist/src/store.js
CHANGED
|
@@ -461,8 +461,11 @@ export class Store {
|
|
|
461
461
|
throw new ServiceError('not_found', 'Session not found', 404);
|
|
462
462
|
return { ...row, capabilities: JSON.parse(row.capabilities) };
|
|
463
463
|
}
|
|
464
|
-
forkLocal(id, request, expected, controller, clone
|
|
465
|
-
|
|
464
|
+
forkLocal(id, request, expected, controller, clone,
|
|
465
|
+
/** A rewind forks from an earlier conversation leaf. */
|
|
466
|
+
at) {
|
|
467
|
+
const input = { kind: 'fork', id, expected, ...(at ? { leaf: at.leaf } : {}) };
|
|
468
|
+
return this.request('local-operator', request, input, () => {
|
|
466
469
|
this.authorizeControl(id, controller);
|
|
467
470
|
const source = this.session(id);
|
|
468
471
|
if (source.revision !== expected)
|
|
@@ -478,7 +481,7 @@ export class Store {
|
|
|
478
481
|
const file = clone(source, target);
|
|
479
482
|
this.db
|
|
480
483
|
.prepare('INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES(?,?,?,?,?,?)')
|
|
481
|
-
.run(target, source.cwd, target, file, source.pi_cursor, source.pi_leaf);
|
|
484
|
+
.run(target, source.cwd, target, file, source.pi_cursor, at ? at.leaf : source.pi_leaf);
|
|
482
485
|
this.event(target, 'session.created', {
|
|
483
486
|
cwd: source.cwd,
|
|
484
487
|
local_workspace: true,
|
|
@@ -493,7 +496,13 @@ export class Store {
|
|
|
493
496
|
this.db
|
|
494
497
|
.prepare('INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?')
|
|
495
498
|
.run(target, id);
|
|
496
|
-
this.event(target, 'session.forked', {
|
|
499
|
+
this.event(target, 'session.forked', {
|
|
500
|
+
parent: id,
|
|
501
|
+
parent_revision: expected,
|
|
502
|
+
leaf: at ? at.leaf : source.pi_leaf,
|
|
503
|
+
cursor: source.pi_cursor,
|
|
504
|
+
...(at ? { rewound: true } : {}),
|
|
505
|
+
}, request);
|
|
497
506
|
return this.session(target);
|
|
498
507
|
});
|
|
499
508
|
}
|
|
@@ -521,6 +530,13 @@ export class Store {
|
|
|
521
530
|
lastActivityAny() {
|
|
522
531
|
return Math.max(this.openedAt, ...this.activity.values());
|
|
523
532
|
}
|
|
533
|
+
/** The newest prompt submitted to a session, for the session picker. */
|
|
534
|
+
lastPrompt(id) {
|
|
535
|
+
const row = this.db
|
|
536
|
+
.prepare('SELECT text FROM submissions WHERE session_id=? ORDER BY rowid DESC LIMIT 1')
|
|
537
|
+
.get(id);
|
|
538
|
+
return row?.text ?? null;
|
|
539
|
+
}
|
|
524
540
|
sessions() {
|
|
525
541
|
return this.db.prepare('SELECT id FROM sessions ORDER BY rowid').all().map((x) => this.session(x.id));
|
|
526
542
|
}
|
|
@@ -573,6 +589,23 @@ export class Store {
|
|
|
573
589
|
}
|
|
574
590
|
});
|
|
575
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* The one sanctioned route change: an operator switching model or thinking level in an
|
|
594
|
+
* interactive session between turns. Recorded as provider.route_changed; the worker restarts on
|
|
595
|
+
* the same Pi transcript with the new route.
|
|
596
|
+
*/
|
|
597
|
+
switchRoute(id, route) {
|
|
598
|
+
return this.transaction(() => {
|
|
599
|
+
const from = this.providerRoute(id);
|
|
600
|
+
if (!from || !this.localWorkspace(id))
|
|
601
|
+
throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
|
|
602
|
+
this.db
|
|
603
|
+
.prepare('UPDATE session_routes SET route=? WHERE session_id=?')
|
|
604
|
+
.run(canonical(route), id);
|
|
605
|
+
this.event(id, 'provider.route_changed', { from, to: route });
|
|
606
|
+
return { from, to: route };
|
|
607
|
+
});
|
|
608
|
+
}
|
|
576
609
|
providerRoute(id) {
|
|
577
610
|
const row = this.db.prepare('SELECT route FROM session_routes WHERE session_id=?').get(id);
|
|
578
611
|
return row ? JSON.parse(row.route) : null;
|
|
@@ -920,6 +953,9 @@ export class Store {
|
|
|
920
953
|
const lookup = this.db.prepare('SELECT payload FROM pi_entries WHERE session_id=? AND entry_id=?');
|
|
921
954
|
let remaining = 256 * 1024;
|
|
922
955
|
const messages = [];
|
|
956
|
+
// Walking back from the newest entry: tool calls belong just before the message that follows
|
|
957
|
+
// them, so a turn's calls are shown in place rather than gathered at the end.
|
|
958
|
+
let trailing = [];
|
|
923
959
|
while (cursor &&
|
|
924
960
|
!seen.has(cursor) &&
|
|
925
961
|
seen.size < 10000 &&
|
|
@@ -934,6 +970,16 @@ export class Store {
|
|
|
934
970
|
const message = entry.message;
|
|
935
971
|
if (!message || !['user', 'assistant'].includes(message.role))
|
|
936
972
|
continue;
|
|
973
|
+
if (Array.isArray(message.content)) {
|
|
974
|
+
const calls = message.content
|
|
975
|
+
.filter((part) => part.type === 'toolCall')
|
|
976
|
+
.map((part) => String(part.id));
|
|
977
|
+
const later = messages.at(-1);
|
|
978
|
+
if (calls.length && later)
|
|
979
|
+
later.tool_calls = [...calls, ...(later.tool_calls ?? [])];
|
|
980
|
+
else if (calls.length)
|
|
981
|
+
trailing = [...calls, ...trailing];
|
|
982
|
+
}
|
|
937
983
|
const text = typeof message.content === 'string'
|
|
938
984
|
? message.content
|
|
939
985
|
: message.content
|
|
@@ -964,6 +1010,7 @@ export class Store {
|
|
|
964
1010
|
attempts: boundedAttempts,
|
|
965
1011
|
submissions: boundedSubmissions,
|
|
966
1012
|
messages: messages.reverse(),
|
|
1013
|
+
...(trailing.length ? { trailing_tool_calls: trailing } : {}),
|
|
967
1014
|
history_truncated: cursor !== null,
|
|
968
1015
|
approvals: this.approvals(id),
|
|
969
1016
|
operations: this.operations(id),
|
package/dist/src/supervisor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BoardReads } from './board-reads.js';
|
|
2
|
+
import { takeImages } from './mentions.js';
|
|
2
3
|
import { HostBreaker, callThroughBreaker } from './host-breaker.js';
|
|
3
4
|
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
@@ -19,6 +20,28 @@ import { digest } from './protocol.js';
|
|
|
19
20
|
import { readLocalWorkspace } from './local-workspace.js';
|
|
20
21
|
import { repositoryPath } from './repository-snapshots.js';
|
|
21
22
|
import { acpFailure, credentialStrings, parseAcpFailure, providerOutcome, redactFailureText, toolMetadata, } from './acp-outcome.js';
|
|
23
|
+
/** Errors a local tool call returns to the model as feedback; any other one fails the worker.
|
|
24
|
+
* Every code src/local-edits.ts can throw belongs here (a failed replacement is not a crash). */
|
|
25
|
+
export const LOCAL_TOOL_ERRORS = [
|
|
26
|
+
'local_busy',
|
|
27
|
+
'workspace_read_failed',
|
|
28
|
+
'unsafe_path',
|
|
29
|
+
'edit_mismatch',
|
|
30
|
+
'workspace_changed',
|
|
31
|
+
'workspace_busy',
|
|
32
|
+
'workspace_limit',
|
|
33
|
+
'workspace_conflict',
|
|
34
|
+
'not_found',
|
|
35
|
+
'binary_file',
|
|
36
|
+
'line_limit',
|
|
37
|
+
'invalid_field',
|
|
38
|
+
'request_conflict',
|
|
39
|
+
'command_conflict',
|
|
40
|
+
'command_limit',
|
|
41
|
+
'invalid_cursor',
|
|
42
|
+
'shell_binding',
|
|
43
|
+
'listen_unreachable',
|
|
44
|
+
];
|
|
22
45
|
export function processIdentity(pid) {
|
|
23
46
|
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
24
47
|
return null;
|
|
@@ -72,6 +95,35 @@ export function processIdentity(pid) {
|
|
|
72
95
|
return null;
|
|
73
96
|
}
|
|
74
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* When a process started, keyed to this boot. Unlike processIdentity it survives exec, so it
|
|
100
|
+
* names a background task whose gated shell has since become the user's command.
|
|
101
|
+
*/
|
|
102
|
+
export function processStart(pid) {
|
|
103
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
if (process.platform === 'linux') {
|
|
107
|
+
const value = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
108
|
+
const fields = value
|
|
109
|
+
.slice(value.lastIndexOf(') ') + 2)
|
|
110
|
+
.trim()
|
|
111
|
+
.split(/\s+/);
|
|
112
|
+
const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
113
|
+
return /^\d+$/.test(fields[19] ?? '') ? `linux-start-v1:${pid}:${boot}:${fields[19]}` : null;
|
|
114
|
+
}
|
|
115
|
+
const started = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
118
|
+
timeout: 2000,
|
|
119
|
+
killSignal: 'SIGKILL',
|
|
120
|
+
}).trim();
|
|
121
|
+
return started ? `ps-start-v1:${pid}:${started}` : null;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
75
127
|
function groupExists(pid) {
|
|
76
128
|
try {
|
|
77
129
|
process.kill(-pid, 0);
|
|
@@ -87,6 +139,10 @@ export class Supervisor extends EventEmitter {
|
|
|
87
139
|
store;
|
|
88
140
|
options;
|
|
89
141
|
localCoding;
|
|
142
|
+
/** Built-in web search and fetch for interactive coding (operator ruling 2026-10-31). */
|
|
143
|
+
web;
|
|
144
|
+
/** Desk-session hooks (Phase 4b); absent means none run. */
|
|
145
|
+
hooks;
|
|
90
146
|
tasks;
|
|
91
147
|
compactionJobs = new Set();
|
|
92
148
|
fetchBreaker = new HostBreaker();
|
|
@@ -429,6 +485,16 @@ export class Supervisor extends EventEmitter {
|
|
|
429
485
|
worker.pi.on('activity', (observation) => {
|
|
430
486
|
if (!worker.attempt || worker.stopping)
|
|
431
487
|
return;
|
|
488
|
+
// PostToolUse runs after the tool and cannot change it; it is recorded like any hook.
|
|
489
|
+
if (observation.phase === 'finished' && this.hooks) {
|
|
490
|
+
const input = parsedInput(observation.input);
|
|
491
|
+
void this.hook(id, 'PostToolUse', {
|
|
492
|
+
tool_name: observation.tool,
|
|
493
|
+
tool_input: input,
|
|
494
|
+
tool_use_id: observation.tool_call_id,
|
|
495
|
+
tool_response: { output: observation.output ?? '', failed: !!observation.failed },
|
|
496
|
+
}, [observation.tool, ...hookAliases(observation.tool, input)]).catch(() => { });
|
|
497
|
+
}
|
|
432
498
|
try {
|
|
433
499
|
this.store.request(`tool-activity:${id}:${epoch}:${worker.attempt}`, `${observation.phase}:${observation.tool_call_id}`, observation, () => {
|
|
434
500
|
this.store.event(id, `tool.${observation.phase}`, {
|
|
@@ -605,6 +671,28 @@ export class Supervisor extends EventEmitter {
|
|
|
605
671
|
return w;
|
|
606
672
|
}
|
|
607
673
|
/** Is this attempt a task step that has acceptance checks to decide it? */
|
|
674
|
+
/** A person's own local coding session: not a delegated child and not AgentPod-admitted work,
|
|
675
|
+
* whose strict semantics stay as they are. */
|
|
676
|
+
isInteractiveCoding(id) {
|
|
677
|
+
return this.interactiveCoding(id);
|
|
678
|
+
}
|
|
679
|
+
/** Run this desk session's hooks for `event`; other sessions (board, station, children) run none. */
|
|
680
|
+
async hook(id, event, payload, names = []) {
|
|
681
|
+
if (!this.hooks || !this.interactiveCoding(id))
|
|
682
|
+
return null;
|
|
683
|
+
return this.hooks.fire(id, event, payload, names);
|
|
684
|
+
}
|
|
685
|
+
/** A PreToolUse hook that blocks turns the tool call into an error the model reads. */
|
|
686
|
+
async preToolUse(id, tool, input, toolCallId) {
|
|
687
|
+
const out = await this.hook(id, 'PreToolUse', { tool_name: tool, tool_input: input, tool_use_id: toolCallId }, [tool, ...hookAliases(tool, input)]);
|
|
688
|
+
if (out?.blocked)
|
|
689
|
+
throw new ServiceError('hook_blocked', `Blocked by a PreToolUse hook: ${out.blocked}`);
|
|
690
|
+
}
|
|
691
|
+
interactiveCoding(id) {
|
|
692
|
+
return (this.store.localCoding(id) &&
|
|
693
|
+
!this.children?.owned(id) &&
|
|
694
|
+
!this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id));
|
|
695
|
+
}
|
|
608
696
|
checkedStepAttempt(attempt) {
|
|
609
697
|
const row = this.store.db
|
|
610
698
|
.prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
|
|
@@ -895,6 +983,77 @@ export class Supervisor extends EventEmitter {
|
|
|
895
983
|
return this.station.view(c.id);
|
|
896
984
|
throw new ServiceError('invalid_field', 'Unsupported station method');
|
|
897
985
|
}
|
|
986
|
+
if (method === 'web.search' || method === 'web.fetch') {
|
|
987
|
+
if (!this.web || !this.store.localCoding(id) || this.children?.owned(id) || !w.attempt)
|
|
988
|
+
throw new ServiceError('web_denied', 'Web search is not admitted for this session', 403);
|
|
989
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
990
|
+
const args = object(body.args);
|
|
991
|
+
const toolCallId = string(body.tool_call_id, 'tool_call_id', 512);
|
|
992
|
+
await this.preToolUse(id, method === 'web.search' ? 'salaam_web_search' : 'salaam_web_fetch', args, toolCallId);
|
|
993
|
+
if (method === 'web.search') {
|
|
994
|
+
const out = await this.web.search({
|
|
995
|
+
query: string(args.query, 'query', 300),
|
|
996
|
+
...(typeof args.kind === 'string'
|
|
997
|
+
? { kind: args.kind }
|
|
998
|
+
: {}),
|
|
999
|
+
...(typeof args.limit === 'number' ? { limit: args.limit } : {}),
|
|
1000
|
+
});
|
|
1001
|
+
// Recorded: kind, backends tried, which answered and how long. Never a key or the query.
|
|
1002
|
+
this.store.transaction(() => this.store.event(id, 'web.search', {
|
|
1003
|
+
attempt_id: w.attempt,
|
|
1004
|
+
tool_call_id: toolCallId,
|
|
1005
|
+
kind: out.kind,
|
|
1006
|
+
cached: !!out.cached,
|
|
1007
|
+
results: out.results.length,
|
|
1008
|
+
tried: out.tried,
|
|
1009
|
+
}));
|
|
1010
|
+
return out;
|
|
1011
|
+
}
|
|
1012
|
+
const url = string(args.url, 'url', 2000);
|
|
1013
|
+
let host = '';
|
|
1014
|
+
try {
|
|
1015
|
+
host = new URL(url).hostname;
|
|
1016
|
+
}
|
|
1017
|
+
catch {
|
|
1018
|
+
/* fetchPage refuses it */
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
const page = await (await import('./web-fetch.js')).fetchPage(url);
|
|
1022
|
+
this.store.transaction(() => this.store.event(id, 'web.fetch', {
|
|
1023
|
+
attempt_id: w.attempt,
|
|
1024
|
+
tool_call_id: toolCallId,
|
|
1025
|
+
host,
|
|
1026
|
+
status: page.status,
|
|
1027
|
+
chars: page.text.length,
|
|
1028
|
+
truncated: page.truncated,
|
|
1029
|
+
}));
|
|
1030
|
+
const { renderFetched } = await import('./web-fetch.js');
|
|
1031
|
+
return { ok: true, url: page.url, text: renderFetched(page) };
|
|
1032
|
+
}
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
const message = String(error.message ?? error).slice(0, 300);
|
|
1035
|
+
this.store.transaction(() => this.store.event(id, 'web.fetch', {
|
|
1036
|
+
attempt_id: w.attempt,
|
|
1037
|
+
tool_call_id: toolCallId,
|
|
1038
|
+
host,
|
|
1039
|
+
error: message,
|
|
1040
|
+
}));
|
|
1041
|
+
return { ok: false, url, error: message };
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (method.startsWith('bgtask.')) {
|
|
1045
|
+
if (!this.localCoding || !this.interactiveCoding(id) || !w.attempt)
|
|
1046
|
+
throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
|
|
1047
|
+
this.assertActive(id, w.epoch, w.attempt);
|
|
1048
|
+
if (method === 'bgtask.list')
|
|
1049
|
+
return { tasks: this.localCoding.tasks(id) };
|
|
1050
|
+
const task = string(body.task_id, 'task_id', 64);
|
|
1051
|
+
if (method === 'bgtask.output')
|
|
1052
|
+
return this.localCoding.taskOutput(id, task);
|
|
1053
|
+
if (method === 'bgtask.stop')
|
|
1054
|
+
return this.localCoding.stopTask(id, task);
|
|
1055
|
+
throw new ServiceError('invalid_field', 'Unsupported background task method');
|
|
1056
|
+
}
|
|
898
1057
|
if (method.startsWith('local.') && method !== 'local.read') {
|
|
899
1058
|
if (!this.localCoding ||
|
|
900
1059
|
!this.store.localCoding(id) ||
|
|
@@ -902,8 +1061,21 @@ export class Supervisor extends EventEmitter {
|
|
|
902
1061
|
!w.attempt)
|
|
903
1062
|
throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
|
|
904
1063
|
this.assertActive(id, w.epoch, w.attempt);
|
|
905
|
-
if (method === 'local.prepare')
|
|
906
|
-
|
|
1064
|
+
if (method === 'local.prepare') {
|
|
1065
|
+
const toolCall = string(body.tool_call_id, 'tool_call_id', 512);
|
|
1066
|
+
const args = object(body.args);
|
|
1067
|
+
// A retried prepare returns the recorded call; its hooks already ran.
|
|
1068
|
+
const fresh = !this.localCoding.hasCall(id, w.epoch, toolCall);
|
|
1069
|
+
if (fresh)
|
|
1070
|
+
await this.preToolUse(id, 'salaam_local', args, toolCall);
|
|
1071
|
+
const prepared = await this.localCoding.prepareReviewed(id, w.epoch, w.attempt, toolCall, args);
|
|
1072
|
+
if (fresh && prepared.approval?.state === 'pending')
|
|
1073
|
+
void this.hook(id, 'Notification', {
|
|
1074
|
+
notification_type: 'permission_prompt',
|
|
1075
|
+
message: `Salaam needs your approval for a ${String(args.action ?? 'local')} request`,
|
|
1076
|
+
}).catch(() => { });
|
|
1077
|
+
return prepared;
|
|
1078
|
+
}
|
|
907
1079
|
const c = this.localCoding.get(string(body.call_id, 'call_id'));
|
|
908
1080
|
if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
|
|
909
1081
|
throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
|
|
@@ -919,6 +1091,7 @@ export class Supervisor extends EventEmitter {
|
|
|
919
1091
|
this.assertActive(id, w.epoch, w.attempt);
|
|
920
1092
|
this.store.taskGrantCheck?.(id);
|
|
921
1093
|
const args = object(body.args);
|
|
1094
|
+
await this.preToolUse(id, 'salaam_workspace', args, string(body.tool_call_id, 'tool_call_id', 512));
|
|
922
1095
|
const result = readLocalWorkspace(this.store.session(id).cwd, args);
|
|
923
1096
|
this.store.transaction(() => this.store.event(id, 'workspace.read', {
|
|
924
1097
|
attempt_id: w.attempt,
|
|
@@ -1146,25 +1319,13 @@ export class Supervisor extends EventEmitter {
|
|
|
1146
1319
|
'request_conflict',
|
|
1147
1320
|
].includes(error.code))
|
|
1148
1321
|
throw error;
|
|
1322
|
+
// A hook that blocked a tool call is the person's policy speaking, not a broken control: the
|
|
1323
|
+
// model reads the reason as the tool's result (Phase 4b).
|
|
1324
|
+
if (error instanceof ServiceError && error.code === 'hook_blocked')
|
|
1325
|
+
throw error;
|
|
1149
1326
|
if ((method.startsWith('coding.') || method.startsWith('local.')) &&
|
|
1150
1327
|
error instanceof ServiceError &&
|
|
1151
|
-
|
|
1152
|
-
'local_busy',
|
|
1153
|
-
'workspace_read_failed',
|
|
1154
|
-
'unsafe_path',
|
|
1155
|
-
'workspace_changed',
|
|
1156
|
-
'workspace_busy',
|
|
1157
|
-
'workspace_limit',
|
|
1158
|
-
'workspace_conflict',
|
|
1159
|
-
'not_found',
|
|
1160
|
-
'binary_file',
|
|
1161
|
-
'line_limit',
|
|
1162
|
-
'invalid_field',
|
|
1163
|
-
'request_conflict',
|
|
1164
|
-
'command_conflict',
|
|
1165
|
-
'command_limit',
|
|
1166
|
-
'invalid_cursor',
|
|
1167
|
-
].includes(error.code))
|
|
1328
|
+
LOCAL_TOOL_ERRORS.includes(error.code))
|
|
1168
1329
|
throw error;
|
|
1169
1330
|
if (method === 'history' &&
|
|
1170
1331
|
error instanceof ServiceError &&
|
|
@@ -1212,6 +1373,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1212
1373
|
const state = {
|
|
1213
1374
|
...this.store.session(id),
|
|
1214
1375
|
provider_route: this.store.providerRoute(id) ?? undefined,
|
|
1376
|
+
project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
|
|
1215
1377
|
provider_capabilities: cap === undefined
|
|
1216
1378
|
? this.providerCapabilities()
|
|
1217
1379
|
: {
|
|
@@ -1478,10 +1640,13 @@ export class Supervisor extends EventEmitter {
|
|
|
1478
1640
|
// A checked task step is decided by its checks, not by a tool error the model went on
|
|
1479
1641
|
// to recover from (operator decision, 2026-10-23). The error stays in the journal
|
|
1480
1642
|
// (tool.finished failed=true). A delegated child turn, whose denied probes are its
|
|
1481
|
-
// boundary proof, and any attempt outside a checked step still fail on it
|
|
1643
|
+
// boundary proof, and any attempt outside a checked step still fail on it, except an
|
|
1644
|
+
// interactive coding turn: at a desk a failed command is feedback, and the turn is
|
|
1645
|
+
// decided by how it ends (step 0, 2026-10-31).
|
|
1482
1646
|
if (value.type === 'tool_execution_end' &&
|
|
1483
1647
|
value.isError === true &&
|
|
1484
|
-
(this.children?.owned(id) ||
|
|
1648
|
+
(this.children?.owned(id) ||
|
|
1649
|
+
(!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id))))
|
|
1485
1650
|
outcome ??= {
|
|
1486
1651
|
version: 1,
|
|
1487
1652
|
stopReason: 'error',
|
|
@@ -1490,7 +1655,31 @@ export class Supervisor extends EventEmitter {
|
|
|
1490
1655
|
};
|
|
1491
1656
|
w.pi.on('record', record);
|
|
1492
1657
|
try {
|
|
1493
|
-
|
|
1658
|
+
// Interactive sessions pick up AGENTS.md edits at the start of each turn (recorded as a new
|
|
1659
|
+
// instructions.loaded revision). A discovery failure never blocks the turn.
|
|
1660
|
+
try {
|
|
1661
|
+
this.tasks.instructions?.refresh(id);
|
|
1662
|
+
}
|
|
1663
|
+
catch {
|
|
1664
|
+
/* the previously loaded guidance stays in force */
|
|
1665
|
+
}
|
|
1666
|
+
// The folder as it was before this turn, so /rewind can return to it.
|
|
1667
|
+
if (this.interactiveCoding(id))
|
|
1668
|
+
this.tasks.checkpoints?.capture(id, work.attempt.id);
|
|
1669
|
+
// UserPromptSubmit hooks may stop the prompt or add context to it (Phase 4b).
|
|
1670
|
+
let message = work.submission.text;
|
|
1671
|
+
const gate = await this.hook(id, 'UserPromptSubmit', { prompt: message });
|
|
1672
|
+
if (gate?.blocked) {
|
|
1673
|
+
this.store.finish(work.attempt.id, 'failed', null, 'Blocked by a UserPromptSubmit hook: ' + gate.blocked);
|
|
1674
|
+
return true;
|
|
1675
|
+
}
|
|
1676
|
+
if (gate?.context)
|
|
1677
|
+
message += '\n\n<hook-context>\n' + gate.context + '\n</hook-context>';
|
|
1678
|
+
const images = takeImages(this.store.db, work.submission.id);
|
|
1679
|
+
const response = await w.pi.command('prompt', {
|
|
1680
|
+
message,
|
|
1681
|
+
...(images.length ? { images } : {}),
|
|
1682
|
+
});
|
|
1494
1683
|
const disposition = object(response.data ?? {}).disposition;
|
|
1495
1684
|
if (disposition !== 'started')
|
|
1496
1685
|
throw new Error('Pi did not start the dispatched prompt');
|
|
@@ -1502,6 +1691,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1502
1691
|
const result = object((await w.pi.command('get_last_assistant_text')).data);
|
|
1503
1692
|
this.childBoundary(id, w.epoch);
|
|
1504
1693
|
this.store.finish(work.attempt.id, outcome ? 'failed' : 'completed', String(result.text ?? ''), outcome?.failure?.message ?? null, { epoch: w.epoch, ...entries }, outcome);
|
|
1694
|
+
void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => { });
|
|
1505
1695
|
}
|
|
1506
1696
|
catch (error) {
|
|
1507
1697
|
const message = redactFailureText(error.message, w.secrets);
|
|
@@ -1586,3 +1776,24 @@ export function toolFailureMessage(toolName) {
|
|
|
1586
1776
|
? `Tool execution failed (tool ${toolName})`
|
|
1587
1777
|
: 'Tool execution failed';
|
|
1588
1778
|
}
|
|
1779
|
+
/** Claude Code names for Salaam's tools, so a `Bash` or `Edit|Write` matcher works unchanged. */
|
|
1780
|
+
export function hookAliases(tool, input) {
|
|
1781
|
+
if (tool === 'salaam_local')
|
|
1782
|
+
return input.action === 'command' ? ['Bash'] : input.action === 'edit' ? ['Edit', 'Write'] : [];
|
|
1783
|
+
if (tool === 'salaam_web_search')
|
|
1784
|
+
return ['WebSearch'];
|
|
1785
|
+
if (tool === 'salaam_web_fetch')
|
|
1786
|
+
return ['WebFetch'];
|
|
1787
|
+
if (tool === 'salaam_workspace')
|
|
1788
|
+
return ['Read'];
|
|
1789
|
+
return [];
|
|
1790
|
+
}
|
|
1791
|
+
function parsedInput(input) {
|
|
1792
|
+
try {
|
|
1793
|
+
const value = JSON.parse(input ?? '{}');
|
|
1794
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
1795
|
+
}
|
|
1796
|
+
catch {
|
|
1797
|
+
return {};
|
|
1798
|
+
}
|
|
1799
|
+
}
|
package/dist/src/task-context.js
CHANGED
|
@@ -14,6 +14,10 @@ export class TaskContext {
|
|
|
14
14
|
artifacts;
|
|
15
15
|
work;
|
|
16
16
|
admitted;
|
|
17
|
+
/** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
|
|
18
|
+
instructions;
|
|
19
|
+
/** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
|
|
20
|
+
checkpoints;
|
|
17
21
|
constructor(store, artifacts) {
|
|
18
22
|
this.store = store;
|
|
19
23
|
this.artifacts = artifacts;
|
|
@@ -65,11 +69,13 @@ export class TaskContext {
|
|
|
65
69
|
if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
|
|
66
70
|
throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
|
|
67
71
|
const admittedContext = this.admitted?.frame(id);
|
|
68
|
-
|
|
72
|
+
const projectInstructions = this.instructions?.frame(id);
|
|
73
|
+
return admittedContext || projectInstructions
|
|
69
74
|
? 'SALAAM_TASK_STATE ' +
|
|
70
75
|
canonical({
|
|
71
76
|
...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
|
|
72
|
-
admitted_context: admittedContext,
|
|
77
|
+
...(admittedContext ? { admitted_context: admittedContext } : {}),
|
|
78
|
+
...(projectInstructions ? { project_instructions: projectInstructions } : {}),
|
|
73
79
|
})
|
|
74
80
|
: frame;
|
|
75
81
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export function terminalState(snapshot) {
|
|
3
|
+
if (!snapshot)
|
|
4
|
+
return 'idle';
|
|
5
|
+
if ((snapshot.approvals ?? []).some((a) => a.state === 'pending'))
|
|
6
|
+
return 'blocked';
|
|
7
|
+
if ((snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
|
|
8
|
+
return 'working';
|
|
9
|
+
return 'idle';
|
|
10
|
+
}
|
|
11
|
+
export function titleFor(state, name) {
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
const clean = name
|
|
14
|
+
?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
15
|
+
.trim()
|
|
16
|
+
.slice(0, 32);
|
|
17
|
+
if (!clean)
|
|
18
|
+
return 'Salaam';
|
|
19
|
+
const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
|
|
20
|
+
return `Salaam · ${clean} · ${words}`;
|
|
21
|
+
}
|
|
22
|
+
export function statusEnabled(env = process.env) {
|
|
23
|
+
return env.SALAAM_TERMINAL_STATUS !== 'off';
|
|
24
|
+
}
|
|
25
|
+
const run = (bin, args, done) => {
|
|
26
|
+
// Short timeout, failures ignored: Herdr must never slow Salaam down.
|
|
27
|
+
execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
|
|
28
|
+
};
|
|
29
|
+
/** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
|
|
30
|
+
export class HerdrReporter {
|
|
31
|
+
env;
|
|
32
|
+
exec;
|
|
33
|
+
now;
|
|
34
|
+
enabled;
|
|
35
|
+
seq = 0;
|
|
36
|
+
busy = false;
|
|
37
|
+
queued;
|
|
38
|
+
last;
|
|
39
|
+
constructor(env = process.env, exec = run, now = Date.now) {
|
|
40
|
+
this.env = env;
|
|
41
|
+
this.exec = exec;
|
|
42
|
+
this.now = now;
|
|
43
|
+
this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
|
|
44
|
+
}
|
|
45
|
+
nextSeq() {
|
|
46
|
+
this.seq = Math.max(this.seq + 1, this.now());
|
|
47
|
+
return String(this.seq);
|
|
48
|
+
}
|
|
49
|
+
head(verb) {
|
|
50
|
+
return ['pane', verb, this.env.HERDR_PANE_ID, '--source', 'salaam', '--agent', 'salaam'];
|
|
51
|
+
}
|
|
52
|
+
report(state, session) {
|
|
53
|
+
if (!this.enabled)
|
|
54
|
+
return;
|
|
55
|
+
const key = state + ' ' + (session ?? '');
|
|
56
|
+
if (key === this.last)
|
|
57
|
+
return;
|
|
58
|
+
this.last = key;
|
|
59
|
+
this.queued = [
|
|
60
|
+
...this.head('report-agent'),
|
|
61
|
+
'--state',
|
|
62
|
+
state,
|
|
63
|
+
'--seq',
|
|
64
|
+
'',
|
|
65
|
+
...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
|
|
66
|
+
...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
|
|
67
|
+
];
|
|
68
|
+
this.flush();
|
|
69
|
+
}
|
|
70
|
+
release() {
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return;
|
|
73
|
+
this.queued = undefined;
|
|
74
|
+
this.exec(this.env.HERDR_BIN_PATH, [...this.head('release-agent'), '--seq', this.nextSeq()], () => { });
|
|
75
|
+
}
|
|
76
|
+
flush() {
|
|
77
|
+
if (this.busy || !this.queued)
|
|
78
|
+
return;
|
|
79
|
+
const args = this.queued;
|
|
80
|
+
this.queued = undefined;
|
|
81
|
+
args[args.indexOf('--seq') + 1] = this.nextSeq();
|
|
82
|
+
this.busy = true;
|
|
83
|
+
this.exec(this.env.HERDR_BIN_PATH, args, () => {
|
|
84
|
+
this.busy = false;
|
|
85
|
+
this.flush();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|