@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,103 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ /**
4
+ * Salaam tells the terminal what it is doing (trial polish C, 2026-10-31): the window title says
5
+ * working / needs you, and inside Herdr the pane reports its state through Herdr's documented
6
+ * `pane report-agent` command (herdrdev/herdr, docs/next/.../add-herdr-support.mdx). Herdr's
7
+ * docs define no OSC 7501 sequence, so none is sent. SALAAM_TERMINAL_STATUS=off turns both off.
8
+ * Neither carries prompt text, command text or anything secret: only the state and a short
9
+ * session name.
10
+ */
11
+ export type TerminalState = 'idle' | 'working' | 'blocked';
12
+
13
+ export function terminalState(
14
+ snapshot: { attempts?: { state: string }[]; approvals?: { state: string }[] } | undefined,
15
+ ): TerminalState {
16
+ if (!snapshot) return 'idle';
17
+ if ((snapshot.approvals ?? []).some((a) => a.state === 'pending')) return 'blocked';
18
+ if (
19
+ (snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state))
20
+ )
21
+ return 'working';
22
+ return 'idle';
23
+ }
24
+
25
+ export function titleFor(state: TerminalState, name: string | undefined): string {
26
+ // eslint-disable-next-line no-control-regex
27
+ const clean = name
28
+ ?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
29
+ .trim()
30
+ .slice(0, 32);
31
+ if (!clean) return 'Salaam';
32
+ const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
33
+ return `Salaam · ${clean} · ${words}`;
34
+ }
35
+
36
+ export function statusEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
37
+ return env.SALAAM_TERMINAL_STATUS !== 'off';
38
+ }
39
+
40
+ type Run = (bin: string, args: string[], done: () => void) => void;
41
+ const run: Run = (bin, args, done) => {
42
+ // Short timeout, failures ignored: Herdr must never slow Salaam down.
43
+ execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
44
+ };
45
+
46
+ /** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
47
+ export class HerdrReporter {
48
+ readonly enabled: boolean;
49
+ private seq = 0;
50
+ private busy = false;
51
+ private queued?: string[];
52
+ private last?: string;
53
+ constructor(
54
+ private readonly env: NodeJS.ProcessEnv = process.env,
55
+ private readonly exec: Run = run,
56
+ private readonly now: () => number = Date.now,
57
+ ) {
58
+ this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
59
+ }
60
+ private nextSeq(): string {
61
+ this.seq = Math.max(this.seq + 1, this.now());
62
+ return String(this.seq);
63
+ }
64
+ private head(verb: string): string[] {
65
+ return ['pane', verb, this.env.HERDR_PANE_ID!, '--source', 'salaam', '--agent', 'salaam'];
66
+ }
67
+ report(state: TerminalState, session: string | undefined): void {
68
+ if (!this.enabled) return;
69
+ const key = state + ' ' + (session ?? '');
70
+ if (key === this.last) return;
71
+ this.last = key;
72
+ this.queued = [
73
+ ...this.head('report-agent'),
74
+ '--state',
75
+ state,
76
+ '--seq',
77
+ '',
78
+ ...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
79
+ ...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
80
+ ];
81
+ this.flush();
82
+ }
83
+ release(): void {
84
+ if (!this.enabled) return;
85
+ this.queued = undefined;
86
+ this.exec(
87
+ this.env.HERDR_BIN_PATH!,
88
+ [...this.head('release-agent'), '--seq', this.nextSeq()],
89
+ () => {},
90
+ );
91
+ }
92
+ private flush(): void {
93
+ if (this.busy || !this.queued) return;
94
+ const args = this.queued;
95
+ this.queued = undefined;
96
+ args[args.indexOf('--seq') + 1] = this.nextSeq();
97
+ this.busy = true;
98
+ this.exec(this.env.HERDR_BIN_PATH!, args, () => {
99
+ this.busy = false;
100
+ this.flush();
101
+ });
102
+ }
103
+ }
@@ -0,0 +1,133 @@
1
+ import { humanTool, safeText } from './tui-theme.js';
2
+
3
+ /**
4
+ * One compact line per tool call in the conversation (trial polish #9): what ran and how it
5
+ * ended, e.g. `Edit src/x.ts +3 −1` or `Bash npm test ✓ exit 0` with the last output lines.
6
+ * Built only from the recorded tool activity; it never reads the workspace.
7
+ */
8
+ export interface ToolLine {
9
+ state: 'running' | 'finished' | 'failed';
10
+ label: string;
11
+ /** The last output lines of a finished command (at most `tail` lines). */
12
+ tail: string[];
13
+ /** A command's full output as text, for the transcript; absent when only the raw record exists. */
14
+ full?: string;
15
+ }
16
+ const lineCount = (text: string) => (text === '' ? 0 : text.replace(/\n$/, '').split('\n').length);
17
+ function short(text: string, max = 60): string {
18
+ const first = safeText(text).split('\n')[0]!.trim();
19
+ return first.length > max ? first.slice(0, max - 1) + '…' : first;
20
+ }
21
+ /** The recorded result of a local command: its exit code and output, even from a truncated record. */
22
+ function commandResult(output?: string): { exit?: number | null; text?: string } {
23
+ if (!output) return {};
24
+ try {
25
+ const parsed = JSON.parse(output) as { exit_code?: number | null; output?: string };
26
+ return { exit: parsed.exit_code, text: parsed.output };
27
+ } catch {
28
+ const exit = /"exit_code":(-?\d+|null)/.exec(output)?.[1];
29
+ return exit === undefined ? {} : { exit: exit === 'null' ? null : Number(exit) };
30
+ }
31
+ }
32
+ export function toolLine(
33
+ tool: string,
34
+ input: string | undefined,
35
+ finished: { failed?: boolean; output?: string } | undefined,
36
+ tail = 3,
37
+ ): ToolLine {
38
+ const state = !finished ? 'running' : finished.failed ? 'failed' : 'finished';
39
+ let args: {
40
+ action?: string;
41
+ command?: string;
42
+ background?: boolean;
43
+ edits?: {
44
+ path?: string;
45
+ content?: string;
46
+ sha256?: string | null;
47
+ replacements?: { old: string; new: string }[];
48
+ }[];
49
+ } = {};
50
+ try {
51
+ args = JSON.parse(input ?? '{}');
52
+ } catch {}
53
+ if (tool === 'salaam_local' && args.action === 'edit' && Array.isArray(args.edits)) {
54
+ let added = 0,
55
+ removed = 0,
56
+ created = false;
57
+ for (const edit of args.edits) {
58
+ if (edit.replacements?.length)
59
+ for (const r of edit.replacements) {
60
+ added += lineCount(r.new);
61
+ removed += lineCount(r.old);
62
+ }
63
+ else if (typeof edit.content === 'string') {
64
+ added += lineCount(edit.content);
65
+ if (edit.sha256 === null) created = true;
66
+ }
67
+ }
68
+ const what =
69
+ args.edits.length === 1
70
+ ? safeText(String(args.edits[0]!.path ?? ''))
71
+ : `${args.edits.length} files`;
72
+ return {
73
+ state,
74
+ label: `Edit ${what} +${added} −${removed}${created ? ' (new)' : ''}`,
75
+ tail: [],
76
+ };
77
+ }
78
+ if (tool === 'salaam_local' && args.action === 'command' && typeof args.command === 'string') {
79
+ const result = commandResult(finished?.output);
80
+ const ended =
81
+ !finished || args.background
82
+ ? args.background && finished
83
+ ? ' · background'
84
+ : ''
85
+ : result.exit === 0
86
+ ? ' ✓ exit 0'
87
+ : result.exit === undefined || result.exit === null
88
+ ? finished.failed
89
+ ? ' × did not finish'
90
+ : ''
91
+ : ` × exit ${result.exit}`;
92
+ const lines = (result.text ?? '')
93
+ .replace(/\n+$/, '')
94
+ .split('\n')
95
+ .filter((l) => l.trim() !== '');
96
+ return {
97
+ state:
98
+ finished && result.exit !== undefined && result.exit !== null && result.exit !== 0
99
+ ? 'failed'
100
+ : state,
101
+ label: `Bash ${short(args.command)}${ended}`,
102
+ tail: finished ? lines.slice(-tail).map((l) => short(l, 120)) : [],
103
+ ...(result.text !== undefined ? { full: result.text.replace(/\n+$/, '') } : {}),
104
+ };
105
+ }
106
+ return { state, label: humanTool(tool, input), tail: [] };
107
+ }
108
+
109
+ /** The verb the progress line shows for what the agent is doing now. */
110
+ export function activityVerb(running?: ToolLine): string {
111
+ if (!running) return 'Thinking';
112
+ if (running.label.startsWith('Edit ')) return 'Editing';
113
+ if (running.label.startsWith('Bash ')) return 'Running ' + running.label.slice(5);
114
+ return running.label;
115
+ }
116
+
117
+ const SPIN = ['◐', '◓', '◑', '◒'];
118
+ /** The live progress line while a turn runs: spinner, verb, elapsed seconds, tokens, interrupt hint. */
119
+ export function progressLine(
120
+ verb: string,
121
+ startedAt: number,
122
+ now: number,
123
+ tokens?: string,
124
+ ): string {
125
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
126
+ const elapsed = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
127
+ return [
128
+ SPIN[seconds % SPIN.length] + ' ' + safeText(verb) + '…',
129
+ elapsed,
130
+ ...(tokens ? [tokens + ' tokens'] : []),
131
+ 'esc to interrupt',
132
+ ].join(' · ');
133
+ }
@@ -0,0 +1,38 @@
1
+ /** /classifier (operator 2026-10-31): the command reviewer has its own model selection. */
2
+ export interface ClassifierView {
3
+ configured: boolean;
4
+ enabled: boolean;
5
+ choice: string;
6
+ pinned: boolean;
7
+ model?: string;
8
+ on_unavailable?: string;
9
+ options: { name: string; provider: string; model: string }[];
10
+ }
11
+
12
+ /** Picker rows: every configured reviewer, then Off. Empty when there is nothing to choose. */
13
+ export function classifierItems(v: ClassifierView) {
14
+ if (!v.configured || v.pinned) return [];
15
+ const mark = (on: boolean) => (on ? '● ' : '○ ');
16
+ return [
17
+ ...v.options.map((o) => ({
18
+ value: o.name,
19
+ label: mark(v.choice === o.name) + o.name,
20
+ description: `${o.provider} · ${o.model}`,
21
+ })),
22
+ { value: 'off', label: mark(v.choice === 'off') + 'Off', description: 'Rules only' },
23
+ ];
24
+ }
25
+
26
+ export function classifierNotice(v: ClassifierView): string {
27
+ if (!v.configured && v.choice === 'off' && !v.pinned)
28
+ return 'No command reviewer is configured; set --danger-classifier-config on the service.';
29
+ const what = v.enabled
30
+ ? `Command reviewer ${v.choice} (${v.model}) is on for this session` +
31
+ ` (if it does not answer: ${v.on_unavailable === 'ask' ? 'ask' : 'rules only'})`
32
+ : 'Command reviewer is off for this session';
33
+ return (
34
+ what +
35
+ (v.pinned ? ', pinned by this host' : '') +
36
+ '. It can only make a command ask, never let one run.'
37
+ );
38
+ }
package/src/tui-client.ts CHANGED
@@ -45,7 +45,15 @@ export interface UsageView {
45
45
  known_output: number;
46
46
  known_cost: number;
47
47
  estimated_cost: number;
48
+ /** Prompt tokens read from or written to the provider's cache; most of a turn's input. */
49
+ known_cache_read?: number;
50
+ known_cache_write?: number;
48
51
  }
52
+ /** All input tokens, cached or not. `known_input` alone is only the uncached part. */
53
+ export const usageInput = (u: UsageView) =>
54
+ u.known_input + (u.known_cache_read ?? 0) + (u.known_cache_write ?? 0);
55
+ /** Known and estimated costs come from different observations, so the spend is their sum. */
56
+ export const usageCost = (u: UsageView) => u.known_cost + u.estimated_cost;
49
57
  export interface ToolActivity {
50
58
  seq: number;
51
59
  kind: string;
@@ -57,6 +65,8 @@ export interface ToolActivity {
57
65
  input?: string;
58
66
  input_truncated?: boolean;
59
67
  failed?: boolean;
68
+ output?: string;
69
+ output_truncated?: boolean;
60
70
  };
61
71
  }
62
72
  export interface LivePreview {
@@ -66,6 +76,15 @@ export interface LivePreview {
66
76
  }
67
77
  export type OperatorConnection = 'connecting' | 'connected' | 'reconnecting' | 'offline';
68
78
  /** An operator client only: the service owns workers, authority and durable outcomes. */
79
+ /** "AGENTS.md" or "AGENTS.md (2 files)": which instruction files guide this session. */
80
+ export function instructionsLabel(
81
+ summary: import('./project-instructions.js').InstructionsSummary,
82
+ ): string {
83
+ const names = [...new Set(summary.files.map((f) => f.path.split('/').at(-1)!))].join(' + ');
84
+ return summary.files.length > 1 ? `${names} (${summary.files.length} files)` : names;
85
+ }
86
+ /** Longest a stale control lease is waited out (the service's lease TTL is 15 s). */
87
+ const CONTROL_LAPSE_MS = 16000;
69
88
  export class OperatorClient extends EventEmitter {
70
89
  readonly socket: string;
71
90
  readonly clientId = randomUUID();
@@ -142,7 +161,13 @@ export class OperatorClient extends EventEmitter {
142
161
  policy: import('./autonomy.js').PermissionPolicy;
143
162
  configurable: boolean;
144
163
  coding: boolean;
145
- command_auto_approval: false;
164
+ command_auto_approval: boolean;
165
+ /** Phase 2b: whether this host lets a session choose full access. */
166
+ full_access_allowed?: boolean;
167
+ /** Whether this desk session may choose YOLO. */
168
+ yolo_allowed?: boolean;
169
+ /** Where the next command runs, e.g. "in a Seatbelt sandbox (workspace-write)". */
170
+ command_sandbox?: string;
146
171
  }> {
147
172
  if (!this.snapshot) throw new Error('Choose a session first.');
148
173
  return call(this.socket, `/v1/sessions/${this.snapshot.id}/permissions`);
@@ -150,7 +175,11 @@ export class OperatorClient extends EventEmitter {
150
175
  async setPermissions(
151
176
  session: string,
152
177
  expected: number,
153
- policy: { mode: import('./autonomy.js').PermissionMode; paths: string[] },
178
+ policy: {
179
+ mode: import('./autonomy.js').PermissionMode;
180
+ paths: string[];
181
+ access?: 'full' | 'yolo';
182
+ },
154
183
  ): Promise<void> {
155
184
  if (this.snapshot?.id !== session)
156
185
  throw new Error('Session changed; review permissions again.');
@@ -165,6 +194,64 @@ export class OperatorClient extends EventEmitter {
165
194
  );
166
195
  await this.refresh();
167
196
  }
197
+ /** Desk-session approval rules (phase 2a). */
198
+ async approvalRules(): Promise<{
199
+ applies: boolean;
200
+ rules: import('./approval-rules.js').StoredRule[];
201
+ problems: string[];
202
+ }> {
203
+ if (!this.snapshot) throw new Error('Choose a session first.');
204
+ return call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`);
205
+ }
206
+ /** Turn a reviewed request into a rule: its exact command words or its exact edited paths. */
207
+ async ruleFromOperation(operation: string, scope: 'session' | 'repo'): Promise<void> {
208
+ if (!this.snapshot) throw new Error('Choose a session first.');
209
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
210
+ ...this.actor(),
211
+ request_id: randomUUID(),
212
+ scope,
213
+ operation_id: operation,
214
+ });
215
+ }
216
+ async addRule(
217
+ scope: 'session' | 'repo',
218
+ rule: import('./approval-rules.js').ApprovalRule,
219
+ ): Promise<void> {
220
+ if (!this.snapshot) throw new Error('Choose a session first.');
221
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
222
+ ...this.actor(),
223
+ request_id: randomUUID(),
224
+ scope,
225
+ rule,
226
+ });
227
+ this.report('Rule added.');
228
+ }
229
+ async removeRule(id: string): Promise<void> {
230
+ if (!this.snapshot) throw new Error('Choose a session first.');
231
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules/${id}/remove`, {
232
+ ...this.actor(),
233
+ });
234
+ this.report('Rule removed.');
235
+ }
236
+ /** Your hooks and this workspace's, with their trust state (Phase 4b). */
237
+ async hooks(): Promise<{ sources: import('./hooks.js').HookSource[] }> {
238
+ if (!this.snapshot) throw new Error('Choose a session first.');
239
+ return call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks`);
240
+ }
241
+ /** Trust this workspace's hooks file as reviewed; `digest` binds the content you saw. */
242
+ async trustHooks(digest: string): Promise<void> {
243
+ if (!this.snapshot) throw new Error('Choose a session first.');
244
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/trust`, {
245
+ ...this.actor(),
246
+ digest,
247
+ });
248
+ this.report('Workspace hooks trusted. They run from the next tool call or prompt.');
249
+ }
250
+ async revokeHooks(): Promise<void> {
251
+ if (!this.snapshot) throw new Error('Choose a session first.');
252
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/revoke`, { ...this.actor() });
253
+ this.report('Workspace hooks no longer trusted; they will not run.');
254
+ }
168
255
  async steer(text: string): Promise<void> {
169
256
  if (!this.snapshot) throw new Error('Choose a session first.');
170
257
  const result = await call<{ state: string }>(
@@ -208,6 +295,99 @@ export class OperatorClient extends EventEmitter {
208
295
  await this.refresh();
209
296
  this.report('Reverted the reviewed local edit. Conversation history is preserved.');
210
297
  }
298
+ async checkpoints(): Promise<import('./turn-rewind.js').TurnCheckpoint[]> {
299
+ if (!this.snapshot) throw new Error('Choose a session first.');
300
+ return (
301
+ await call<{ checkpoints: import('./turn-rewind.js').TurnCheckpoint[] }>(
302
+ this.socket,
303
+ `/v1/sessions/${this.snapshot.id}/checkpoints`,
304
+ )
305
+ ).checkpoints;
306
+ }
307
+ async rewindReview(
308
+ attempt: string,
309
+ mode: import('./turn-rewind.js').RewindMode,
310
+ ): Promise<import('./turn-rewind.js').RewindReview> {
311
+ if (!this.snapshot) throw new Error('Choose a session first.');
312
+ return call(
313
+ this.socket,
314
+ `/v1/sessions/${this.snapshot.id}/rewind?attempt=${encodeURIComponent(attempt)}&mode=${mode}`,
315
+ );
316
+ }
317
+ /** Returns the rewound turn's instruction so it can be edited and sent again. */
318
+ async rewind(review: import('./turn-rewind.js').RewindReview): Promise<string> {
319
+ if (!this.snapshot) throw new Error('Choose a session first.');
320
+ const id = this.snapshot.id;
321
+ const result = await call<{ state: string; session_id: string; text: string }>(
322
+ this.socket,
323
+ `/v1/sessions/${id}/rewind`,
324
+ {
325
+ ...this.actor(),
326
+ request_id: randomUUID(),
327
+ attempt_id: review.attempt_id,
328
+ mode: review.mode,
329
+ review_digest: review.digest,
330
+ },
331
+ );
332
+ if (result.state !== 'completed')
333
+ throw new Error('Rewind outcome is uncertain; inspect files before continuing.');
334
+ if (result.session_id !== id) await this.open(result.session_id);
335
+ else await this.refresh();
336
+ this.report(
337
+ (review.mode === 'conversation'
338
+ ? 'Conversation'
339
+ : review.mode === 'files'
340
+ ? 'Files'
341
+ : 'Files and conversation') +
342
+ ` rewound to before turn ${review.turn}.` +
343
+ (result.session_id !== id ? ' The earlier conversation stays as its own session.' : ''),
344
+ );
345
+ return result.text;
346
+ }
347
+ async changeReview(): Promise<import('./turn-rewind.js').ChangeReview | null> {
348
+ if (!this.snapshot) throw new Error('Choose a session first.');
349
+ return (
350
+ await call<{ review: import('./turn-rewind.js').ChangeReview | null }>(
351
+ this.socket,
352
+ `/v1/sessions/${this.snapshot.id}/review`,
353
+ )
354
+ ).review;
355
+ }
356
+ async changeHunks(
357
+ review: import('./turn-rewind.js').ChangeReview,
358
+ path: string,
359
+ ): Promise<import('./turn-checkpoints.js').CheckpointHunk[]> {
360
+ if (!this.snapshot) throw new Error('Choose a session first.');
361
+ const q = new URLSearchParams({ path, base: review.base, current: review.current });
362
+ return (
363
+ await call<{ hunks: import('./turn-checkpoints.js').CheckpointHunk[] }>(
364
+ this.socket,
365
+ `/v1/sessions/${this.snapshot.id}/review/hunks?${q}`,
366
+ )
367
+ ).hunks;
368
+ }
369
+ async changeAct(
370
+ review: import('./turn-rewind.js').ChangeReview,
371
+ action: 'accept' | 'revert',
372
+ path: string,
373
+ hunk?: import('./turn-checkpoints.js').CheckpointHunk,
374
+ ): Promise<import('./turn-rewind.js').ChangeReview | null> {
375
+ if (!this.snapshot) throw new Error('Choose a session first.');
376
+ return (
377
+ await call<{ review: import('./turn-rewind.js').ChangeReview | null }>(
378
+ this.socket,
379
+ `/v1/sessions/${this.snapshot.id}/review`,
380
+ {
381
+ ...this.actor(),
382
+ action,
383
+ path,
384
+ base: review.base,
385
+ current: review.current,
386
+ ...(hunk ? { hunk } : {}),
387
+ },
388
+ )
389
+ ).review;
390
+ }
211
391
  async fork(): Promise<void> {
212
392
  if (!this.snapshot) throw new Error('Choose a session first.');
213
393
  const session = await call<Session>(this.socket, `/v1/sessions/${this.snapshot.id}/fork`, {
@@ -291,15 +471,15 @@ export class OperatorClient extends EventEmitter {
291
471
  this.snapshot = await call<SessionSnapshot>(this.socket, `/v1/sessions/${id}/snapshot`);
292
472
  if (!options.observe) {
293
473
  try {
294
- this.lease = await acquireControl(this.socket, id, this.clientId, options.takeover);
474
+ this.lease = await this.acquireOrWait(id, options.takeover);
295
475
  } catch (error) {
296
476
  if (
297
477
  !(error instanceof ServiceError) ||
298
- !['control_conflict', 'control_required'].includes(error.code)
478
+ !['control_conflict', 'control_required', 'control_busy'].includes(error.code)
299
479
  )
300
480
  throw error;
301
481
  this.notice =
302
- 'Another client has control. You are observing; use Take control to take over.';
482
+ 'Another terminal is using this session and still renews its control. You are observing; press ctrl+k and choose Take control to take it over.';
303
483
  }
304
484
  }
305
485
  if (this.lease) await call(this.socket, `/v1/sessions/${id}/resume`, this.actor());
@@ -313,7 +493,26 @@ export class OperatorClient extends EventEmitter {
313
493
  this.watcher = this.watch(id, generation);
314
494
  this.changed();
315
495
  }
316
- private actor(): { client_id: string; token: string } {
496
+ /** Acquire control. A lease left by a terminal that was killed lapses within its short TTL, so
497
+ * wait that out once instead of opening as an observer; a live terminal renews and keeps it. */
498
+ private async acquireOrWait(id: string, takeover?: boolean): Promise<ControlLease> {
499
+ try {
500
+ return await acquireControl(this.socket, id, this.clientId, takeover);
501
+ } catch (error) {
502
+ if (takeover || !(error instanceof ServiceError) || error.code !== 'control_busy')
503
+ throw error;
504
+ const snapshot = await call<SessionSnapshot>(this.socket, `/v1/sessions/${id}/snapshot`);
505
+ const wait = snapshot.control.expires_at - Date.now();
506
+ if (wait <= 0 || wait > CONTROL_LAPSE_MS) throw error;
507
+ this.notice = `Waiting ${Math.ceil(wait / 1000)} s for the previous terminal's control to lapse…`;
508
+ this.changed();
509
+ await new Promise((done) => setTimeout(done, wait + 250));
510
+ const lease = await acquireControl(this.socket, id, this.clientId);
511
+ this.notice = 'You have control: the previous terminal had stopped.';
512
+ return lease;
513
+ }
514
+ }
515
+ actor(): { client_id: string; token: string } {
317
516
  if (!this.lease) throw new Error('Take control before sending an instruction.');
318
517
  return { client_id: this.clientId, token: this.lease.token };
319
518
  }
@@ -358,10 +557,10 @@ export class OperatorClient extends EventEmitter {
358
557
  await job;
359
558
  this.changed();
360
559
  }
361
- async submit(text: string, continues?: string): Promise<string> {
560
+ async submit(text: string, continues?: string, confirmSensitive = false): Promise<string> {
362
561
  this.actor();
363
562
  if (!this.snapshot || !this.outbox) throw new Error('Open a session first.');
364
- const record = this.outbox.enqueue(text, this.snapshot.revision, continues);
563
+ const record = this.outbox.enqueue(text, this.snapshot.revision, continues, confirmSensitive);
365
564
  this.notice = 'Instruction saved locally. Delivering…';
366
565
  this.changed();
367
566
  try {
@@ -374,6 +573,8 @@ export class OperatorClient extends EventEmitter {
374
573
  'Delivery pending. The saved instruction will be retried: ' + (error as Error).message;
375
574
  }
376
575
  await this.refresh().catch(() => {});
576
+ // A failed worker cannot take the instruction: say so instead of "accepted".
577
+ this.notice = sessionFailure(this.snapshot) ?? this.notice;
377
578
  this.changed();
378
579
  return record.request_id;
379
580
  }
@@ -469,6 +670,19 @@ export class OperatorClient extends EventEmitter {
469
670
  this.notice = 'Reviewed context admitted. Tool permissions are unchanged.';
470
671
  await this.refresh();
471
672
  }
673
+ /** Switch this session's model or thinking level between turns; the conversation carries on. */
674
+ async switchRoute(route: import('./provider-config.js').ProviderRoute): Promise<void> {
675
+ if (!this.snapshot) throw new Error('Open a session first.');
676
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/route`, {
677
+ ...this.actor(),
678
+ route: {
679
+ provider: route.provider === 'salaam-proof' ? 'offline' : route.provider,
680
+ ...(route.provider === 'salaam-proof' ? {} : { model: route.model }),
681
+ ...(route.reasoning === undefined ? {} : { reasoning: route.reasoning }),
682
+ },
683
+ });
684
+ await this.refresh();
685
+ }
472
686
  async decide(approval: Approval, decision: 'approve' | 'deny'): Promise<void> {
473
687
  if (!this.outbox) throw new Error('Open a session first.');
474
688
  this.outbox.enqueueDecision(
@@ -572,6 +786,13 @@ export class OperatorClient extends EventEmitter {
572
786
  optional<ToolActivity[]>(`/v1/sessions/${id}/activity`),
573
787
  ]);
574
788
  if (generation !== this.generation) return;
789
+ const before =
790
+ this.snapshot?.id === snapshot.id ? this.snapshot.project_instructions : null;
791
+ const after = snapshot.project_instructions;
792
+ if (before && after && after.revision > before.revision)
793
+ this.notice = after.files.length
794
+ ? 'Project instructions changed · reloaded ' + instructionsLabel(after)
795
+ : 'Project instructions removed';
575
796
  this.snapshot = snapshot;
576
797
  this.health = health;
577
798
  this.commands = commands ?? [];
@@ -616,6 +837,7 @@ export class OperatorClient extends EventEmitter {
616
837
  (event: DurableEvent) => {
617
838
  if (event.seq <= cursor || generation !== this.generation) return;
618
839
  cursor = event.seq;
840
+ this.emit('durable', event);
619
841
  if (
620
842
  [
621
843
  'attempt.completed',
@@ -692,3 +914,12 @@ export class OperatorClient extends EventEmitter {
692
914
  await this.leave();
693
915
  }
694
916
  }
917
+
918
+ /** What went wrong when a session's worker has failed, for the status line and the Changes tab. */
919
+ export function sessionFailure(
920
+ s: { state: string; error: string | null } | undefined,
921
+ ): string | undefined {
922
+ if (!s) return undefined;
923
+ if (s.state !== 'unhealthy' && !(s.state === 'exited' && s.error)) return undefined;
924
+ return `Session stopped: ${s.error ?? 'the worker is not healthy'}. Fix it, then /new or ctrl+n for a fresh session.`;
925
+ }