@superjackfruitlabs/salaam 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +12 -11
  3. package/dist/src/acp-desk.js +140 -0
  4. package/dist/src/acp-outcome.js +182 -18
  5. package/dist/src/acp.js +296 -15
  6. package/dist/src/agent-profiles.js +45 -0
  7. package/dist/src/agentpod-authority.js +169 -11
  8. package/dist/src/agentpod-default-config.js +12 -0
  9. package/dist/src/agentpod-work-decision-protocol.js +40 -6
  10. package/dist/src/agentpod-work-decisions.js +108 -4
  11. package/dist/src/agentpod-work.js +1214 -127
  12. package/dist/src/approval-rules.js +287 -0
  13. package/dist/src/attach.js +3 -1
  14. package/dist/src/autonomy.js +80 -9
  15. package/dist/src/board-reads.js +106 -0
  16. package/dist/src/children.js +83 -0
  17. package/dist/src/cli-help.js +61 -6
  18. package/dist/src/cli.js +140 -4
  19. package/dist/src/command-danger.js +797 -0
  20. package/dist/src/command-network.js +200 -0
  21. package/dist/src/command-sandbox.js +327 -0
  22. package/dist/src/custom-commands.js +159 -0
  23. package/dist/src/danger-classifier.js +286 -0
  24. package/dist/src/execution-evidence.js +33 -1
  25. package/dist/src/git-helpers.js +160 -0
  26. package/dist/src/hooks.js +351 -0
  27. package/dist/src/host-bind.js +111 -0
  28. package/dist/src/host-breaker.js +88 -0
  29. package/dist/src/host-start.js +41 -1
  30. package/dist/src/host.js +284 -6
  31. package/dist/src/house-extension.js +292 -61
  32. package/dist/src/local-coding.js +726 -35
  33. package/dist/src/local-edit-helper.js +25 -0
  34. package/dist/src/local-edits.js +168 -6
  35. package/dist/src/local-undo.js +25 -16
  36. package/dist/src/managed.js +1 -1
  37. package/dist/src/mentions.js +277 -0
  38. package/dist/src/operator-mcp-cli.js +107 -0
  39. package/dist/src/operator-mcp.js +342 -0
  40. package/dist/src/persona.js +31 -0
  41. package/dist/src/pi-sessions.js +5 -1
  42. package/dist/src/pi.js +22 -1
  43. package/dist/src/project-instructions.js +195 -0
  44. package/dist/src/protocol.js +9 -3
  45. package/dist/src/rate-limit-retries.js +62 -0
  46. package/dist/src/recurring-work.js +1 -0
  47. package/dist/src/repository-snapshots.js +5 -2
  48. package/dist/src/run-grants.js +62 -0
  49. package/dist/src/service.js +482 -10
  50. package/dist/src/session-pick.js +77 -0
  51. package/dist/src/shell-passthrough.js +39 -0
  52. package/dist/src/station-checkpoints.js +139 -0
  53. package/dist/src/station-config.js +8 -1
  54. package/dist/src/station-files.js +20 -1
  55. package/dist/src/station-task-verification.js +6 -3
  56. package/dist/src/station-work.js +315 -22
  57. package/dist/src/store.js +70 -4
  58. package/dist/src/supervisor.js +408 -25
  59. package/dist/src/task-budget.js +39 -3
  60. package/dist/src/task-context.js +8 -2
  61. package/dist/src/task-critic.js +115 -0
  62. package/dist/src/task-driver.js +46 -2
  63. package/dist/src/task-policy.js +44 -12
  64. package/dist/src/task-verification.js +42 -9
  65. package/dist/src/task-work.js +47 -0
  66. package/dist/src/terminal-status.js +88 -0
  67. package/dist/src/tool-identity.js +69 -0
  68. package/dist/src/tui-activity.js +99 -0
  69. package/dist/src/tui-classifier.js +25 -0
  70. package/dist/src/tui-client.js +186 -5
  71. package/dist/src/tui-commands.js +23 -5
  72. package/dist/src/tui-composer.js +113 -0
  73. package/dist/src/tui-keymap.js +116 -0
  74. package/dist/src/tui-mentions.js +38 -0
  75. package/dist/src/tui-modals.js +157 -34
  76. package/dist/src/tui-notify.js +62 -0
  77. package/dist/src/tui-review.js +55 -0
  78. package/dist/src/tui-turn-keys.js +52 -0
  79. package/dist/src/tui-view.js +243 -69
  80. package/dist/src/tui.js +875 -118
  81. package/dist/src/turn-checkpoints.js +221 -0
  82. package/dist/src/turn-rewind.js +168 -0
  83. package/dist/src/usage.js +1 -1
  84. package/dist/src/web-fetch.js +114 -0
  85. package/dist/src/web-guard.js +163 -0
  86. package/dist/src/web-search.js +302 -0
  87. package/npm-shrinkwrap.json +2 -2
  88. package/package.json +1 -1
  89. package/src/acp-desk.ts +155 -0
  90. package/src/acp-outcome.ts +174 -20
  91. package/src/acp.ts +386 -30
  92. package/src/agent-profiles.ts +48 -0
  93. package/src/agentpod-authority.ts +234 -15
  94. package/src/agentpod-default-config.ts +16 -0
  95. package/src/agentpod-work-decision-protocol.ts +69 -8
  96. package/src/agentpod-work-decisions.ts +123 -5
  97. package/src/agentpod-work.ts +1455 -152
  98. package/src/approval-rules.ts +360 -0
  99. package/src/attach.ts +4 -1
  100. package/src/autonomy.ts +111 -10
  101. package/src/board-reads.ts +148 -0
  102. package/src/children.ts +112 -0
  103. package/src/cli-help.ts +60 -6
  104. package/src/cli.ts +174 -4
  105. package/src/command-danger.ts +886 -0
  106. package/src/command-network.ts +215 -0
  107. package/src/command-sandbox.ts +393 -0
  108. package/src/custom-commands.ts +175 -0
  109. package/src/danger-classifier.ts +321 -0
  110. package/src/execution-evidence.ts +31 -1
  111. package/src/git-helpers.ts +177 -0
  112. package/src/hooks.ts +425 -0
  113. package/src/host-bind.ts +156 -0
  114. package/src/host-breaker.ts +89 -0
  115. package/src/host-start.ts +51 -1
  116. package/src/host.ts +365 -6
  117. package/src/house-extension.ts +313 -60
  118. package/src/local-coding.ts +894 -43
  119. package/src/local-edit-helper.ts +26 -0
  120. package/src/local-edits.ts +191 -8
  121. package/src/local-undo.ts +29 -14
  122. package/src/managed.ts +2 -2
  123. package/src/mentions.ts +304 -0
  124. package/src/operator-mcp-cli.ts +108 -0
  125. package/src/operator-mcp.ts +420 -0
  126. package/src/persona.ts +37 -0
  127. package/src/pi-sessions.ts +9 -1
  128. package/src/pi.ts +23 -1
  129. package/src/project-instructions.ts +229 -0
  130. package/src/protocol.ts +25 -3
  131. package/src/rate-limit-retries.ts +69 -0
  132. package/src/recurring-work.ts +1 -0
  133. package/src/repository-snapshots.ts +5 -2
  134. package/src/run-grants.ts +107 -0
  135. package/src/service.ts +709 -10
  136. package/src/session-pick.ts +102 -0
  137. package/src/shell-passthrough.ts +38 -0
  138. package/src/station-checkpoints.ts +183 -0
  139. package/src/station-config.ts +10 -1
  140. package/src/station-files.ts +20 -1
  141. package/src/station-task-verification.ts +6 -3
  142. package/src/station-work.ts +369 -20
  143. package/src/store.ts +70 -3
  144. package/src/supervisor.ts +463 -26
  145. package/src/task-budget.ts +48 -3
  146. package/src/task-context.ts +9 -2
  147. package/src/task-critic.ts +171 -0
  148. package/src/task-driver.ts +49 -2
  149. package/src/task-policy.ts +65 -13
  150. package/src/task-verification.ts +53 -9
  151. package/src/task-work.ts +75 -0
  152. package/src/terminal-status.ts +103 -0
  153. package/src/tool-identity.ts +110 -0
  154. package/src/tui-activity.ts +133 -0
  155. package/src/tui-classifier.ts +38 -0
  156. package/src/tui-client.ts +239 -8
  157. package/src/tui-commands.ts +49 -10
  158. package/src/tui-composer.ts +124 -0
  159. package/src/tui-keymap.ts +152 -0
  160. package/src/tui-mentions.ts +43 -0
  161. package/src/tui-modals.ts +173 -43
  162. package/src/tui-notify.ts +77 -0
  163. package/src/tui-review.ts +76 -0
  164. package/src/tui-turn-keys.ts +77 -0
  165. package/src/tui-view.ts +341 -88
  166. package/src/tui.ts +1047 -137
  167. package/src/turn-checkpoints.ts +253 -0
  168. package/src/turn-rewind.ts +258 -0
  169. package/src/usage.ts +1 -1
  170. package/src/web-fetch.ts +140 -0
  171. package/src/web-guard.ts +195 -0
  172. package/src/web-search.ts +402 -0
@@ -0,0 +1,69 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { userInfo } from 'node:os';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { existsSync } from 'node:fs';
6
+ import { ServiceError } from './protocol.js';
7
+ export const sharedUserWarning = (user) => `Tool execution runs as ${user}, the same OS user that holds the provider credential. Set --tools-user to separate them.`;
8
+ export function serviceToolIdentity() {
9
+ const user = userInfo().username;
10
+ return {
11
+ mode: 'service-user',
12
+ user,
13
+ uid: process.getuid?.() ?? -1,
14
+ prefix: [],
15
+ warning: sharedUserWarning(user),
16
+ };
17
+ }
18
+ const run = (argv, input) => spawnSync(argv[0], argv.slice(1), {
19
+ env: { PATH: '/usr/local/bin:/usr/bin:/bin', LANG: 'C' },
20
+ input,
21
+ encoding: 'utf8',
22
+ timeout: 10000,
23
+ killSignal: 'SIGKILL',
24
+ maxBuffer: 1 << 20,
25
+ });
26
+ /** Fail closed unless the tools user exists, is reachable without a password, and cannot read state. */
27
+ export function probeToolsUser(user, stateDir, prefix = ['sudo', '-n', '-u', user, '--']) {
28
+ if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(user))
29
+ throw new Error('--tools-user must be a plain Unix user name');
30
+ if (process.platform !== 'linux' && prefix[0] === 'sudo')
31
+ throw new Error('--tools-user is supported on Linux hosts only');
32
+ const id = run([...prefix, '/usr/bin/id', '-u']);
33
+ const uid = Number(String(id.stdout).trim());
34
+ if (id.status !== 0 || !Number.isSafeInteger(uid))
35
+ throw new Error(`Cannot run as tools user ${user} (sudo -n -u ${user} failed); see docs/security.md host setup`);
36
+ if (uid === 0 || uid === process.getuid?.())
37
+ throw new Error('The tools user must be an unprivileged user distinct from the service user');
38
+ if (run([...prefix, '/usr/bin/test', '-r', stateDir]).status === 0)
39
+ throw new Error(`Tools user ${user} can read the Salaam state directory; restrict it to 0700`);
40
+ return { mode: 'tools-user', user, uid, prefix, warning: null };
41
+ }
42
+ export function toolArgv(identity, argv) {
43
+ return [...identity.prefix, ...argv];
44
+ }
45
+ /** Kill a tool process group owned by the tools user; the daemon cannot signal another uid. */
46
+ export function killToolGroup(identity, pgid) {
47
+ if (identity.mode !== 'tools-user' || !Number.isSafeInteger(pgid) || pgid < 2)
48
+ return;
49
+ run(toolArgv(identity, ['/bin/kill', '-KILL', '--', '-' + pgid]));
50
+ }
51
+ const here = fileURLToPath(import.meta.url);
52
+ /** Source runs (tests) use the built helper; packages ship it beside this file. */
53
+ export const EDIT_HELPER = here.endsWith('.ts')
54
+ ? join(dirname(here), '..', 'dist/src/local-edit-helper.js')
55
+ : join(dirname(here), 'local-edit-helper.js');
56
+ /** Apply reviewed edits as the tools user. Reports each applied path; any failure throws. */
57
+ export function applyEditsAsTools(identity, root, edits, applied) {
58
+ if (!existsSync(EDIT_HELPER))
59
+ throw new ServiceError('tool_identity', 'Edit helper is missing');
60
+ const result = run(toolArgv(identity, [process.execPath, EDIT_HELPER, root]), JSON.stringify(edits));
61
+ for (const line of String(result.stdout ?? '').split('\n')) {
62
+ if (line.startsWith('applied '))
63
+ applied(JSON.parse(line.slice(8)));
64
+ }
65
+ if (result.status !== 0)
66
+ throw new ServiceError('tool_identity', `Edit as ${identity.user} failed: ${String(result.stderr ?? '')
67
+ .trim()
68
+ .slice(0, 400)}`);
69
+ }
@@ -0,0 +1,99 @@
1
+ import { humanTool, safeText } from './tui-theme.js';
2
+ const lineCount = (text) => (text === '' ? 0 : text.replace(/\n$/, '').split('\n').length);
3
+ function short(text, max = 60) {
4
+ const first = safeText(text).split('\n')[0].trim();
5
+ return first.length > max ? first.slice(0, max - 1) + '…' : first;
6
+ }
7
+ /** The recorded result of a local command: its exit code and output, even from a truncated record. */
8
+ function commandResult(output) {
9
+ if (!output)
10
+ return {};
11
+ try {
12
+ const parsed = JSON.parse(output);
13
+ return { exit: parsed.exit_code, text: parsed.output };
14
+ }
15
+ catch {
16
+ const exit = /"exit_code":(-?\d+|null)/.exec(output)?.[1];
17
+ return exit === undefined ? {} : { exit: exit === 'null' ? null : Number(exit) };
18
+ }
19
+ }
20
+ export function toolLine(tool, input, finished, tail = 3) {
21
+ const state = !finished ? 'running' : finished.failed ? 'failed' : 'finished';
22
+ let args = {};
23
+ try {
24
+ args = JSON.parse(input ?? '{}');
25
+ }
26
+ catch { }
27
+ if (tool === 'salaam_local' && args.action === 'edit' && Array.isArray(args.edits)) {
28
+ let added = 0, removed = 0, created = false;
29
+ for (const edit of args.edits) {
30
+ if (edit.replacements?.length)
31
+ for (const r of edit.replacements) {
32
+ added += lineCount(r.new);
33
+ removed += lineCount(r.old);
34
+ }
35
+ else if (typeof edit.content === 'string') {
36
+ added += lineCount(edit.content);
37
+ if (edit.sha256 === null)
38
+ created = true;
39
+ }
40
+ }
41
+ const what = args.edits.length === 1
42
+ ? safeText(String(args.edits[0].path ?? ''))
43
+ : `${args.edits.length} files`;
44
+ return {
45
+ state,
46
+ label: `Edit ${what} +${added} −${removed}${created ? ' (new)' : ''}`,
47
+ tail: [],
48
+ };
49
+ }
50
+ if (tool === 'salaam_local' && args.action === 'command' && typeof args.command === 'string') {
51
+ const result = commandResult(finished?.output);
52
+ const ended = !finished || args.background
53
+ ? args.background && finished
54
+ ? ' · background'
55
+ : ''
56
+ : result.exit === 0
57
+ ? ' ✓ exit 0'
58
+ : result.exit === undefined || result.exit === null
59
+ ? finished.failed
60
+ ? ' × did not finish'
61
+ : ''
62
+ : ` × exit ${result.exit}`;
63
+ const lines = (result.text ?? '')
64
+ .replace(/\n+$/, '')
65
+ .split('\n')
66
+ .filter((l) => l.trim() !== '');
67
+ return {
68
+ state: finished && result.exit !== undefined && result.exit !== null && result.exit !== 0
69
+ ? 'failed'
70
+ : state,
71
+ label: `Bash ${short(args.command)}${ended}`,
72
+ tail: finished ? lines.slice(-tail).map((l) => short(l, 120)) : [],
73
+ ...(result.text !== undefined ? { full: result.text.replace(/\n+$/, '') } : {}),
74
+ };
75
+ }
76
+ return { state, label: humanTool(tool, input), tail: [] };
77
+ }
78
+ /** The verb the progress line shows for what the agent is doing now. */
79
+ export function activityVerb(running) {
80
+ if (!running)
81
+ return 'Thinking';
82
+ if (running.label.startsWith('Edit '))
83
+ return 'Editing';
84
+ if (running.label.startsWith('Bash '))
85
+ return 'Running ' + running.label.slice(5);
86
+ return running.label;
87
+ }
88
+ const SPIN = ['◐', '◓', '◑', '◒'];
89
+ /** The live progress line while a turn runs: spinner, verb, elapsed seconds, tokens, interrupt hint. */
90
+ export function progressLine(verb, startedAt, now, tokens) {
91
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
92
+ const elapsed = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
93
+ return [
94
+ SPIN[seconds % SPIN.length] + ' ' + safeText(verb) + '…',
95
+ elapsed,
96
+ ...(tokens ? [tokens + ' tokens'] : []),
97
+ 'esc to interrupt',
98
+ ].join(' · ');
99
+ }
@@ -0,0 +1,25 @@
1
+ /** Picker rows: every configured reviewer, then Off. Empty when there is nothing to choose. */
2
+ export function classifierItems(v) {
3
+ if (!v.configured || v.pinned)
4
+ return [];
5
+ const mark = (on) => (on ? '● ' : '○ ');
6
+ return [
7
+ ...v.options.map((o) => ({
8
+ value: o.name,
9
+ label: mark(v.choice === o.name) + o.name,
10
+ description: `${o.provider} · ${o.model}`,
11
+ })),
12
+ { value: 'off', label: mark(v.choice === 'off') + 'Off', description: 'Rules only' },
13
+ ];
14
+ }
15
+ export function classifierNotice(v) {
16
+ if (!v.configured && v.choice === 'off' && !v.pinned)
17
+ return 'No command reviewer is configured; set --danger-classifier-config on the service.';
18
+ const what = v.enabled
19
+ ? `Command reviewer ${v.choice} (${v.model}) is on for this session` +
20
+ ` (if it does not answer: ${v.on_unavailable === 'ask' ? 'ask' : 'rules only'})`
21
+ : 'Command reviewer is off for this session';
22
+ return (what +
23
+ (v.pinned ? ', pinned by this host' : '') +
24
+ '. It can only make a command ask, never let one run.');
25
+ }
@@ -4,7 +4,18 @@ import { join } from 'node:path';
4
4
  import { Outbox, acquireControl, subscribe } from './attach.js';
5
5
  import { call } from './transport.js';
6
6
  import { ServiceError, } from './protocol.js';
7
+ /** All input tokens, cached or not. `known_input` alone is only the uncached part. */
8
+ export const usageInput = (u) => u.known_input + (u.known_cache_read ?? 0) + (u.known_cache_write ?? 0);
9
+ /** Known and estimated costs come from different observations, so the spend is their sum. */
10
+ export const usageCost = (u) => u.known_cost + u.estimated_cost;
7
11
  /** An operator client only: the service owns workers, authority and durable outcomes. */
12
+ /** "AGENTS.md" or "AGENTS.md (2 files)": which instruction files guide this session. */
13
+ export function instructionsLabel(summary) {
14
+ const names = [...new Set(summary.files.map((f) => f.path.split('/').at(-1)))].join(' + ');
15
+ return summary.files.length > 1 ? `${names} (${summary.files.length} files)` : names;
16
+ }
17
+ /** Longest a stale control lease is waited out (the service's lease TTL is 15 s). */
18
+ const CONTROL_LAPSE_MS = 16000;
8
19
  export class OperatorClient extends EventEmitter {
9
20
  state;
10
21
  socket;
@@ -87,6 +98,64 @@ export class OperatorClient extends EventEmitter {
87
98
  this.report('Permissions updated. Progression is unchanged; already-running commands are not stopped by this setting.');
88
99
  await this.refresh();
89
100
  }
101
+ /** Desk-session approval rules (phase 2a). */
102
+ async approvalRules() {
103
+ if (!this.snapshot)
104
+ throw new Error('Choose a session first.');
105
+ return call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`);
106
+ }
107
+ /** Turn a reviewed request into a rule: its exact command words or its exact edited paths. */
108
+ async ruleFromOperation(operation, scope) {
109
+ if (!this.snapshot)
110
+ throw new Error('Choose a session first.');
111
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
112
+ ...this.actor(),
113
+ request_id: randomUUID(),
114
+ scope,
115
+ operation_id: operation,
116
+ });
117
+ }
118
+ async addRule(scope, rule) {
119
+ if (!this.snapshot)
120
+ throw new Error('Choose a session first.');
121
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
122
+ ...this.actor(),
123
+ request_id: randomUUID(),
124
+ scope,
125
+ rule,
126
+ });
127
+ this.report('Rule added.');
128
+ }
129
+ async removeRule(id) {
130
+ if (!this.snapshot)
131
+ throw new Error('Choose a session first.');
132
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules/${id}/remove`, {
133
+ ...this.actor(),
134
+ });
135
+ this.report('Rule removed.');
136
+ }
137
+ /** Your hooks and this workspace's, with their trust state (Phase 4b). */
138
+ async hooks() {
139
+ if (!this.snapshot)
140
+ throw new Error('Choose a session first.');
141
+ return call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks`);
142
+ }
143
+ /** Trust this workspace's hooks file as reviewed; `digest` binds the content you saw. */
144
+ async trustHooks(digest) {
145
+ if (!this.snapshot)
146
+ throw new Error('Choose a session first.');
147
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/trust`, {
148
+ ...this.actor(),
149
+ digest,
150
+ });
151
+ this.report('Workspace hooks trusted. They run from the next tool call or prompt.');
152
+ }
153
+ async revokeHooks() {
154
+ if (!this.snapshot)
155
+ throw new Error('Choose a session first.');
156
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/revoke`, { ...this.actor() });
157
+ this.report('Workspace hooks no longer trusted; they will not run.');
158
+ }
90
159
  async steer(text) {
91
160
  if (!this.snapshot)
92
161
  throw new Error('Choose a session first.');
@@ -118,6 +187,66 @@ export class OperatorClient extends EventEmitter {
118
187
  await this.refresh();
119
188
  this.report('Reverted the reviewed local edit. Conversation history is preserved.');
120
189
  }
190
+ async checkpoints() {
191
+ if (!this.snapshot)
192
+ throw new Error('Choose a session first.');
193
+ return (await call(this.socket, `/v1/sessions/${this.snapshot.id}/checkpoints`)).checkpoints;
194
+ }
195
+ async rewindReview(attempt, mode) {
196
+ if (!this.snapshot)
197
+ throw new Error('Choose a session first.');
198
+ return call(this.socket, `/v1/sessions/${this.snapshot.id}/rewind?attempt=${encodeURIComponent(attempt)}&mode=${mode}`);
199
+ }
200
+ /** Returns the rewound turn's instruction so it can be edited and sent again. */
201
+ async rewind(review) {
202
+ if (!this.snapshot)
203
+ throw new Error('Choose a session first.');
204
+ const id = this.snapshot.id;
205
+ const result = await call(this.socket, `/v1/sessions/${id}/rewind`, {
206
+ ...this.actor(),
207
+ request_id: randomUUID(),
208
+ attempt_id: review.attempt_id,
209
+ mode: review.mode,
210
+ review_digest: review.digest,
211
+ });
212
+ if (result.state !== 'completed')
213
+ throw new Error('Rewind outcome is uncertain; inspect files before continuing.');
214
+ if (result.session_id !== id)
215
+ await this.open(result.session_id);
216
+ else
217
+ await this.refresh();
218
+ this.report((review.mode === 'conversation'
219
+ ? 'Conversation'
220
+ : review.mode === 'files'
221
+ ? 'Files'
222
+ : 'Files and conversation') +
223
+ ` rewound to before turn ${review.turn}.` +
224
+ (result.session_id !== id ? ' The earlier conversation stays as its own session.' : ''));
225
+ return result.text;
226
+ }
227
+ async changeReview() {
228
+ if (!this.snapshot)
229
+ throw new Error('Choose a session first.');
230
+ return (await call(this.socket, `/v1/sessions/${this.snapshot.id}/review`)).review;
231
+ }
232
+ async changeHunks(review, path) {
233
+ if (!this.snapshot)
234
+ throw new Error('Choose a session first.');
235
+ const q = new URLSearchParams({ path, base: review.base, current: review.current });
236
+ return (await call(this.socket, `/v1/sessions/${this.snapshot.id}/review/hunks?${q}`)).hunks;
237
+ }
238
+ async changeAct(review, action, path, hunk) {
239
+ if (!this.snapshot)
240
+ throw new Error('Choose a session first.');
241
+ return (await call(this.socket, `/v1/sessions/${this.snapshot.id}/review`, {
242
+ ...this.actor(),
243
+ action,
244
+ path,
245
+ base: review.base,
246
+ current: review.current,
247
+ ...(hunk ? { hunk } : {}),
248
+ })).review;
249
+ }
121
250
  async fork() {
122
251
  if (!this.snapshot)
123
252
  throw new Error('Choose a session first.');
@@ -196,14 +325,14 @@ export class OperatorClient extends EventEmitter {
196
325
  this.snapshot = await call(this.socket, `/v1/sessions/${id}/snapshot`);
197
326
  if (!options.observe) {
198
327
  try {
199
- this.lease = await acquireControl(this.socket, id, this.clientId, options.takeover);
328
+ this.lease = await this.acquireOrWait(id, options.takeover);
200
329
  }
201
330
  catch (error) {
202
331
  if (!(error instanceof ServiceError) ||
203
- !['control_conflict', 'control_required'].includes(error.code))
332
+ !['control_conflict', 'control_required', 'control_busy'].includes(error.code))
204
333
  throw error;
205
334
  this.notice =
206
- 'Another client has control. You are observing; use Take control to take over.';
335
+ '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.';
207
336
  }
208
337
  }
209
338
  if (this.lease)
@@ -219,6 +348,27 @@ export class OperatorClient extends EventEmitter {
219
348
  this.watcher = this.watch(id, generation);
220
349
  this.changed();
221
350
  }
351
+ /** Acquire control. A lease left by a terminal that was killed lapses within its short TTL, so
352
+ * wait that out once instead of opening as an observer; a live terminal renews and keeps it. */
353
+ async acquireOrWait(id, takeover) {
354
+ try {
355
+ return await acquireControl(this.socket, id, this.clientId, takeover);
356
+ }
357
+ catch (error) {
358
+ if (takeover || !(error instanceof ServiceError) || error.code !== 'control_busy')
359
+ throw error;
360
+ const snapshot = await call(this.socket, `/v1/sessions/${id}/snapshot`);
361
+ const wait = snapshot.control.expires_at - Date.now();
362
+ if (wait <= 0 || wait > CONTROL_LAPSE_MS)
363
+ throw error;
364
+ this.notice = `Waiting ${Math.ceil(wait / 1000)} s for the previous terminal's control to lapse…`;
365
+ this.changed();
366
+ await new Promise((done) => setTimeout(done, wait + 250));
367
+ const lease = await acquireControl(this.socket, id, this.clientId);
368
+ this.notice = 'You have control: the previous terminal had stopped.';
369
+ return lease;
370
+ }
371
+ }
222
372
  actor() {
223
373
  if (!this.lease)
224
374
  throw new Error('Take control before sending an instruction.');
@@ -263,11 +413,11 @@ export class OperatorClient extends EventEmitter {
263
413
  await job;
264
414
  this.changed();
265
415
  }
266
- async submit(text, continues) {
416
+ async submit(text, continues, confirmSensitive = false) {
267
417
  this.actor();
268
418
  if (!this.snapshot || !this.outbox)
269
419
  throw new Error('Open a session first.');
270
- const record = this.outbox.enqueue(text, this.snapshot.revision, continues);
420
+ const record = this.outbox.enqueue(text, this.snapshot.revision, continues, confirmSensitive);
271
421
  this.notice = 'Instruction saved locally. Delivering…';
272
422
  this.changed();
273
423
  try {
@@ -281,6 +431,8 @@ export class OperatorClient extends EventEmitter {
281
431
  'Delivery pending. The saved instruction will be retried: ' + error.message;
282
432
  }
283
433
  await this.refresh().catch(() => { });
434
+ // A failed worker cannot take the instruction: say so instead of "accepted".
435
+ this.notice = sessionFailure(this.snapshot) ?? this.notice;
284
436
  this.changed();
285
437
  return record.request_id;
286
438
  }
@@ -368,6 +520,20 @@ export class OperatorClient extends EventEmitter {
368
520
  this.notice = 'Reviewed context admitted. Tool permissions are unchanged.';
369
521
  await this.refresh();
370
522
  }
523
+ /** Switch this session's model or thinking level between turns; the conversation carries on. */
524
+ async switchRoute(route) {
525
+ if (!this.snapshot)
526
+ throw new Error('Open a session first.');
527
+ await call(this.socket, `/v1/sessions/${this.snapshot.id}/route`, {
528
+ ...this.actor(),
529
+ route: {
530
+ provider: route.provider === 'salaam-proof' ? 'offline' : route.provider,
531
+ ...(route.provider === 'salaam-proof' ? {} : { model: route.model }),
532
+ ...(route.reasoning === undefined ? {} : { reasoning: route.reasoning }),
533
+ },
534
+ });
535
+ await this.refresh();
536
+ }
371
537
  async decide(approval, decision) {
372
538
  if (!this.outbox)
373
539
  throw new Error('Open a session first.');
@@ -459,6 +625,12 @@ export class OperatorClient extends EventEmitter {
459
625
  ]);
460
626
  if (generation !== this.generation)
461
627
  return;
628
+ const before = this.snapshot?.id === snapshot.id ? this.snapshot.project_instructions : null;
629
+ const after = snapshot.project_instructions;
630
+ if (before && after && after.revision > before.revision)
631
+ this.notice = after.files.length
632
+ ? 'Project instructions changed · reloaded ' + instructionsLabel(after)
633
+ : 'Project instructions removed';
462
634
  this.snapshot = snapshot;
463
635
  this.health = health;
464
636
  this.commands = commands ?? [];
@@ -497,6 +669,7 @@ export class OperatorClient extends EventEmitter {
497
669
  if (event.seq <= cursor || generation !== this.generation)
498
670
  return;
499
671
  cursor = event.seq;
672
+ this.emit('durable', event);
500
673
  if ([
501
674
  'attempt.completed',
502
675
  'attempt.failed',
@@ -567,3 +740,11 @@ export class OperatorClient extends EventEmitter {
567
740
  await this.leave();
568
741
  }
569
742
  }
743
+ /** What went wrong when a session's worker has failed, for the status line and the Changes tab. */
744
+ export function sessionFailure(s) {
745
+ if (!s)
746
+ return undefined;
747
+ if (s.state !== 'unhealthy' && !(s.state === 'exited' && s.error))
748
+ return undefined;
749
+ return `Session stopped: ${s.error ?? 'the worker is not healthy'}. Fix it, then /new or ctrl+n for a fresh session.`;
750
+ }
@@ -4,10 +4,11 @@ export const SLASH_COMMANDS = [
4
4
  command('help', 'Show commands and their execution type', ['commands'], '[command]'),
5
5
  command('exit', 'Detach; preserve the session and running work', ['quit', 'q']),
6
6
  command('keys', 'Show keyboard shortcuts', ['hotkeys']),
7
- command('model', 'Choose provider and model for a new session', ['models'], '[provider/model]'),
8
- command('provider', 'Choose a provider for a new session', [], '[provider]'),
9
- command('thinking', 'Choose reasoning effort for a new session', [], '[level]'),
10
- command('coding', 'New local coding session with reviewed edits and unsandboxed commands', [], '[workspace]'),
7
+ command('model', 'Switch this session’s provider and model', ['models'], '[provider/model]'),
8
+ command('provider', 'Switch this session’s provider', [], '[provider]'),
9
+ command('thinking', 'Switch this session’s reasoning effort', [], '[level]'),
10
+ command('coding', 'New local coding session with reviewed edits and commands', [], '[workspace]'),
11
+ command('init', 'Draft an AGENTS.md for this repository; the edit asks for approval'),
11
12
  command('new', 'Create a session in a workspace', [], '[workspace]'),
12
13
  command('sessions', 'Switch or resume a session', ['resume'], '[session-id]'),
13
14
  command('name', 'Set a durable session label', ['rename'], '<name>'),
@@ -16,9 +17,15 @@ export const SLASH_COMMANDS = [
16
17
  command('cancel', 'Cancel an instruction with explicit confirmation', ['stop']),
17
18
  command('queue', 'Save a durable next-turn instruction, or inspect the queue', [], '[text]'),
18
19
  command('steer', 'Send recorded guidance to an active local turn; uncertain delivery never replays', [], '<text>'),
19
- command('diff', 'Review workspace changes'),
20
+ command('diff', 'Review workspace changes file by file'),
21
+ command('commit', 'Draft a commit of the current work; staging and the commit each ask (uses provider credits)', [], undefined, 'model'),
22
+ command('branch', 'Switch to a branch, creating it if needed', [], '<name>', 'model'),
23
+ command('pr', 'Push this branch and open a pull request; both always ask, except in YOLO', [], '[base]', 'model'),
20
24
  command('tools', 'Inspect available capabilities and recorded tool activity'),
21
25
  command('permissions', 'Configure local permissions independently of task progression'),
26
+ command('mode', 'Cycle review → accept edits → auto (shift+tab)'),
27
+ command('full-access', 'Turn full access on or off: commands run without the OS sandbox; dangerous ones still ask'),
28
+ command('yolo', 'Turn YOLO on (type yolo to confirm) or off: no sandbox and no approvals'),
22
29
  command('approvals', 'Review exact pending approval requests'),
23
30
  command('context', 'Inspect session context, facts and usage'),
24
31
  command('usage', 'Show recorded usage and unknown observations'),
@@ -27,6 +34,7 @@ export const SLASH_COMMANDS = [
27
34
  command('export', 'Export full durable conversation as Markdown; never overwrite', [], '[path]'),
28
35
  command('settings', 'Choose terminal appearance and model settings'),
29
36
  command('fork', 'Fork an idle local conversation; keep the same workspace'),
37
+ command('rewind', 'Rewind files, conversation or both to before an earlier turn', [], '[turn]'),
30
38
  command('undo', 'Review and revert the last completed local edit; retain conversation history', [], '[reconcile]'),
31
39
  command('plan', 'Ask the model for a plan (uses provider credits; not a permission mode)', [], '[request]', 'model'),
32
40
  command('review', 'Ask the model to review workspace changes (uses provider credits)', [], '[focus]', 'model'),
@@ -35,6 +43,9 @@ export const SLASH_COMMANDS = [
35
43
  command('agents', 'Inspect native/external worker profiles and child work'),
36
44
  command('progression', 'Choose manual or bounded task progression; permissions stay separate'),
37
45
  command('tasks', 'Open durable task actions'),
46
+ command('classifier', 'Pick the small-model command reviewer for this session, or turn it off', [], '[name|on|off]'),
47
+ command('hooks', 'Show your hooks and this workspace’s; trust or revoke the workspace file', [], '[trust|revoke]'),
48
+ command('jobs', 'Show background commands and their output, or stop one', ['bg'], '[stop <id>]'),
38
49
  ];
39
50
  export const SLASH_COMPLETIONS = SLASH_COMMANDS.flatMap((c) => [c.name, ...c.aliases].map((name) => ({ name, description: c.description })));
40
51
  /** Slash input is always local; malformed or unknown commands never become prompts. */
@@ -65,3 +76,10 @@ export function slashHelp(name) {
65
76
  .map((c) => `/${c.name}${c.arguments ? ' ' + c.arguments : ''} — ${c.description} [${c.kind}]${c.aliases.length ? '\n Aliases: ' + c.aliases.map((a) => '/' + a).join(', ') : ''}`)
66
77
  .join('\n');
67
78
  }
79
+ /** What /init asks the agent to do. The write goes through the session's normal edit approval. */
80
+ export const INIT_INSTRUCTION = [
81
+ 'Draft an AGENTS.md for this repository.',
82
+ 'First read the tree and the files that define how it is built and tested (package manifests, build and CI configuration, README, existing contributor docs, any CLAUDE.md).',
83
+ 'Then write AGENTS.md at the repository root: a short overview, the exact commands to build, test, lint and format, the code layout, and conventions a contributor must follow.',
84
+ 'Keep it under 150 lines, state only what the files show, and if AGENTS.md already exists, propose improvements to it instead of replacing it.',
85
+ ].join(' ');
@@ -0,0 +1,113 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const CTRL_C_WINDOW_MS = 2000;
5
+ export function ctrlCAction(input) {
6
+ if (input.running)
7
+ return 'interrupt';
8
+ if (input.text !== '')
9
+ return 'clear';
10
+ if (input.armedAt !== undefined && input.now - input.armedAt <= CTRL_C_WINDOW_MS)
11
+ return 'exit';
12
+ return 'arm';
13
+ }
14
+ const SECRET_PATTERNS = [
15
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
16
+ /\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/,
17
+ /\bsk-ant-[A-Za-z0-9_-]{8,}/,
18
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/,
19
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/,
20
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/,
21
+ /\bAKIA[0-9A-Z]{16}\b/,
22
+ /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./,
23
+ /\b(password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*\S{6,}/i,
24
+ /\bbearer\s+[A-Za-z0-9._~+/-]{16,}/i,
25
+ /[A-Za-z0-9+_-]{48,}={0,2}/,
26
+ ];
27
+ /** True when the text looks like it carries a credential; such text is never written to history. */
28
+ export function looksSecret(text) {
29
+ return SECRET_PATTERNS.some((p) => p.test(text));
30
+ }
31
+ export const HISTORY_LIMIT = 500;
32
+ export const HISTORY_ENTRY_LIMIT = 8192;
33
+ /** Where this user's composer history lives: $XDG_STATE_HOME/salaam/history.json, or nowhere when
34
+ * SALAAM_HISTORY=off (history then lasts only for this terminal). */
35
+ export function historyPath(env = process.env) {
36
+ if (env.SALAAM_HISTORY === 'off')
37
+ return undefined;
38
+ const base = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
39
+ return join(base, 'salaam', 'history.json');
40
+ }
41
+ /** Per-user composer history, newest last, bounded, and without secret-looking entries. */
42
+ export class ComposerHistory {
43
+ path;
44
+ entries = [];
45
+ constructor(path) {
46
+ this.path = path;
47
+ try {
48
+ if (path && existsSync(path)) {
49
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
50
+ if (Array.isArray(raw))
51
+ this.entries = raw
52
+ .filter((e) => typeof e === 'string')
53
+ .filter((e) => keepable(e))
54
+ .slice(-HISTORY_LIMIT);
55
+ }
56
+ }
57
+ catch {
58
+ this.entries = [];
59
+ }
60
+ }
61
+ /** Adds a sent message. Returns false when it was not kept (blank, too long, or secret-looking). */
62
+ add(text) {
63
+ if (!keepable(text))
64
+ return false;
65
+ this.entries = this.entries.filter((e) => e !== text);
66
+ this.entries.push(text);
67
+ if (this.entries.length > HISTORY_LIMIT)
68
+ this.entries = this.entries.slice(-HISTORY_LIMIT);
69
+ this.save();
70
+ return true;
71
+ }
72
+ save() {
73
+ if (!this.path)
74
+ return;
75
+ try {
76
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
77
+ const tmp = this.path + '.tmp';
78
+ writeFileSync(tmp, JSON.stringify(this.entries), { mode: 0o600 });
79
+ chmodSync(tmp, 0o600);
80
+ renameSync(tmp, this.path);
81
+ }
82
+ catch {
83
+ // History is a convenience; failing to save never blocks sending.
84
+ }
85
+ }
86
+ }
87
+ function keepable(text) {
88
+ return text.trim() !== '' && text.length <= HISTORY_ENTRY_LIMIT && !looksSecret(text);
89
+ }
90
+ /**
91
+ * Reverse-incremental search: the newest entry older than `before` (an index into `entries`,
92
+ * newest last) that contains the query, case-insensitively. Returns its index or -1.
93
+ */
94
+ export function searchHistory(entries, query, before) {
95
+ const q = query.toLowerCase();
96
+ for (let i = Math.min(before, entries.length) - 1; i >= 0; i--)
97
+ if (entries[i].toLowerCase().includes(q))
98
+ return i;
99
+ return -1;
100
+ }
101
+ /** The status-line text while searching, matching readline's wording. */
102
+ export function searchPrompt(query, match) {
103
+ const shown = match === undefined ? '' : match.replace(/\s+/g, ' ').slice(0, 120);
104
+ return match === undefined && query !== ''
105
+ ? `(failing reverse-i-search)\`${query}': · esc cancels`
106
+ : `(reverse-i-search)\`${query}': ${shown} · enter keeps · ctrl+r older · esc cancels`;
107
+ }
108
+ /**
109
+ * `!command` from the composer. The TUI never runs it itself: it becomes an instruction for the
110
+ * model to run exactly this command through salaam_local, so it takes the same approval path,
111
+ * session mode, sandbox and danger check as any model command. Returns undefined for other text.
112
+ */
113
+ export { shellPassthrough } from './shell-passthrough.js';