@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
package/src/service.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { DangerClassifier, loadDangerClassifierOptions } from './danger-classifier.js';
3
+ import { WebSearch, loadWebSearchConfig } from './web-search.js';
1
4
  import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
2
5
  import { loadPersona } from './persona.js';
3
6
  import {
@@ -13,6 +16,7 @@ import { join, resolve } from 'node:path';
13
16
  import { providerRoute } from './provider-config.js';
14
17
  import { TaskContext } from './task-context.js';
15
18
  import { ContextAdmission } from './context-admission.js';
19
+ import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
16
20
  import { SLASH_COMMANDS } from './tui-commands.js';
17
21
  import { TaskWork } from './task-work.js';
18
22
  import { TaskBudget } from './task-budget.js';
@@ -28,6 +32,7 @@ import { OperatorInbox } from './operator-inbox.js';
28
32
  import { RecurringWork } from './recurring-work.js';
29
33
  import { JobWork } from './job-work.js';
30
34
  import { Store } from './store.js';
35
+ import { pickerSummary } from './session-pick.js';
31
36
  import { acquireStateLock } from './state-lock.js';
32
37
  import { Artifacts } from './artifacts.js';
33
38
  import { Supervisor, type SupervisorOptions } from './supervisor.js';
@@ -63,16 +68,23 @@ import { McpAdmission } from './mcp-admission.js';
63
68
  import { McpWork } from './mcp-work.js';
64
69
  import { ToolkitWork, type ToolkitWorkConfig } from './toolkit-work.js';
65
70
  import { permissionPolicy, configurePermissions } from './autonomy.js';
66
- import { chooseLocalSandbox, type SandboxLevel } from './command-sandbox.js';
71
+ import { expandPrompt, storeImages, type PromptImage } from './mentions.js';
72
+ import { ApprovalRules } from './approval-rules.js';
73
+ import { chooseLocalSandbox, type SandboxLevel, resolveReadPaths } from './command-sandbox.js';
67
74
  import { LocalCoding, type LocalSandbox } from './local-coding.js';
75
+ import { Hooks } from './hooks.js';
68
76
  import { localDiff } from './local-diff.js';
69
77
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
70
78
  import { forkPiSession } from './pi-sessions.js';
79
+ import { TurnCheckpoints, type RewindMode } from './turn-rewind.js';
71
80
  import { StationWork } from './station-work.js';
72
81
  import { readToolkitConfig } from './toolkit-config.js';
73
82
  import { probeToolsUser, serviceToolIdentity, type ToolIdentity } from './tool-identity.js';
74
83
 
75
84
  export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'bridgeSocket'> {
85
+ /** The user-level AGENTS.md for interactive sessions; null turns it off. Default:
86
+ * /salaam/AGENTS.md or ~/.config/salaam/AGENTS.md. */
87
+ userInstructions?: string | null;
76
88
  hostManifestDigest?: string;
77
89
  /** Receipt fields for the launcher profile; reported only by a manifest-served host. */
78
90
  hostReceipt?: Record<string, unknown>;
@@ -115,9 +127,29 @@ export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'br
115
127
  /** Host config `unsandboxed_commands: "allow"`: run local commands unsandboxed on a host with no
116
128
  * usable sandbox. Absent, they are refused (operator ruling 2026-10-30, option A). */
117
129
  unsandboxedCommands?: 'allow';
130
+ /** Read-only paths outside the workspace local commands may read (resolved, `toolchain` expanded). */
131
+ localSandboxReadPaths?: string[];
132
+ /** Web search config file: optional providers (searxng, brave, tavily, exa) and their key
133
+ * references. Keyless official APIs answer without one (operator ruling 2026-10-31). */
134
+ webSearchConfig?: string;
135
+ /** Phase 4c: the optional small-model command reviewer (a JSON file); absent means off. */
136
+ dangerClassifierConfig?: string;
137
+ /** A host's reviewed pin: one option's name (sessions cannot switch or turn it off), or "off"
138
+ * (the reviewer is forbidden). Absent: sessions choose from the config's options. */
139
+ dangerClassifierPin?: string;
140
+ /** The user's approval rules file (default ~/.config/salaam/permissions.json). */
141
+ permissionsFile?: string;
142
+ /** Your hooks file (Phase 4b); defaults to ~/.config/salaam/hooks.json or $SALAAM_HOOKS_FILE. */
143
+ hooksFile?: string;
118
144
  /** Host config `command_network: "broker"`: sandboxed local commands reach the network only
119
145
  * through this stk local-commands socket (operator rulings 2026-10-30). */
120
146
  commandNetwork?: { socket: string };
147
+ /** Phase 2b: may an interactive session choose full access? Default true for local desk use;
148
+ * a host started from a manifest passes its reviewed `full_access_sessions` setting. */
149
+ allowFullAccessSessions?: boolean;
150
+ /** Whether an interactive session may choose YOLO (no sandbox, no approvals). Desk default allows
151
+ * it; a host started from a manifest passes its reviewed `yolo_sessions` setting. */
152
+ allowYoloSessions?: boolean;
121
153
  /** Internal test seam: argv prefix replacing `sudo -n -u <toolsUser> --`. */
122
154
  toolsLauncher?: string[];
123
155
  codingSocket?: string;
@@ -178,7 +210,24 @@ export class SalaamService {
178
210
  private readonly hostReceipt: Record<string, unknown> | null;
179
211
  private readonly operatorAccess: { group: string; gid: number } | null;
180
212
  private readonly agentpodWorkProgression: boolean;
213
+ /** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
214
+ private readonly allowFullAccessSessions: boolean;
215
+ private readonly allowYoloSessions: boolean;
216
+ private readonly reviewers = new Map<string, DangerClassifier>();
217
+ private reviewerDefault = 'off';
218
+ private reviewerPin?: string;
219
+ /** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
220
+ private reviewerChoice(session: string): string {
221
+ if (this.reviewerPin) return this.reviewerPin;
222
+ const row = this.store.db
223
+ .prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
224
+ .get(session) as { choice: string } | undefined;
225
+ const choice = row?.choice ?? this.reviewerDefault;
226
+ return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
227
+ }
181
228
  private constructor(options: ServiceOptions) {
229
+ this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
230
+ this.allowYoloSessions = options.allowYoloSessions !== false;
182
231
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
183
232
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
184
233
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
@@ -227,7 +276,10 @@ export class SalaamService {
227
276
  this.socket = join(this.stateDir, 'operator.sock');
228
277
  this.bridgeSocket = join(this.stateDir, 'bridge.sock');
229
278
  if (Buffer.byteLength(this.bridgeSocket) > 100)
230
- throw new Error('State path is too long for a Unix socket; choose a shorter --state path');
279
+ throw new Error(
280
+ `State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
281
+ 'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.',
282
+ );
231
283
  this.release = acquireStateLock(this.stateDir);
232
284
  try {
233
285
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
@@ -240,6 +292,18 @@ export class SalaamService {
240
292
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
241
293
  this.tasks = new TaskContext(this.store, this.artifacts);
242
294
  this.tasks.admitted = new ContextAdmission(this.store);
295
+ this.tasks.instructions = new ProjectInstructions(
296
+ this.store,
297
+ this.artifacts,
298
+ options.userInstructions === undefined
299
+ ? userInstructionsPath()
300
+ : options.userInstructions,
301
+ );
302
+ this.tasks.checkpoints = new TurnCheckpoints(
303
+ this.store,
304
+ this.stateDir,
305
+ this.toolIdentity.mode !== 'tools-user',
306
+ );
243
307
  this.taskWork = new TaskWork(this.store, this.tasks);
244
308
  this.taskBudget = new TaskBudget(this.store, this.taskWork);
245
309
  if (options.admitModelRequest) {
@@ -374,6 +438,9 @@ export class SalaamService {
374
438
  const localSandbox: LocalSandbox = {
375
439
  ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
376
440
  allowUnsandboxed: options.unsandboxedCommands === 'allow',
441
+ ...(options.localSandboxReadPaths?.length
442
+ ? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
443
+ : {}),
377
444
  ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
378
445
  };
379
446
  this.localCoding = new LocalCoding(
@@ -383,7 +450,48 @@ export class SalaamService {
383
450
  this.toolIdentity,
384
451
  localSandbox,
385
452
  );
453
+ this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
454
+ this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
455
+ this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
386
456
  this.supervisor.localCoding = this.localCoding;
457
+ if (options.dangerClassifierConfig) {
458
+ const loaded = loadDangerClassifierOptions(
459
+ JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')),
460
+ );
461
+ for (const o of loaded.options) this.reviewers.set(o.name, new DangerClassifier(o));
462
+ this.reviewerDefault = loaded.default;
463
+ }
464
+ if (options.dangerClassifierPin !== undefined) {
465
+ if (
466
+ options.dangerClassifierPin !== 'off' &&
467
+ !this.reviewers.has(options.dangerClassifierPin)
468
+ )
469
+ throw Error(
470
+ `The pinned command reviewer ${options.dangerClassifierPin} is not configured`,
471
+ );
472
+ this.reviewerPin = options.dangerClassifierPin;
473
+ }
474
+ if (this.reviewers.size) {
475
+ this.store.db.exec(
476
+ 'CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)',
477
+ );
478
+ this.localCoding.classifier = {
479
+ review: (s, command, cwd) => {
480
+ const reviewer = this.reviewers.get(this.reviewerChoice(s));
481
+ return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
482
+ },
483
+ };
484
+ }
485
+ const local = this.localCoding;
486
+ this.supervisor.hooks = new Hooks(
487
+ this.store,
488
+ (session, command, input, timeout) => local.runHook(session, command, input, timeout),
489
+ options.hooksFile,
490
+ );
491
+ const webConfig = options.webSearchConfig
492
+ ? loadWebSearchConfig(options.webSearchConfig)
493
+ : { providers: [] };
494
+ this.supervisor.web = new WebSearch(webConfig);
387
495
  this.agents = new AgentProfiles(
388
496
  this.store,
389
497
  this.supervisor.route,
@@ -1978,7 +2086,10 @@ export class SalaamService {
1978
2086
  this.send(
1979
2087
  res,
1980
2088
  200,
1981
- this.store.sessions().map((x) => this.supervisor.inspect(x.id)),
2089
+ this.store.sessions().map((x) => {
2090
+ const session = this.supervisor.inspect(x.id);
2091
+ return { ...session, ...pickerSummary(session, this.store) };
2092
+ }),
1982
2093
  );
1983
2094
  return;
1984
2095
  }
@@ -2121,13 +2232,61 @@ export class SalaamService {
2121
2232
  }
2122
2233
  if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
2123
2234
  const id = parts[2];
2235
+ // Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
2236
+ // and remove. Adding or removing needs the session's control lease, like the permission policy.
2237
+ if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
2238
+ const rules = this.localCoding.rules!;
2239
+ if (parts.length === 4 && req.method === 'GET') {
2240
+ this.send(res, 200, {
2241
+ applies: rules.applies(id),
2242
+ rules: rules.list(id),
2243
+ problems: rules.problems(id),
2244
+ });
2245
+ return;
2246
+ }
2247
+ const body = object(await readJson(req));
2248
+ this.store.authorizeControl(id, {
2249
+ client_id: string(body.client_id, 'client_id', 128),
2250
+ token: string(body.token, 'token', 128),
2251
+ });
2252
+ if (parts.length === 4 && req.method === 'POST') {
2253
+ const scope = string(body.scope, 'scope');
2254
+ if (scope !== 'session' && scope !== 'repo')
2255
+ throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
2256
+ const request = string(body.request_id, 'request_id', 128);
2257
+ this.send(
2258
+ res,
2259
+ 200,
2260
+ body.operation_id !== undefined
2261
+ ? rules.addFromOperation(
2262
+ id,
2263
+ string(body.operation_id, 'operation_id', 128),
2264
+ scope,
2265
+ request,
2266
+ )
2267
+ : rules.add(id, scope, body.rule, request),
2268
+ );
2269
+ return;
2270
+ }
2271
+ if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
2272
+ rules.remove(id, string(parts[4], 'rule', 128));
2273
+ this.send(res, 200, { removed: parts[4] });
2274
+ return;
2275
+ }
2276
+ }
2124
2277
  if (parts.length === 4 && parts[3] === 'permissions') {
2125
2278
  if (req.method === 'GET') {
2126
2279
  this.send(res, 200, {
2127
2280
  policy: permissionPolicy(this.store, id),
2128
2281
  configurable: this.store.localWorkspace(id),
2129
2282
  coding: this.store.localCoding(id),
2130
- command_auto_approval: false,
2283
+ // Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
2284
+ command_auto_approval: this.store.localCoding(id),
2285
+ full_access_allowed: this.allowFullAccessSessions,
2286
+ yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
2287
+ ...(this.store.localCoding(id)
2288
+ ? { command_sandbox: this.localCoding.commandSandbox(id) }
2289
+ : {}),
2131
2290
  });
2132
2291
  return;
2133
2292
  }
@@ -2146,17 +2305,152 @@ export class SalaamService {
2146
2305
  client_id: string(body.client_id, 'client_id', 128),
2147
2306
  token: string(body.token, 'token', 128),
2148
2307
  },
2308
+ {
2309
+ allowFullAccess: this.allowFullAccessSessions,
2310
+ allowYolo: this.allowYoloSessions,
2311
+ deskSession: !!this.localCoding.rules?.applies(id),
2312
+ },
2149
2313
  ),
2150
2314
  );
2151
2315
  return;
2152
2316
  }
2153
2317
  }
2318
+ if (parts.length === 4 && parts[3] === 'danger-classifier') {
2319
+ const view = () => {
2320
+ const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off',
2321
+ chosen = this.reviewers.get(choice)?.config;
2322
+ return {
2323
+ configured: this.reviewers.size > 0,
2324
+ enabled: !!chosen,
2325
+ ...(chosen
2326
+ ? {
2327
+ provider: chosen.provider,
2328
+ model: chosen.model,
2329
+ on_unavailable: chosen.on_unavailable,
2330
+ }
2331
+ : {}),
2332
+ choice,
2333
+ pinned: this.reviewerPin !== undefined,
2334
+ options: [...this.reviewers.values()].map(({ config: o }) => ({
2335
+ name: o.name,
2336
+ provider: o.provider,
2337
+ model: o.model,
2338
+ })),
2339
+ };
2340
+ };
2341
+ if (req.method === 'GET') {
2342
+ this.send(res, 200, view());
2343
+ return;
2344
+ }
2345
+ if (req.method === 'POST') {
2346
+ const body = object(await readJson(req));
2347
+ this.store.authorizeControl(id, {
2348
+ client_id: string(body.client_id, 'client_id', 128),
2349
+ token: string(body.token, 'token', 128),
2350
+ });
2351
+ // `enabled` is the earlier on/off form: on means the config's default.
2352
+ const choice =
2353
+ typeof body.enabled === 'boolean'
2354
+ ? body.enabled
2355
+ ? this.reviewerDefault === 'off'
2356
+ ? [...this.reviewers.keys()][0]
2357
+ : this.reviewerDefault
2358
+ : 'off'
2359
+ : string(body.choice, 'choice', 64);
2360
+ if (!this.reviewers.size && choice !== 'off')
2361
+ throw new ServiceError(
2362
+ 'classifier_unavailable',
2363
+ 'No command reviewer is configured on this host (--danger-classifier-config)',
2364
+ );
2365
+ if (this.reviewerPin !== undefined)
2366
+ throw new ServiceError(
2367
+ 'classifier_pinned',
2368
+ `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`,
2369
+ 403,
2370
+ );
2371
+ if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
2372
+ throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
2373
+ if (this.reviewers.size)
2374
+ this.store.transaction(() => {
2375
+ this.store.db
2376
+ .prepare(
2377
+ 'INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice',
2378
+ )
2379
+ .run(id, choice!);
2380
+ this.store.event(id, 'permission.classifier-set', { choice });
2381
+ });
2382
+ this.send(res, 200, view());
2383
+ return;
2384
+ }
2385
+ }
2386
+ // Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
2387
+ if (parts[3] === 'hooks' && this.supervisor.hooks) {
2388
+ if (!this.supervisor.isInteractiveCoding(id))
2389
+ throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
2390
+ const hooks = this.supervisor.hooks;
2391
+ if (parts.length === 4 && req.method === 'GET') {
2392
+ this.send(res, 200, { sources: hooks.sources(id) });
2393
+ return;
2394
+ }
2395
+ if (parts.length === 5 && req.method === 'POST') {
2396
+ const body = object(await readJson(req));
2397
+ this.store.authorizeControl(id, {
2398
+ client_id: string(body.client_id, 'client_id', 128),
2399
+ token: string(body.token, 'token', 128),
2400
+ });
2401
+ if (parts[4] === 'trust') {
2402
+ this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
2403
+ return;
2404
+ }
2405
+ if (parts[4] === 'revoke') {
2406
+ hooks.revoke(id);
2407
+ this.send(res, 200, { revoked: true });
2408
+ return;
2409
+ }
2410
+ }
2411
+ }
2412
+ if (parts[3] === 'local-tasks' && this.localCoding) {
2413
+ if (!this.supervisor.isInteractiveCoding(id))
2414
+ throw new ServiceError(
2415
+ 'background_denied',
2416
+ 'Background commands are not admitted',
2417
+ 403,
2418
+ );
2419
+ if (parts.length === 4 && req.method === 'GET') {
2420
+ this.send(res, 200, { tasks: this.localCoding.tasks(id) });
2421
+ return;
2422
+ }
2423
+ if (parts.length === 5 && req.method === 'GET') {
2424
+ this.send(res, 200, this.localCoding.taskOutput(id, parts[4]!));
2425
+ return;
2426
+ }
2427
+ if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
2428
+ this.send(res, 200, this.localCoding.stopTask(id, parts[4]!));
2429
+ return;
2430
+ }
2431
+ }
2154
2432
  if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
2155
2433
  if (!this.store.localWorkspace(id))
2156
2434
  throw new ServiceError('local_denied', 'Local workspace required');
2157
2435
  this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
2158
2436
  return;
2159
2437
  }
2438
+ // An edit request's exact files, before and after, so an editor can show it as a diff.
2439
+ if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
2440
+ if (!this.store.localCoding(id))
2441
+ throw new ServiceError('local_denied', 'Local coding session required');
2442
+ this.send(
2443
+ res,
2444
+ 200,
2445
+ this.localCoding.edits(
2446
+ id,
2447
+ url.searchParams.has('operation_id')
2448
+ ? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
2449
+ : { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) },
2450
+ ),
2451
+ );
2452
+ return;
2453
+ }
2160
2454
  if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
2161
2455
  const body = object(await readJson(req));
2162
2456
  this.send(
@@ -2227,6 +2521,132 @@ export class SalaamService {
2227
2521
  return;
2228
2522
  }
2229
2523
  }
2524
+ if (
2525
+ (parts.length === 4 || parts.length === 5) &&
2526
+ (parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')
2527
+ ) {
2528
+ const checkpoints = this.tasks.checkpoints!;
2529
+ // Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
2530
+ const running = <T extends object | null>(value: T): T =>
2531
+ value && this.localCoding
2532
+ ? {
2533
+ ...value,
2534
+ background_running: this.localCoding
2535
+ .tasks(id)
2536
+ .filter((t) => t.state === 'running').length,
2537
+ }
2538
+ : value;
2539
+ const controller = (body: Record<string, unknown>) => ({
2540
+ client_id: string(body.client_id, 'client_id', 128),
2541
+ token: string(body.token, 'token', 128),
2542
+ });
2543
+ const idle = () => {
2544
+ this.supervisor.assertIdle(id);
2545
+ if (
2546
+ this.store.db
2547
+ .prepare(
2548
+ "SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
2549
+ )
2550
+ .get(id)
2551
+ )
2552
+ throw new ServiceError('session_busy', 'Wait for the current turn to finish');
2553
+ };
2554
+ if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
2555
+ this.send(res, 200, { checkpoints: checkpoints.list(id) });
2556
+ return;
2557
+ }
2558
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
2559
+ this.send(
2560
+ res,
2561
+ 200,
2562
+ running(
2563
+ checkpoints.reviewRewind(
2564
+ id,
2565
+ string(url.searchParams.get('attempt'), 'attempt', 128),
2566
+ string(url.searchParams.get('mode'), 'mode', 16) as RewindMode,
2567
+ ),
2568
+ ),
2569
+ );
2570
+ return;
2571
+ }
2572
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
2573
+ if (this.agentpod)
2574
+ throw new ServiceError(
2575
+ 'admission_required',
2576
+ 'Managed station sessions cannot rewind',
2577
+ );
2578
+ const body = object(await readJson(req));
2579
+ const request = string(body.request_id, 'request_id', 128);
2580
+ this.send(
2581
+ res,
2582
+ 200,
2583
+ checkpoints.rewind(
2584
+ id,
2585
+ request,
2586
+ string(body.attempt_id, 'attempt_id', 128),
2587
+ string(body.mode, 'mode', 16) as RewindMode,
2588
+ string(body.review_digest, 'review_digest', 128),
2589
+ controller(body),
2590
+ idle,
2591
+ (leaf, summary) =>
2592
+ this.store.forkLocal(
2593
+ id,
2594
+ request + ':fork',
2595
+ this.store.session(id).revision,
2596
+ controller(body),
2597
+ (source, target) => {
2598
+ const dir = join(this.stateDir, 'workers', target, 'sessions');
2599
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
2600
+ return forkPiSession(source.pi_session_file!, source.cwd, dir, target, {
2601
+ leaf,
2602
+ summary,
2603
+ });
2604
+ },
2605
+ { leaf },
2606
+ ),
2607
+ ),
2608
+ );
2609
+ return;
2610
+ }
2611
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
2612
+ this.send(res, 200, { review: running(checkpoints.review(id)) });
2613
+ return;
2614
+ }
2615
+ if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
2616
+ const q = (name: string) => string(url.searchParams.get(name), name, 1024);
2617
+ this.send(res, 200, {
2618
+ hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
2619
+ });
2620
+ return;
2621
+ }
2622
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
2623
+ const body = object(await readJson(req));
2624
+ const hunk =
2625
+ body.hunk === undefined
2626
+ ? undefined
2627
+ : {
2628
+ text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
2629
+ patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
2630
+ };
2631
+ this.send(res, 200, {
2632
+ review: running(
2633
+ checkpoints.act(
2634
+ id,
2635
+ {
2636
+ action: string(body.action, 'action', 16) as 'accept' | 'revert',
2637
+ path: string(body.path, 'path', 1024),
2638
+ ...(hunk ? { hunk } : {}),
2639
+ base: string(body.base, 'base', 128),
2640
+ current: string(body.current, 'current', 128),
2641
+ },
2642
+ controller(body),
2643
+ idle,
2644
+ ),
2645
+ ),
2646
+ });
2647
+ return;
2648
+ }
2649
+ }
2230
2650
  if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
2231
2651
  if (this.agentpod)
2232
2652
  throw new ServiceError(
@@ -2830,6 +3250,48 @@ export class SalaamService {
2830
3250
  );
2831
3251
  return;
2832
3252
  }
3253
+ if (parts[3] === 'route' && req.method === 'POST') {
3254
+ const body = object(await readJson(req));
3255
+ this.store.authorizeControl(id, {
3256
+ client_id: string(body.client_id, 'client_id', 128),
3257
+ token: string(body.token, 'token', 128),
3258
+ });
3259
+ if (!this.tasks.instructions!.applies(id))
3260
+ throw new ServiceError(
3261
+ 'route_switch_unavailable',
3262
+ 'Only interactive sessions can switch model mid-session',
3263
+ 409,
3264
+ );
3265
+ this.supervisor.assertIdle(id);
3266
+ const input = object(body.route);
3267
+ const requested = providerRoute(
3268
+ string(input.provider, 'provider'),
3269
+ input.model === undefined ? undefined : string(input.model, 'model'),
3270
+ undefined,
3271
+ undefined,
3272
+ undefined,
3273
+ input.reasoning as import('./provider-config.js').ReasoningLevel | undefined,
3274
+ );
3275
+ const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
3276
+ throw new ServiceError(
3277
+ 'provider_auth_required',
3278
+ `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`,
3279
+ 400,
3280
+ );
3281
+ });
3282
+ if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
3283
+ throw new ServiceError(
3284
+ 'provider_output_cap_changed',
3285
+ 'That route enforces output caps differently; start a new session for it',
3286
+ 409,
3287
+ );
3288
+ this.supervisor.assertIdle(id);
3289
+ this.store.switchRoute(id, prepared.route);
3290
+ await this.supervisor.stopSession(id);
3291
+ void this.supervisor.start(id).catch(() => {});
3292
+ this.send(res, 200, this.supervisor.inspect(id));
3293
+ return;
3294
+ }
2833
3295
  if (parts[3] === 'resume' && req.method === 'POST') {
2834
3296
  const body = object(await readJson(req));
2835
3297
  this.store.authorizeControl(id, {
@@ -2848,6 +3310,12 @@ export class SalaamService {
2848
3310
  );
2849
3311
  if (session.state !== 'ready' && session.state !== 'starting')
2850
3312
  void this.supervisor.start(id).catch(() => {});
3313
+ // Show the project instructions an interactive session will use before its first turn.
3314
+ try {
3315
+ this.tasks.instructions?.refresh(id);
3316
+ } catch {
3317
+ /* reported on the next turn */
3318
+ }
2851
3319
  this.send(res, 202, this.supervisor.inspect(id));
2852
3320
  return;
2853
3321
  }
@@ -2863,11 +3331,24 @@ export class SalaamService {
2863
3331
  }
2864
3332
  if (parts[3] === 'submissions' && req.method === 'POST') {
2865
3333
  const body = object(await readJson(req));
3334
+ const local = this.store.localCoding(id);
3335
+ let text = string(body.text, 'text', 256 * 1024);
3336
+ let images: PromptImage[] = [];
3337
+ // Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
3338
+ if (local) {
3339
+ if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
3340
+ throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
3341
+ ({ text, images } = expandPrompt(
3342
+ this.store.session(id).cwd,
3343
+ text,
3344
+ body.confirm_sensitive === true,
3345
+ ));
3346
+ }
2866
3347
  const submission = this.store.accept(
2867
3348
  id,
2868
3349
  string(body.request_id, 'request_id', 128),
2869
3350
  integer(body.expected_revision, 'expected_revision'),
2870
- string(body.text, 'text', 256 * 1024),
3351
+ text,
2871
3352
  {
2872
3353
  client_id: string(body.client_id, 'client_id', 128),
2873
3354
  token: string(body.token, 'token', 128),
@@ -2876,6 +3357,8 @@ export class SalaamService {
2876
3357
  ? null
2877
3358
  : string(body.continues_attempt_id, 'continues_attempt_id', 128),
2878
3359
  );
3360
+ // Same tick as the acceptance, so dispatch cannot claim the submission without them.
3361
+ storeImages(this.store.db, submission.id, images);
2879
3362
  this.send(res, 202, submission);
2880
3363
  return;
2881
3364
  }