@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { typedText } from './shell-passthrough.js';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { OFFLINE_ROUTE } from './provider-config.js';
|
|
4
|
+
|
|
5
|
+
/** How `salaam code` opens a folder: continue its latest session (the default), start a new
|
|
6
|
+
* coding session, or pick one from the list. */
|
|
7
|
+
export type CodeStart = 'continue' | 'new' | 'resume';
|
|
8
|
+
|
|
9
|
+
export function parseCodeStart(flags: string[]): CodeStart {
|
|
10
|
+
const chosen = (['continue', 'new', 'resume'] as const).filter((f) => flags.includes('--' + f));
|
|
11
|
+
if (chosen.length > 1) throw new Error('Choose one of --continue, --new or --resume.');
|
|
12
|
+
return chosen[0] ?? 'continue';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const folder = (path: string) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
|
|
16
|
+
|
|
17
|
+
/** The most recently created session in this folder; sessions come oldest first. */
|
|
18
|
+
export function latestSessionFor(
|
|
19
|
+
sessions: readonly { id: string; cwd: string }[],
|
|
20
|
+
cwd: string,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
const want = folder(cwd);
|
|
23
|
+
return [...sessions].reverse().find((s) => folder(s.cwd) === want)?.id;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a new `salaam code` session should use Anthropic: only when nothing was chosen, the
|
|
27
|
+
* running service defaults to offline (as a TUI-started one does) and a key is stored. */
|
|
28
|
+
export function deskUsesAnthropic(
|
|
29
|
+
chosen: boolean,
|
|
30
|
+
serviceProvider: string | undefined,
|
|
31
|
+
hasAnthropicKey: boolean,
|
|
32
|
+
): boolean {
|
|
33
|
+
return (
|
|
34
|
+
!chosen &&
|
|
35
|
+
(serviceProvider ?? OFFLINE_ROUTE.provider) === OFFLINE_ROUTE.provider &&
|
|
36
|
+
hasAnthropicKey
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `salaam -c` and `salaam --continue` are short for `salaam code --continue`. */
|
|
41
|
+
export function continueArgs(args: readonly string[]): string[] | undefined {
|
|
42
|
+
if (args[0] !== '-c' && args[0] !== '--continue') return;
|
|
43
|
+
return ['code', '--continue', ...args.slice(1)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PickerSession {
|
|
47
|
+
id: string;
|
|
48
|
+
cwd: string;
|
|
49
|
+
last_active_at?: string;
|
|
50
|
+
}
|
|
51
|
+
const activeAt = (s: { last_active_at?: string }) =>
|
|
52
|
+
s.last_active_at ? Date.parse(s.last_active_at) : -Infinity;
|
|
53
|
+
|
|
54
|
+
/** This folder's sessions first, then every other one; newest first within each. */
|
|
55
|
+
export function pickerOrder<T extends PickerSession>(sessions: readonly T[], cwd: string): T[] {
|
|
56
|
+
const want = folder(cwd);
|
|
57
|
+
const newest = (a: T, b: T) => activeAt(b) - activeAt(a);
|
|
58
|
+
const here = sessions.filter((s) => folder(s.cwd) === want).sort(newest);
|
|
59
|
+
return [...here, ...sessions.filter((s) => folder(s.cwd) !== want).sort(newest)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function relativeTime(at: string | undefined, now = Date.now()): string {
|
|
63
|
+
if (!at) return '';
|
|
64
|
+
const s = Math.max(0, (now - Date.parse(at)) / 1000);
|
|
65
|
+
if (s < 60) return 'just now';
|
|
66
|
+
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
|
67
|
+
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
|
68
|
+
if (s < 2 * 86400) return 'yesterday';
|
|
69
|
+
return `${Math.floor(s / 86400)} days ago`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One picker row: what you last asked (one short line), when, and where. */
|
|
73
|
+
export function sessionDescription(
|
|
74
|
+
s: { cwd: string; state: string; last_prompt?: string; last_active_at?: string },
|
|
75
|
+
now = Date.now(),
|
|
76
|
+
): string {
|
|
77
|
+
const prompt = s.last_prompt?.replace(/\s+/g, ' ').trim();
|
|
78
|
+
const quoted = prompt ? `“${prompt.length > 60 ? prompt.slice(0, 59) + '…' : prompt}”` : '';
|
|
79
|
+
return [quoted || s.state, relativeTime(s.last_active_at, now), s.cwd]
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(' · ');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What the picker shows for a desk session: its newest prompt (at most 200 characters) and when
|
|
85
|
+
* its transcript last changed. Board and station sessions get nothing extra. */
|
|
86
|
+
export function pickerSummary(
|
|
87
|
+
session: { id: string; capabilities: string[]; pi_session_file: string | null },
|
|
88
|
+
store: { lastPrompt(id: string): string | null },
|
|
89
|
+
): { last_prompt?: string; last_active_at?: string } {
|
|
90
|
+
if (!session.capabilities.includes('local-coding-reviewed')) return {};
|
|
91
|
+
const prompt = store.lastPrompt(session.id);
|
|
92
|
+
let at: string | undefined;
|
|
93
|
+
try {
|
|
94
|
+
if (session.pi_session_file) at = statSync(session.pi_session_file).mtime.toISOString();
|
|
95
|
+
} catch {
|
|
96
|
+
/* no transcript yet */
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
...(prompt ? { last_prompt: typedText(prompt).slice(0, 200) } : {}),
|
|
100
|
+
...(at ? { last_active_at: at } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!command` in a desk session (trial polish #12, 2026-10-31). The terminal never runs the command
|
|
3
|
+
* itself: it asks the model to run exactly that text through salaam_local. The service reads the
|
|
4
|
+
* same fixed prompt back from the turn's submission and binds the turn to the typed command, so a
|
|
5
|
+
* model that runs anything else is refused, whatever the session's mode.
|
|
6
|
+
*/
|
|
7
|
+
const LEAD = 'I want to run this shell command myself. Run exactly this command with salaam_local';
|
|
8
|
+
|
|
9
|
+
export function shellPassthrough(text: string): string | undefined {
|
|
10
|
+
const t = text.trim();
|
|
11
|
+
if (!t.startsWith('!') || t.startsWith('!!')) return undefined;
|
|
12
|
+
const command = t.slice(1).trim();
|
|
13
|
+
if (command === '' || command.includes('\n') || command.includes('```')) return undefined;
|
|
14
|
+
return [
|
|
15
|
+
LEAD,
|
|
16
|
+
'(action "command"), unchanged: no edits, no extra flags, no other commands. Then show me its',
|
|
17
|
+
'output and exit status, and do nothing else unless I ask.',
|
|
18
|
+
'',
|
|
19
|
+
'```sh',
|
|
20
|
+
command,
|
|
21
|
+
'```',
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The command a turn's prompt binds it to, when the prompt is exactly a `!command` request. */
|
|
26
|
+
export function boundShellCommand(prompt: string): string | undefined {
|
|
27
|
+
if (!prompt.startsWith(LEAD + '\n')) return undefined;
|
|
28
|
+
const lines = prompt.split('\n');
|
|
29
|
+
if (lines.length !== 7 || lines[4] !== '```sh' || lines[6] !== '```') return undefined;
|
|
30
|
+
const command = lines[5]!;
|
|
31
|
+
return shellPassthrough('!' + command) === prompt ? command : undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the person typed, for display: `! COMMAND` for a `!command` prompt, else the text. */
|
|
35
|
+
export function typedText(prompt: string): string {
|
|
36
|
+
const command = boundShellCommand(prompt);
|
|
37
|
+
return command === undefined ? prompt : '! ' + command;
|
|
38
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -572,8 +572,11 @@ export class Store {
|
|
|
572
572
|
expected: number,
|
|
573
573
|
controller: Controller,
|
|
574
574
|
clone: (source: Session, target: string) => string,
|
|
575
|
+
/** A rewind forks from an earlier conversation leaf. */
|
|
576
|
+
at?: { leaf: string | null },
|
|
575
577
|
): Session {
|
|
576
|
-
|
|
578
|
+
const input = { kind: 'fork', id, expected, ...(at ? { leaf: at.leaf } : {}) };
|
|
579
|
+
return this.request('local-operator', request, input, () => {
|
|
577
580
|
this.authorizeControl(id, controller);
|
|
578
581
|
const source = this.session(id);
|
|
579
582
|
if (source.revision !== expected)
|
|
@@ -598,7 +601,7 @@ export class Store {
|
|
|
598
601
|
.prepare(
|
|
599
602
|
'INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES(?,?,?,?,?,?)',
|
|
600
603
|
)
|
|
601
|
-
.run(target, source.cwd, target, file, source.pi_cursor, source.pi_leaf);
|
|
604
|
+
.run(target, source.cwd, target, file, source.pi_cursor, at ? at.leaf : source.pi_leaf);
|
|
602
605
|
this.event(
|
|
603
606
|
target,
|
|
604
607
|
'session.created',
|
|
@@ -623,7 +626,13 @@ export class Store {
|
|
|
623
626
|
this.event(
|
|
624
627
|
target,
|
|
625
628
|
'session.forked',
|
|
626
|
-
{
|
|
629
|
+
{
|
|
630
|
+
parent: id,
|
|
631
|
+
parent_revision: expected,
|
|
632
|
+
leaf: at ? at.leaf : source.pi_leaf,
|
|
633
|
+
cursor: source.pi_cursor,
|
|
634
|
+
...(at ? { rewound: true } : {}),
|
|
635
|
+
},
|
|
627
636
|
request,
|
|
628
637
|
);
|
|
629
638
|
return this.session(target);
|
|
@@ -655,6 +664,13 @@ export class Store {
|
|
|
655
664
|
lastActivityAny(): number {
|
|
656
665
|
return Math.max(this.openedAt, ...this.activity.values());
|
|
657
666
|
}
|
|
667
|
+
/** The newest prompt submitted to a session, for the session picker. */
|
|
668
|
+
lastPrompt(id: string): string | null {
|
|
669
|
+
const row = this.db
|
|
670
|
+
.prepare('SELECT text FROM submissions WHERE session_id=? ORDER BY rowid DESC LIMIT 1')
|
|
671
|
+
.get(id) as { text: string } | undefined;
|
|
672
|
+
return row?.text ?? null;
|
|
673
|
+
}
|
|
658
674
|
sessions(): Session[] {
|
|
659
675
|
return (
|
|
660
676
|
this.db.prepare('SELECT id FROM sessions ORDER BY rowid').all() as { id: string }[]
|
|
@@ -720,6 +736,27 @@ export class Store {
|
|
|
720
736
|
}
|
|
721
737
|
});
|
|
722
738
|
}
|
|
739
|
+
/**
|
|
740
|
+
* The one sanctioned route change: an operator switching model or thinking level in an
|
|
741
|
+
* interactive session between turns. Recorded as provider.route_changed; the worker restarts on
|
|
742
|
+
* the same Pi transcript with the new route.
|
|
743
|
+
*/
|
|
744
|
+
switchRoute(id: string, route: ProviderRoute): { from: ProviderRoute; to: ProviderRoute } {
|
|
745
|
+
return this.transaction(() => {
|
|
746
|
+
const from = this.providerRoute(id);
|
|
747
|
+
if (!from || !this.localWorkspace(id))
|
|
748
|
+
throw new ServiceError(
|
|
749
|
+
'route_switch_unavailable',
|
|
750
|
+
'Only interactive sessions can switch model mid-session',
|
|
751
|
+
409,
|
|
752
|
+
);
|
|
753
|
+
this.db
|
|
754
|
+
.prepare('UPDATE session_routes SET route=? WHERE session_id=?')
|
|
755
|
+
.run(canonical(route), id);
|
|
756
|
+
this.event(id, 'provider.route_changed', { from, to: route });
|
|
757
|
+
return { from, to: route };
|
|
758
|
+
});
|
|
759
|
+
}
|
|
723
760
|
providerRoute(id: string): ProviderRoute | null {
|
|
724
761
|
const row = this.db.prepare('SELECT route FROM session_routes WHERE session_id=?').get(id) as
|
|
725
762
|
{ route: string } | undefined;
|
|
@@ -1167,6 +1204,9 @@ export class Store {
|
|
|
1167
1204
|
);
|
|
1168
1205
|
let remaining = 256 * 1024;
|
|
1169
1206
|
const messages: SessionSnapshot['messages'] = [];
|
|
1207
|
+
// Walking back from the newest entry: tool calls belong just before the message that follows
|
|
1208
|
+
// them, so a turn's calls are shown in place rather than gathered at the end.
|
|
1209
|
+
let trailing: string[] = [];
|
|
1170
1210
|
while (
|
|
1171
1211
|
cursor &&
|
|
1172
1212
|
!seen.has(cursor) &&
|
|
@@ -1181,6 +1221,14 @@ export class Store {
|
|
|
1181
1221
|
cursor = entry.parentId ?? null;
|
|
1182
1222
|
const message = entry.message;
|
|
1183
1223
|
if (!message || !['user', 'assistant'].includes(message.role)) continue;
|
|
1224
|
+
if (Array.isArray(message.content)) {
|
|
1225
|
+
const calls = message.content
|
|
1226
|
+
.filter((part: { type: string; id?: unknown }) => part.type === 'toolCall')
|
|
1227
|
+
.map((part: { id: unknown }) => String(part.id));
|
|
1228
|
+
const later = messages.at(-1);
|
|
1229
|
+
if (calls.length && later) later.tool_calls = [...calls, ...(later.tool_calls ?? [])];
|
|
1230
|
+
else if (calls.length) trailing = [...calls, ...trailing];
|
|
1231
|
+
}
|
|
1184
1232
|
const text =
|
|
1185
1233
|
typeof message.content === 'string'
|
|
1186
1234
|
? message.content
|
|
@@ -1212,6 +1260,7 @@ export class Store {
|
|
|
1212
1260
|
attempts: boundedAttempts,
|
|
1213
1261
|
submissions: boundedSubmissions,
|
|
1214
1262
|
messages: messages.reverse(),
|
|
1263
|
+
...(trailing.length ? { trailing_tool_calls: trailing } : {}),
|
|
1215
1264
|
history_truncated: cursor !== null,
|
|
1216
1265
|
approvals: this.approvals(id),
|
|
1217
1266
|
operations: this.operations(id),
|
package/src/supervisor.ts
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';
|
|
@@ -110,6 +111,28 @@ interface Worker {
|
|
|
110
111
|
resolve: () => void;
|
|
111
112
|
reject: (error: Error) => void;
|
|
112
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
|
+
];
|
|
113
136
|
export function processIdentity(pid: number): string | null {
|
|
114
137
|
if (!Number.isSafeInteger(pid) || pid < 1) return null;
|
|
115
138
|
try {
|
|
@@ -166,6 +189,33 @@ export function processIdentity(pid: number): string | null {
|
|
|
166
189
|
return null;
|
|
167
190
|
}
|
|
168
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
|
+
}
|
|
169
219
|
function groupExists(pid: number): boolean {
|
|
170
220
|
try {
|
|
171
221
|
process.kill(-pid, 0);
|
|
@@ -177,6 +227,10 @@ function groupExists(pid: number): boolean {
|
|
|
177
227
|
}
|
|
178
228
|
export class Supervisor extends EventEmitter {
|
|
179
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;
|
|
180
234
|
tasks!: TaskContext;
|
|
181
235
|
private compactionJobs = new Set<Promise<void>>();
|
|
182
236
|
private readonly fetchBreaker = new HostBreaker();
|
|
@@ -558,6 +612,21 @@ export class Supervisor extends EventEmitter {
|
|
|
558
612
|
});
|
|
559
613
|
worker.pi.on('activity', (observation: import('./pi.js').ToolObservation) => {
|
|
560
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
|
+
}
|
|
561
630
|
try {
|
|
562
631
|
this.store.request(
|
|
563
632
|
`tool-activity:${id}:${epoch}:${worker.attempt}`,
|
|
@@ -750,6 +819,44 @@ export class Supervisor extends EventEmitter {
|
|
|
750
819
|
return w;
|
|
751
820
|
}
|
|
752
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
|
+
}
|
|
753
860
|
private checkedStepAttempt(attempt: string): boolean {
|
|
754
861
|
const row = this.store.db
|
|
755
862
|
.prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
|
|
@@ -1123,6 +1230,83 @@ export class Supervisor extends EventEmitter {
|
|
|
1123
1230
|
if (method === 'station.status') return this.station.view(c.id);
|
|
1124
1231
|
throw new ServiceError('invalid_field', 'Unsupported station method');
|
|
1125
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
|
+
}
|
|
1126
1310
|
if (method.startsWith('local.') && method !== 'local.read') {
|
|
1127
1311
|
if (
|
|
1128
1312
|
!this.localCoding ||
|
|
@@ -1132,14 +1316,26 @@ export class Supervisor extends EventEmitter {
|
|
|
1132
1316
|
)
|
|
1133
1317
|
throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
|
|
1134
1318
|
this.assertActive(id, w.epoch, w.attempt);
|
|
1135
|
-
if (method === 'local.prepare')
|
|
1136
|
-
|
|
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(
|
|
1137
1326
|
id,
|
|
1138
1327
|
w.epoch,
|
|
1139
1328
|
w.attempt,
|
|
1140
|
-
|
|
1141
|
-
|
|
1329
|
+
toolCall,
|
|
1330
|
+
args,
|
|
1142
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
|
+
}
|
|
1143
1339
|
const c = this.localCoding.get(string(body.call_id, 'call_id'));
|
|
1144
1340
|
if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
|
|
1145
1341
|
throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
|
|
@@ -1153,6 +1349,12 @@ export class Supervisor extends EventEmitter {
|
|
|
1153
1349
|
this.assertActive(id, w.epoch, w.attempt);
|
|
1154
1350
|
this.store.taskGrantCheck?.(id);
|
|
1155
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
|
+
);
|
|
1156
1358
|
const result = readLocalWorkspace(this.store.session(id).cwd, args);
|
|
1157
1359
|
this.store.transaction(() =>
|
|
1158
1360
|
this.store.event(id, 'workspace.read', {
|
|
@@ -1439,26 +1641,13 @@ export class Supervisor extends EventEmitter {
|
|
|
1439
1641
|
].includes(error.code)
|
|
1440
1642
|
)
|
|
1441
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;
|
|
1442
1647
|
if (
|
|
1443
1648
|
(method.startsWith('coding.') || method.startsWith('local.')) &&
|
|
1444
1649
|
error instanceof ServiceError &&
|
|
1445
|
-
|
|
1446
|
-
'local_busy',
|
|
1447
|
-
'workspace_read_failed',
|
|
1448
|
-
'unsafe_path',
|
|
1449
|
-
'workspace_changed',
|
|
1450
|
-
'workspace_busy',
|
|
1451
|
-
'workspace_limit',
|
|
1452
|
-
'workspace_conflict',
|
|
1453
|
-
'not_found',
|
|
1454
|
-
'binary_file',
|
|
1455
|
-
'line_limit',
|
|
1456
|
-
'invalid_field',
|
|
1457
|
-
'request_conflict',
|
|
1458
|
-
'command_conflict',
|
|
1459
|
-
'command_limit',
|
|
1460
|
-
'invalid_cursor',
|
|
1461
|
-
].includes(error.code)
|
|
1650
|
+
LOCAL_TOOL_ERRORS.includes(error.code)
|
|
1462
1651
|
)
|
|
1463
1652
|
throw error;
|
|
1464
1653
|
if (
|
|
@@ -1510,6 +1699,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1510
1699
|
const state = {
|
|
1511
1700
|
...this.store.session(id),
|
|
1512
1701
|
provider_route: this.store.providerRoute(id) ?? undefined,
|
|
1702
|
+
project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
|
|
1513
1703
|
provider_capabilities:
|
|
1514
1704
|
cap === undefined
|
|
1515
1705
|
? this.providerCapabilities()
|
|
@@ -1793,11 +1983,14 @@ export class Supervisor extends EventEmitter {
|
|
|
1793
1983
|
// A checked task step is decided by its checks, not by a tool error the model went on
|
|
1794
1984
|
// to recover from (operator decision, 2026-10-23). The error stays in the journal
|
|
1795
1985
|
// (tool.finished failed=true). A delegated child turn, whose denied probes are its
|
|
1796
|
-
// boundary proof, and any attempt outside a checked step still fail on it
|
|
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).
|
|
1797
1989
|
if (
|
|
1798
1990
|
value.type === 'tool_execution_end' &&
|
|
1799
1991
|
value.isError === true &&
|
|
1800
|
-
(this.children?.owned(id) ||
|
|
1992
|
+
(this.children?.owned(id) ||
|
|
1993
|
+
(!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id)))
|
|
1801
1994
|
)
|
|
1802
1995
|
outcome ??= {
|
|
1803
1996
|
version: 1,
|
|
@@ -1807,7 +2000,33 @@ export class Supervisor extends EventEmitter {
|
|
|
1807
2000
|
};
|
|
1808
2001
|
w.pi.on('record', record);
|
|
1809
2002
|
try {
|
|
1810
|
-
|
|
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
|
+
});
|
|
1811
2030
|
const disposition = object(response.data ?? {}).disposition;
|
|
1812
2031
|
if (disposition !== 'started') throw new Error('Pi did not start the dispatched prompt');
|
|
1813
2032
|
this.store.running(work.attempt.id);
|
|
@@ -1824,6 +2043,7 @@ export class Supervisor extends EventEmitter {
|
|
|
1824
2043
|
{ epoch: w.epoch, ...entries },
|
|
1825
2044
|
outcome,
|
|
1826
2045
|
);
|
|
2046
|
+
void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => {});
|
|
1827
2047
|
} catch (error) {
|
|
1828
2048
|
const message = redactFailureText((error as Error).message, w.secrets);
|
|
1829
2049
|
if (!w.stopping && !this.store.cancelled(work.attempt.id))
|
|
@@ -1910,3 +2130,21 @@ export function toolFailureMessage(toolName: unknown): string {
|
|
|
1910
2130
|
? `Tool execution failed (tool ${toolName})`
|
|
1911
2131
|
: 'Tool execution failed';
|
|
1912
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
|
+
}
|
package/src/task-context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { TaskWork } from './task-work.js';
|
|
3
3
|
import type { ContextAdmission } from './context-admission.js';
|
|
4
|
+
import type { ProjectInstructions } from './project-instructions.js';
|
|
4
5
|
import type { Store } from './store.js';
|
|
5
6
|
import type { Artifacts } from './artifacts.js';
|
|
6
7
|
import { canonical, ServiceError, string, integer, object } from './protocol.js';
|
|
@@ -41,6 +42,10 @@ export interface Compaction {
|
|
|
41
42
|
export class TaskContext {
|
|
42
43
|
work?: TaskWork;
|
|
43
44
|
admitted?: ContextAdmission;
|
|
45
|
+
/** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
|
|
46
|
+
instructions?: ProjectInstructions;
|
|
47
|
+
/** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
|
|
48
|
+
checkpoints?: import('./turn-rewind.js').TurnCheckpoints;
|
|
44
49
|
constructor(
|
|
45
50
|
readonly store: Store,
|
|
46
51
|
readonly artifacts: Artifacts,
|
|
@@ -99,11 +104,13 @@ export class TaskContext {
|
|
|
99
104
|
if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
|
|
100
105
|
throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
|
|
101
106
|
const admittedContext = this.admitted?.frame(id);
|
|
102
|
-
|
|
107
|
+
const projectInstructions = this.instructions?.frame(id);
|
|
108
|
+
return admittedContext || projectInstructions
|
|
103
109
|
? 'SALAAM_TASK_STATE ' +
|
|
104
110
|
canonical({
|
|
105
111
|
...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
|
|
106
|
-
admitted_context: admittedContext,
|
|
112
|
+
...(admittedContext ? { admitted_context: admittedContext } : {}),
|
|
113
|
+
...(projectInstructions ? { project_instructions: projectInstructions } : {}),
|
|
107
114
|
})
|
|
108
115
|
: frame;
|
|
109
116
|
}
|