@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
@@ -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 } from 'node:http';
2
5
  import { loadPersona } from './persona.js';
3
6
  import { mkdirSync, chmodSync, chownSync, existsSync, unlinkSync, realpathSync, statSync, } from 'node:fs';
@@ -5,6 +8,7 @@ import { join, resolve } from 'node:path';
5
8
  import { providerRoute } from './provider-config.js';
6
9
  import { TaskContext } from './task-context.js';
7
10
  import { ContextAdmission } from './context-admission.js';
11
+ import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
8
12
  import { SLASH_COMMANDS } from './tui-commands.js';
9
13
  import { TaskWork } from './task-work.js';
10
14
  import { TaskBudget } from './task-budget.js';
@@ -20,6 +24,7 @@ import { OperatorInbox } from './operator-inbox.js';
20
24
  import { RecurringWork } from './recurring-work.js';
21
25
  import { JobWork } from './job-work.js';
22
26
  import { Store } from './store.js';
27
+ import { pickerSummary } from './session-pick.js';
23
28
  import { acquireStateLock } from './state-lock.js';
24
29
  import { Artifacts } from './artifacts.js';
25
30
  import { Supervisor } from './supervisor.js';
@@ -53,11 +58,15 @@ import { McpAdmission } from './mcp-admission.js';
53
58
  import { McpWork } from './mcp-work.js';
54
59
  import { ToolkitWork } from './toolkit-work.js';
55
60
  import { permissionPolicy, configurePermissions } from './autonomy.js';
56
- import { chooseLocalSandbox } from './command-sandbox.js';
61
+ import { expandPrompt, storeImages } from './mentions.js';
62
+ import { ApprovalRules } from './approval-rules.js';
63
+ import { chooseLocalSandbox, resolveReadPaths } from './command-sandbox.js';
57
64
  import { LocalCoding } from './local-coding.js';
65
+ import { Hooks } from './hooks.js';
58
66
  import { localDiff } from './local-diff.js';
59
67
  import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
60
68
  import { forkPiSession } from './pi-sessions.js';
69
+ import { TurnCheckpoints } from './turn-rewind.js';
61
70
  import { StationWork } from './station-work.js';
62
71
  import { readToolkitConfig } from './toolkit-config.js';
63
72
  import { probeToolsUser, serviceToolIdentity } from './tool-identity.js';
@@ -115,7 +124,25 @@ export class SalaamService {
115
124
  hostReceipt;
116
125
  operatorAccess;
117
126
  agentpodWorkProgression;
127
+ /** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
128
+ allowFullAccessSessions;
129
+ allowYoloSessions;
130
+ reviewers = new Map();
131
+ reviewerDefault = 'off';
132
+ reviewerPin;
133
+ /** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
134
+ reviewerChoice(session) {
135
+ if (this.reviewerPin)
136
+ return this.reviewerPin;
137
+ const row = this.store.db
138
+ .prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
139
+ .get(session);
140
+ const choice = row?.choice ?? this.reviewerDefault;
141
+ return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
142
+ }
118
143
  constructor(options) {
144
+ this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
145
+ this.allowYoloSessions = options.allowYoloSessions !== false;
119
146
  this.agentpodWorkProgression = options.agentpodWorkProgression === true;
120
147
  if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
121
148
  throw Error('Strict work requires reviewed AgentPod protected station configuration');
@@ -157,7 +184,8 @@ export class SalaamService {
157
184
  this.socket = join(this.stateDir, 'operator.sock');
158
185
  this.bridgeSocket = join(this.stateDir, 'bridge.sock');
159
186
  if (Buffer.byteLength(this.bridgeSocket) > 100)
160
- throw new Error('State path is too long for a Unix socket; choose a shorter --state path');
187
+ throw new Error(`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
188
+ 'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.');
161
189
  this.release = acquireStateLock(this.stateDir);
162
190
  try {
163
191
  this.store = new Store(join(this.stateDir, 'journal.sqlite'));
@@ -170,6 +198,10 @@ export class SalaamService {
170
198
  this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
171
199
  this.tasks = new TaskContext(this.store, this.artifacts);
172
200
  this.tasks.admitted = new ContextAdmission(this.store);
201
+ this.tasks.instructions = new ProjectInstructions(this.store, this.artifacts, options.userInstructions === undefined
202
+ ? userInstructionsPath()
203
+ : options.userInstructions);
204
+ this.tasks.checkpoints = new TurnCheckpoints(this.store, this.stateDir, this.toolIdentity.mode !== 'tools-user');
173
205
  this.taskWork = new TaskWork(this.store, this.tasks);
174
206
  this.taskBudget = new TaskBudget(this.store, this.taskWork);
175
207
  if (options.admitModelRequest) {
@@ -252,10 +284,43 @@ export class SalaamService {
252
284
  const localSandbox = {
253
285
  ...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
254
286
  allowUnsandboxed: options.unsandboxedCommands === 'allow',
287
+ ...(options.localSandboxReadPaths?.length
288
+ ? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
289
+ : {}),
255
290
  ...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
256
291
  };
257
292
  this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt), this.toolIdentity, localSandbox);
293
+ this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
294
+ this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
295
+ this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
258
296
  this.supervisor.localCoding = this.localCoding;
297
+ if (options.dangerClassifierConfig) {
298
+ const loaded = loadDangerClassifierOptions(JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')));
299
+ for (const o of loaded.options)
300
+ this.reviewers.set(o.name, new DangerClassifier(o));
301
+ this.reviewerDefault = loaded.default;
302
+ }
303
+ if (options.dangerClassifierPin !== undefined) {
304
+ if (options.dangerClassifierPin !== 'off' &&
305
+ !this.reviewers.has(options.dangerClassifierPin))
306
+ throw Error(`The pinned command reviewer ${options.dangerClassifierPin} is not configured`);
307
+ this.reviewerPin = options.dangerClassifierPin;
308
+ }
309
+ if (this.reviewers.size) {
310
+ this.store.db.exec('CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)');
311
+ this.localCoding.classifier = {
312
+ review: (s, command, cwd) => {
313
+ const reviewer = this.reviewers.get(this.reviewerChoice(s));
314
+ return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
315
+ },
316
+ };
317
+ }
318
+ const local = this.localCoding;
319
+ this.supervisor.hooks = new Hooks(this.store, (session, command, input, timeout) => local.runHook(session, command, input, timeout), options.hooksFile);
320
+ const webConfig = options.webSearchConfig
321
+ ? loadWebSearchConfig(options.webSearchConfig)
322
+ : { providers: [] };
323
+ this.supervisor.web = new WebSearch(webConfig);
259
324
  this.agents = new AgentProfiles(this.store, this.supervisor.route, async (route, session) => {
260
325
  const prepared = await this.supervisor.prepareProviderRoute(route);
261
326
  const task = this.taskWork.forSession(session);
@@ -1298,7 +1363,10 @@ export class SalaamService {
1298
1363
  return;
1299
1364
  }
1300
1365
  if (url.pathname === '/v1/sessions' && req.method === 'GET') {
1301
- this.send(res, 200, this.store.sessions().map((x) => this.supervisor.inspect(x.id)));
1366
+ this.send(res, 200, this.store.sessions().map((x) => {
1367
+ const session = this.supervisor.inspect(x.id);
1368
+ return { ...session, ...pickerSummary(session, this.store) };
1369
+ }));
1302
1370
  return;
1303
1371
  }
1304
1372
  if (url.pathname === '/v1/sessions' && req.method === 'POST') {
@@ -1389,13 +1457,52 @@ export class SalaamService {
1389
1457
  }
1390
1458
  if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
1391
1459
  const id = parts[2];
1460
+ // Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
1461
+ // and remove. Adding or removing needs the session's control lease, like the permission policy.
1462
+ if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
1463
+ const rules = this.localCoding.rules;
1464
+ if (parts.length === 4 && req.method === 'GET') {
1465
+ this.send(res, 200, {
1466
+ applies: rules.applies(id),
1467
+ rules: rules.list(id),
1468
+ problems: rules.problems(id),
1469
+ });
1470
+ return;
1471
+ }
1472
+ const body = object(await readJson(req));
1473
+ this.store.authorizeControl(id, {
1474
+ client_id: string(body.client_id, 'client_id', 128),
1475
+ token: string(body.token, 'token', 128),
1476
+ });
1477
+ if (parts.length === 4 && req.method === 'POST') {
1478
+ const scope = string(body.scope, 'scope');
1479
+ if (scope !== 'session' && scope !== 'repo')
1480
+ throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
1481
+ const request = string(body.request_id, 'request_id', 128);
1482
+ this.send(res, 200, body.operation_id !== undefined
1483
+ ? rules.addFromOperation(id, string(body.operation_id, 'operation_id', 128), scope, request)
1484
+ : rules.add(id, scope, body.rule, request));
1485
+ return;
1486
+ }
1487
+ if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
1488
+ rules.remove(id, string(parts[4], 'rule', 128));
1489
+ this.send(res, 200, { removed: parts[4] });
1490
+ return;
1491
+ }
1492
+ }
1392
1493
  if (parts.length === 4 && parts[3] === 'permissions') {
1393
1494
  if (req.method === 'GET') {
1394
1495
  this.send(res, 200, {
1395
1496
  policy: permissionPolicy(this.store, id),
1396
1497
  configurable: this.store.localWorkspace(id),
1397
1498
  coding: this.store.localCoding(id),
1398
- command_auto_approval: false,
1499
+ // Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
1500
+ command_auto_approval: this.store.localCoding(id),
1501
+ full_access_allowed: this.allowFullAccessSessions,
1502
+ yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
1503
+ ...(this.store.localCoding(id)
1504
+ ? { command_sandbox: this.localCoding.commandSandbox(id) }
1505
+ : {}),
1399
1506
  });
1400
1507
  return;
1401
1508
  }
@@ -1404,16 +1511,128 @@ export class SalaamService {
1404
1511
  this.send(res, 200, configurePermissions(this.store, id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), body.policy, {
1405
1512
  client_id: string(body.client_id, 'client_id', 128),
1406
1513
  token: string(body.token, 'token', 128),
1514
+ }, {
1515
+ allowFullAccess: this.allowFullAccessSessions,
1516
+ allowYolo: this.allowYoloSessions,
1517
+ deskSession: !!this.localCoding.rules?.applies(id),
1407
1518
  }));
1408
1519
  return;
1409
1520
  }
1410
1521
  }
1522
+ if (parts.length === 4 && parts[3] === 'danger-classifier') {
1523
+ const view = () => {
1524
+ const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off', chosen = this.reviewers.get(choice)?.config;
1525
+ return {
1526
+ configured: this.reviewers.size > 0,
1527
+ enabled: !!chosen,
1528
+ ...(chosen
1529
+ ? {
1530
+ provider: chosen.provider,
1531
+ model: chosen.model,
1532
+ on_unavailable: chosen.on_unavailable,
1533
+ }
1534
+ : {}),
1535
+ choice,
1536
+ pinned: this.reviewerPin !== undefined,
1537
+ options: [...this.reviewers.values()].map(({ config: o }) => ({
1538
+ name: o.name,
1539
+ provider: o.provider,
1540
+ model: o.model,
1541
+ })),
1542
+ };
1543
+ };
1544
+ if (req.method === 'GET') {
1545
+ this.send(res, 200, view());
1546
+ return;
1547
+ }
1548
+ if (req.method === 'POST') {
1549
+ const body = object(await readJson(req));
1550
+ this.store.authorizeControl(id, {
1551
+ client_id: string(body.client_id, 'client_id', 128),
1552
+ token: string(body.token, 'token', 128),
1553
+ });
1554
+ // `enabled` is the earlier on/off form: on means the config's default.
1555
+ const choice = typeof body.enabled === 'boolean'
1556
+ ? body.enabled
1557
+ ? this.reviewerDefault === 'off'
1558
+ ? [...this.reviewers.keys()][0]
1559
+ : this.reviewerDefault
1560
+ : 'off'
1561
+ : string(body.choice, 'choice', 64);
1562
+ if (!this.reviewers.size && choice !== 'off')
1563
+ throw new ServiceError('classifier_unavailable', 'No command reviewer is configured on this host (--danger-classifier-config)');
1564
+ if (this.reviewerPin !== undefined)
1565
+ throw new ServiceError('classifier_pinned', `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`, 403);
1566
+ if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
1567
+ throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
1568
+ if (this.reviewers.size)
1569
+ this.store.transaction(() => {
1570
+ this.store.db
1571
+ .prepare('INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice')
1572
+ .run(id, choice);
1573
+ this.store.event(id, 'permission.classifier-set', { choice });
1574
+ });
1575
+ this.send(res, 200, view());
1576
+ return;
1577
+ }
1578
+ }
1579
+ // Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
1580
+ if (parts[3] === 'hooks' && this.supervisor.hooks) {
1581
+ if (!this.supervisor.isInteractiveCoding(id))
1582
+ throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
1583
+ const hooks = this.supervisor.hooks;
1584
+ if (parts.length === 4 && req.method === 'GET') {
1585
+ this.send(res, 200, { sources: hooks.sources(id) });
1586
+ return;
1587
+ }
1588
+ if (parts.length === 5 && req.method === 'POST') {
1589
+ const body = object(await readJson(req));
1590
+ this.store.authorizeControl(id, {
1591
+ client_id: string(body.client_id, 'client_id', 128),
1592
+ token: string(body.token, 'token', 128),
1593
+ });
1594
+ if (parts[4] === 'trust') {
1595
+ this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
1596
+ return;
1597
+ }
1598
+ if (parts[4] === 'revoke') {
1599
+ hooks.revoke(id);
1600
+ this.send(res, 200, { revoked: true });
1601
+ return;
1602
+ }
1603
+ }
1604
+ }
1605
+ if (parts[3] === 'local-tasks' && this.localCoding) {
1606
+ if (!this.supervisor.isInteractiveCoding(id))
1607
+ throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
1608
+ if (parts.length === 4 && req.method === 'GET') {
1609
+ this.send(res, 200, { tasks: this.localCoding.tasks(id) });
1610
+ return;
1611
+ }
1612
+ if (parts.length === 5 && req.method === 'GET') {
1613
+ this.send(res, 200, this.localCoding.taskOutput(id, parts[4]));
1614
+ return;
1615
+ }
1616
+ if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
1617
+ this.send(res, 200, this.localCoding.stopTask(id, parts[4]));
1618
+ return;
1619
+ }
1620
+ }
1411
1621
  if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
1412
1622
  if (!this.store.localWorkspace(id))
1413
1623
  throw new ServiceError('local_denied', 'Local workspace required');
1414
1624
  this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
1415
1625
  return;
1416
1626
  }
1627
+ // An edit request's exact files, before and after, so an editor can show it as a diff.
1628
+ if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
1629
+ if (!this.store.localCoding(id))
1630
+ throw new ServiceError('local_denied', 'Local coding session required');
1631
+ this.send(res, 200, this.localCoding.edits(id, url.searchParams.has('operation_id')
1632
+ ? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
1633
+ : { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) }));
1634
+ return;
1635
+ }
1417
1636
  if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
1418
1637
  const body = object(await readJson(req));
1419
1638
  this.send(res, 200, await this.supervisor.steer(id, string(body.request_id, 'request_id', 128), string(body.text, 'text', 65536), {
@@ -1450,6 +1669,83 @@ export class SalaamService {
1450
1669
  return;
1451
1670
  }
1452
1671
  }
1672
+ if ((parts.length === 4 || parts.length === 5) &&
1673
+ (parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')) {
1674
+ const checkpoints = this.tasks.checkpoints;
1675
+ // Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
1676
+ const running = (value) => value && this.localCoding
1677
+ ? {
1678
+ ...value,
1679
+ background_running: this.localCoding
1680
+ .tasks(id)
1681
+ .filter((t) => t.state === 'running').length,
1682
+ }
1683
+ : value;
1684
+ const controller = (body) => ({
1685
+ client_id: string(body.client_id, 'client_id', 128),
1686
+ token: string(body.token, 'token', 128),
1687
+ });
1688
+ const idle = () => {
1689
+ this.supervisor.assertIdle(id);
1690
+ if (this.store.db
1691
+ .prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
1692
+ .get(id))
1693
+ throw new ServiceError('session_busy', 'Wait for the current turn to finish');
1694
+ };
1695
+ if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
1696
+ this.send(res, 200, { checkpoints: checkpoints.list(id) });
1697
+ return;
1698
+ }
1699
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
1700
+ this.send(res, 200, running(checkpoints.reviewRewind(id, string(url.searchParams.get('attempt'), 'attempt', 128), string(url.searchParams.get('mode'), 'mode', 16))));
1701
+ return;
1702
+ }
1703
+ if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
1704
+ if (this.agentpod)
1705
+ throw new ServiceError('admission_required', 'Managed station sessions cannot rewind');
1706
+ const body = object(await readJson(req));
1707
+ const request = string(body.request_id, 'request_id', 128);
1708
+ this.send(res, 200, checkpoints.rewind(id, request, string(body.attempt_id, 'attempt_id', 128), string(body.mode, 'mode', 16), string(body.review_digest, 'review_digest', 128), controller(body), idle, (leaf, summary) => this.store.forkLocal(id, request + ':fork', this.store.session(id).revision, controller(body), (source, target) => {
1709
+ const dir = join(this.stateDir, 'workers', target, 'sessions');
1710
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1711
+ return forkPiSession(source.pi_session_file, source.cwd, dir, target, {
1712
+ leaf,
1713
+ summary,
1714
+ });
1715
+ }, { leaf })));
1716
+ return;
1717
+ }
1718
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
1719
+ this.send(res, 200, { review: running(checkpoints.review(id)) });
1720
+ return;
1721
+ }
1722
+ if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
1723
+ const q = (name) => string(url.searchParams.get(name), name, 1024);
1724
+ this.send(res, 200, {
1725
+ hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
1726
+ });
1727
+ return;
1728
+ }
1729
+ if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
1730
+ const body = object(await readJson(req));
1731
+ const hunk = body.hunk === undefined
1732
+ ? undefined
1733
+ : {
1734
+ text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
1735
+ patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
1736
+ };
1737
+ this.send(res, 200, {
1738
+ review: running(checkpoints.act(id, {
1739
+ action: string(body.action, 'action', 16),
1740
+ path: string(body.path, 'path', 1024),
1741
+ ...(hunk ? { hunk } : {}),
1742
+ base: string(body.base, 'base', 128),
1743
+ current: string(body.current, 'current', 128),
1744
+ }, controller(body), idle)),
1745
+ });
1746
+ return;
1747
+ }
1748
+ }
1453
1749
  if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
1454
1750
  if (this.agentpod)
1455
1751
  throw new ServiceError('admission_required', 'Managed station sessions cannot be forked locally');
@@ -1845,6 +2141,29 @@ export class SalaamService {
1845
2141
  this.send(res, 200, this.store.control(id, string(body.request_id, 'request_id', 128), string(body.client_id, 'client_id', 128), action, integer(body.expected_epoch, 'expected_epoch'), body.token === undefined ? undefined : string(body.token, 'token', 128)));
1846
2142
  return;
1847
2143
  }
2144
+ if (parts[3] === 'route' && req.method === 'POST') {
2145
+ const body = object(await readJson(req));
2146
+ this.store.authorizeControl(id, {
2147
+ client_id: string(body.client_id, 'client_id', 128),
2148
+ token: string(body.token, 'token', 128),
2149
+ });
2150
+ if (!this.tasks.instructions.applies(id))
2151
+ throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
2152
+ this.supervisor.assertIdle(id);
2153
+ const input = object(body.route);
2154
+ const requested = providerRoute(string(input.provider, 'provider'), input.model === undefined ? undefined : string(input.model, 'model'), undefined, undefined, undefined, input.reasoning);
2155
+ const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
2156
+ throw new ServiceError('provider_auth_required', `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`, 400);
2157
+ });
2158
+ if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
2159
+ throw new ServiceError('provider_output_cap_changed', 'That route enforces output caps differently; start a new session for it', 409);
2160
+ this.supervisor.assertIdle(id);
2161
+ this.store.switchRoute(id, prepared.route);
2162
+ await this.supervisor.stopSession(id);
2163
+ void this.supervisor.start(id).catch(() => { });
2164
+ this.send(res, 200, this.supervisor.inspect(id));
2165
+ return;
2166
+ }
1848
2167
  if (parts[3] === 'resume' && req.method === 'POST') {
1849
2168
  const body = object(await readJson(req));
1850
2169
  this.store.authorizeControl(id, {
@@ -1858,6 +2177,13 @@ export class SalaamService {
1858
2177
  throw new ServiceError('provider_route_changed', 'Restart the service with this session’s original provider route, or create a new session');
1859
2178
  if (session.state !== 'ready' && session.state !== 'starting')
1860
2179
  void this.supervisor.start(id).catch(() => { });
2180
+ // Show the project instructions an interactive session will use before its first turn.
2181
+ try {
2182
+ this.tasks.instructions?.refresh(id);
2183
+ }
2184
+ catch {
2185
+ /* reported on the next turn */
2186
+ }
1861
2187
  this.send(res, 202, this.supervisor.inspect(id));
1862
2188
  return;
1863
2189
  }
@@ -1873,12 +2199,23 @@ export class SalaamService {
1873
2199
  }
1874
2200
  if (parts[3] === 'submissions' && req.method === 'POST') {
1875
2201
  const body = object(await readJson(req));
1876
- const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), string(body.text, 'text', 256 * 1024), {
2202
+ const local = this.store.localCoding(id);
2203
+ let text = string(body.text, 'text', 256 * 1024);
2204
+ let images = [];
2205
+ // Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
2206
+ if (local) {
2207
+ if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
2208
+ throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
2209
+ ({ text, images } = expandPrompt(this.store.session(id).cwd, text, body.confirm_sensitive === true));
2210
+ }
2211
+ const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), text, {
1877
2212
  client_id: string(body.client_id, 'client_id', 128),
1878
2213
  token: string(body.token, 'token', 128),
1879
2214
  }, body.continues_attempt_id === undefined
1880
2215
  ? null
1881
2216
  : string(body.continues_attempt_id, 'continues_attempt_id', 128));
2217
+ // Same tick as the acceptance, so dispatch cannot claim the submission without them.
2218
+ storeImages(this.store.db, submission.id, images);
1882
2219
  this.send(res, 202, submission);
1883
2220
  return;
1884
2221
  }
@@ -0,0 +1,77 @@
1
+ import { typedText } from './shell-passthrough.js';
2
+ import { statSync } from 'node:fs';
3
+ import { OFFLINE_ROUTE } from './provider-config.js';
4
+ export function parseCodeStart(flags) {
5
+ const chosen = ['continue', 'new', 'resume'].filter((f) => flags.includes('--' + f));
6
+ if (chosen.length > 1)
7
+ throw new Error('Choose one of --continue, --new or --resume.');
8
+ return chosen[0] ?? 'continue';
9
+ }
10
+ const folder = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
11
+ /** The most recently created session in this folder; sessions come oldest first. */
12
+ export function latestSessionFor(sessions, cwd) {
13
+ const want = folder(cwd);
14
+ return [...sessions].reverse().find((s) => folder(s.cwd) === want)?.id;
15
+ }
16
+ /** Whether a new `salaam code` session should use Anthropic: only when nothing was chosen, the
17
+ * running service defaults to offline (as a TUI-started one does) and a key is stored. */
18
+ export function deskUsesAnthropic(chosen, serviceProvider, hasAnthropicKey) {
19
+ return (!chosen &&
20
+ (serviceProvider ?? OFFLINE_ROUTE.provider) === OFFLINE_ROUTE.provider &&
21
+ hasAnthropicKey);
22
+ }
23
+ /** `salaam -c` and `salaam --continue` are short for `salaam code --continue`. */
24
+ export function continueArgs(args) {
25
+ if (args[0] !== '-c' && args[0] !== '--continue')
26
+ return;
27
+ return ['code', '--continue', ...args.slice(1)];
28
+ }
29
+ const activeAt = (s) => s.last_active_at ? Date.parse(s.last_active_at) : -Infinity;
30
+ /** This folder's sessions first, then every other one; newest first within each. */
31
+ export function pickerOrder(sessions, cwd) {
32
+ const want = folder(cwd);
33
+ const newest = (a, b) => activeAt(b) - activeAt(a);
34
+ const here = sessions.filter((s) => folder(s.cwd) === want).sort(newest);
35
+ return [...here, ...sessions.filter((s) => folder(s.cwd) !== want).sort(newest)];
36
+ }
37
+ export function relativeTime(at, now = Date.now()) {
38
+ if (!at)
39
+ return '';
40
+ const s = Math.max(0, (now - Date.parse(at)) / 1000);
41
+ if (s < 60)
42
+ return 'just now';
43
+ if (s < 3600)
44
+ return `${Math.floor(s / 60)} min ago`;
45
+ if (s < 86400)
46
+ return `${Math.floor(s / 3600)} h ago`;
47
+ if (s < 2 * 86400)
48
+ return 'yesterday';
49
+ return `${Math.floor(s / 86400)} days ago`;
50
+ }
51
+ /** One picker row: what you last asked (one short line), when, and where. */
52
+ export function sessionDescription(s, now = Date.now()) {
53
+ const prompt = s.last_prompt?.replace(/\s+/g, ' ').trim();
54
+ const quoted = prompt ? `“${prompt.length > 60 ? prompt.slice(0, 59) + '…' : prompt}”` : '';
55
+ return [quoted || s.state, relativeTime(s.last_active_at, now), s.cwd]
56
+ .filter(Boolean)
57
+ .join(' · ');
58
+ }
59
+ /** What the picker shows for a desk session: its newest prompt (at most 200 characters) and when
60
+ * its transcript last changed. Board and station sessions get nothing extra. */
61
+ export function pickerSummary(session, store) {
62
+ if (!session.capabilities.includes('local-coding-reviewed'))
63
+ return {};
64
+ const prompt = store.lastPrompt(session.id);
65
+ let at;
66
+ try {
67
+ if (session.pi_session_file)
68
+ at = statSync(session.pi_session_file).mtime.toISOString();
69
+ }
70
+ catch {
71
+ /* no transcript yet */
72
+ }
73
+ return {
74
+ ...(prompt ? { last_prompt: typedText(prompt).slice(0, 200) } : {}),
75
+ ...(at ? { last_active_at: at } : {}),
76
+ };
77
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `!command` in a desk session (trial polish #12, 2026-10-31). The terminal never runs the command
3
+ * itself: it asks the model to run exactly that text through salaam_local. The service reads the
4
+ * same fixed prompt back from the turn's submission and binds the turn to the typed command, so a
5
+ * model that runs anything else is refused, whatever the session's mode.
6
+ */
7
+ const LEAD = 'I want to run this shell command myself. Run exactly this command with salaam_local';
8
+ export function shellPassthrough(text) {
9
+ const t = text.trim();
10
+ if (!t.startsWith('!') || t.startsWith('!!'))
11
+ return undefined;
12
+ const command = t.slice(1).trim();
13
+ if (command === '' || command.includes('\n') || command.includes('```'))
14
+ return undefined;
15
+ return [
16
+ LEAD,
17
+ '(action "command"), unchanged: no edits, no extra flags, no other commands. Then show me its',
18
+ 'output and exit status, and do nothing else unless I ask.',
19
+ '',
20
+ '```sh',
21
+ command,
22
+ '```',
23
+ ].join('\n');
24
+ }
25
+ /** The command a turn's prompt binds it to, when the prompt is exactly a `!command` request. */
26
+ export function boundShellCommand(prompt) {
27
+ if (!prompt.startsWith(LEAD + '\n'))
28
+ return undefined;
29
+ const lines = prompt.split('\n');
30
+ if (lines.length !== 7 || lines[4] !== '```sh' || lines[6] !== '```')
31
+ return undefined;
32
+ const command = lines[5];
33
+ return shellPassthrough('!' + command) === prompt ? command : undefined;
34
+ }
35
+ /** What the person typed, for display: `! COMMAND` for a `!command` prompt, else the text. */
36
+ export function typedText(prompt) {
37
+ const command = boundShellCommand(prompt);
38
+ return command === undefined ? prompt : '! ' + command;
39
+ }