@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.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. 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
- return this.request('local-operator', request, { kind: 'fork', id, expected }, () => {
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
- { parent: id, parent_revision: expected, leaf: source.pi_leaf, cursor: source.pi_cursor },
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
- return this.localCoding.prepare(
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
- string(body.tool_call_id, 'tool_call_id', 512),
1141
- object(body.args),
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) || !this.checkedStepAttempt(work.attempt.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
- const response = await w.pi.command('prompt', { message: work.submission.text });
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
+ }
@@ -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
- return admittedContext
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
  }