@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
@@ -1,18 +1,32 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, lstatSync, rmSync, chmodSync, } from 'node:fs';
2
+ import { mkdirSync, mkdtempSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync, chmodSync, } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { processIdentity } from './supervisor.js';
5
5
  import { randomUUID } from 'node:crypto';
6
6
  import { canonical, digest, object, string, integer, ServiceError, } from './protocol.js';
7
7
  import { readStationConfig, stationUnavailable, stationPrivateFile, stationWorkspaceIdentity, } from './station-config.js';
8
8
  import { stationFileResult } from './station-files.js';
9
+ import { runGrantCovers, runGrantPrefix } from './run-grants.js';
9
10
  import { StationSystemd } from './station-systemd.js';
10
11
  const hashes = /^[a-f0-9]{64}$/;
11
- class StationFilesDefiniteFailure extends ServiceError {
12
- constructor() {
13
- super('station_files_changed', 'Protected file primitive refused before mutation');
12
+ export class StationFilesDefiniteFailure extends ServiceError {
13
+ constructor(code) {
14
+ super(code, 'Protected file primitive refused before mutation');
14
15
  }
15
16
  }
17
+ /** A helper refusal that provably changed nothing, as the code recorded for it, or null. The
18
+ * helper writes station_request_invalid as exactly {version, ok, code} and only while decoding,
19
+ * before it executes anything; any other shape (an effect_state, extra fields) is not definite. */
20
+ export function definiteHelperRefusal(exitCode, transportFailed, result) {
21
+ if (transportFailed || exitCode !== 1 || result.version !== 1 || result.ok !== false)
22
+ return null;
23
+ if (result.code === 'station_files_unavailable' && result.effect_state === 'not_started')
24
+ return 'station_files_changed';
25
+ if (result.code === 'station_request_invalid' &&
26
+ Object.keys(result).sort().join(',') === 'code,ok,version')
27
+ return 'station_request_invalid';
28
+ return null;
29
+ }
16
30
  const terminal = (s) => ['returned', 'failed', 'outcome_unknown', 'cancelled'].includes(s);
17
31
  /** Root-only station controller. Writable source access stays behind the private
18
32
  * traversal-resistant node primitive; arbitrary checks get a separate OS identity,
@@ -24,6 +38,9 @@ export class StationWork {
24
38
  authority;
25
39
  reference;
26
40
  options;
41
+ /** "Approve for this run" grants and the task each session's run belongs to (service wiring). */
42
+ grants;
43
+ grantScope;
27
44
  config;
28
45
  configurationDigest;
29
46
  directory;
@@ -190,6 +207,14 @@ export class StationWork {
190
207
  spawned = true;
191
208
  try {
192
209
  if (id) {
210
+ // Own the process first: a guard that refuses below must still leave a pid and identity
211
+ // that a stop can prove exited (staging card_06a619a3e2e94faf left a pid-less helper).
212
+ const identity = processIdentity(child.pid);
213
+ if (!identity)
214
+ stationUnavailable();
215
+ this.store.transaction(() => this.store.db
216
+ .prepare("UPDATE station_calls SET helper_pid=?,helper_identity=?,helper_state='running' WHERE id=? AND helper_state='launching'")
217
+ .run(child.pid, identity, id));
193
218
  const c = this.get(id);
194
219
  if (verificationGuard)
195
220
  verificationGuard();
@@ -197,12 +222,6 @@ export class StationWork {
197
222
  this.guard(c);
198
223
  if (c.state !== 'dispatching')
199
224
  stationUnavailable();
200
- const identity = processIdentity(child.pid);
201
- if (!identity)
202
- stationUnavailable();
203
- this.store.transaction(() => this.store.db
204
- .prepare("UPDATE station_calls SET helper_pid=?,helper_identity=?,helper_state='running' WHERE id=? AND helper_state='launching'")
205
- .run(child.pid, identity, id));
206
225
  }
207
226
  child.stdin.end(JSON.stringify({
208
227
  version: 1,
@@ -262,13 +281,9 @@ export class StationWork {
262
281
  stationUnavailable();
263
282
  }
264
283
  const result = object(JSON.parse(Buffer.concat(chunks).toString('utf8')));
265
- if (!failed &&
266
- code === 1 &&
267
- result.version === 1 &&
268
- result.ok === false &&
269
- result.code === 'station_files_unavailable' &&
270
- result.effect_state === 'not_started')
271
- throw new StationFilesDefiniteFailure();
284
+ const refusal = definiteHelperRefusal(code, failed, result);
285
+ if (refusal)
286
+ throw new StationFilesDefiniteFailure(refusal);
272
287
  if (failed || code !== 0 || result.version !== 1 || result.ok !== true)
273
288
  stationUnavailable();
274
289
  accept(stationFileResult({ ...request, root_identity: this.config.workspace_identity }, result));
@@ -288,14 +303,43 @@ export class StationWork {
288
303
  return error.code !== 'ESRCH';
289
304
  }
290
305
  }
306
+ /** Whether no process on this host runs the station file helper, proven from every /proc entry. */
307
+ noHelperProcess() {
308
+ if (process.platform !== 'linux')
309
+ return false;
310
+ for (const entry of readdirSync('/proc')) {
311
+ if (!/^\d+$/.test(entry))
312
+ continue;
313
+ let argv;
314
+ try {
315
+ argv = readFileSync(join('/proc', entry, 'cmdline'), 'utf8').split('\0');
316
+ }
317
+ catch (error) {
318
+ if (error.code === 'ENOENT')
319
+ continue;
320
+ return false;
321
+ }
322
+ if (argv.includes('salaam-station-files-v1'))
323
+ return false;
324
+ }
325
+ return true;
326
+ }
291
327
  async stopHelper(id) {
292
328
  const c = this.get(id);
293
329
  if (!c.helper_state || c.helper_state === 'none' || c.helper_state === 'stopped')
294
330
  return;
295
331
  this.files.get(id)?.abort();
296
332
  try {
297
- if (!c.helper_pid || !c.helper_identity)
298
- stationUnavailable();
333
+ if (!c.helper_pid || !c.helper_identity) {
334
+ // Recorded before the fix above: no pid to prove. Settle only when the whole process table
335
+ // holds no station helper; any live or unreadable process keeps the call fenced.
336
+ if (!this.noHelperProcess())
337
+ stationUnavailable();
338
+ this.store.transaction(() => this.store.db
339
+ .prepare("UPDATE station_calls SET helper_state='stopped',helper_stop_json=? WHERE id=?")
340
+ .run(canonical({ version: 1, scope: 'host_process_table', method: 'no_helper_process' }), id));
341
+ return;
342
+ }
299
343
  if (this.processExists(c.helper_pid) || this.processExists(c.helper_pid, true)) {
300
344
  if (processIdentity(c.helper_pid) !== c.helper_identity)
301
345
  stationUnavailable();
@@ -483,7 +527,7 @@ export class StationWork {
483
527
  if (!['patch', 'check'].includes(kind))
484
528
  throw new ServiceError('invalid_field', 'Unsupported station mutation');
485
529
  const allowed = kind === 'patch'
486
- ? ['action', 'path', 'expected_tree', 'expected_hash', 'content']
530
+ ? ['action', 'path', 'expected_tree', 'expected_hash', 'content', 'reason']
487
531
  : ['action', 'expected_tree', 'command'];
488
532
  if (Object.keys(args).some((k) => !allowed.includes(k)) ||
489
533
  typeof args.expected_tree !== 'string' ||
@@ -560,8 +604,250 @@ export class StationWork {
560
604
  input_digest: row.input_digest,
561
605
  });
562
606
  });
607
+ const prepared = this.get(id);
608
+ if (kind === 'patch' && prepared.operation_id)
609
+ this.answerFromGrant(prepared.operation_id, session, args);
563
610
  return this.view(id);
564
611
  }
612
+ /** A write the run's grant covers is answered by it, checked again at decision time. The
613
+ * operation is still prepared, digest-bound and journaled on its own; deletes always ask. */
614
+ /** An agent's standing "Always allow" rule (pinned at admission or added by a renewal, dropped at the renewal that no
615
+ * longer lists it) answers a workspace write anywhere in the workspace, never a delete or a
616
+ * hidden path. Checked again at decision time, so a rule revoked meanwhile answers nothing. */
617
+ answerFromRule(operation, session, args) {
618
+ if (!runGrantCovers('', args))
619
+ return;
620
+ const live = () => this.authority.approvalRules(session).find((r) => r.scope === 'workspace-write');
621
+ const rule = live();
622
+ if (!rule)
623
+ return;
624
+ const approval = this.managed.forOperation(operation);
625
+ if (approval.state !== 'pending')
626
+ return;
627
+ this.managed.decide(approval.id, 'rule:' + operation, approval.revision, approval.operation_digest, 'approve', {
628
+ decidedBy: 'rule:' + rule.id,
629
+ receipt: { rule: 'agent-rule', rule_id: rule.id, set_by: rule.set_by },
630
+ authorize: () => {
631
+ if (live()?.id !== rule.id)
632
+ throw new ServiceError('permission_changed', 'The approval rule no longer covers this write');
633
+ },
634
+ });
635
+ }
636
+ answerFromGrant(operation, session, args) {
637
+ if (!this.grants || !this.grantScope)
638
+ return;
639
+ const binding = this.authority.binding(session);
640
+ const prefix = runGrantPrefix(typeof binding?.run?.id === 'string' ? binding.run.id : '');
641
+ const live = () => {
642
+ const scope = this.grantScope(session);
643
+ if (!scope || !prefix || !runGrantCovers(prefix, args))
644
+ return null;
645
+ const g = this.grants.active({
646
+ session_id: session,
647
+ binding_digest: digest(binding),
648
+ ...scope,
649
+ });
650
+ return g;
651
+ };
652
+ const grant = live();
653
+ if (!grant)
654
+ return this.answerFromRule(operation, session, args);
655
+ const approval = this.managed.forOperation(operation);
656
+ if (approval.state !== 'pending')
657
+ return;
658
+ this.managed.decide(approval.id, 'run-grant:' + operation, approval.revision, approval.operation_digest, 'approve', {
659
+ decidedBy: 'run-grant:' + grant.id,
660
+ receipt: {
661
+ rule: 'run-grant',
662
+ grant_id: grant.id,
663
+ granted_by: grant.granted_by,
664
+ granted_on: grant.approval_id,
665
+ },
666
+ authorize: () => {
667
+ if (live()?.id !== grant.id)
668
+ throw new ServiceError('permission_changed', 'The run grant no longer covers this write');
669
+ },
670
+ });
671
+ }
672
+ /** Item 11b: the restore journal. Each write is recorded before it is sent, so a daemon stop
673
+ * mid-restore leaves an honest unknown, never a silent effect. */
674
+ restoreTables() {
675
+ this.store.db.exec(`CREATE TABLE IF NOT EXISTS station_restores(
676
+ id TEXT PRIMARY KEY NOT NULL, session_id TEXT NOT NULL, request_id TEXT NOT NULL UNIQUE,
677
+ restore_digest TEXT NOT NULL, approved_by TEXT NOT NULL, state TEXT NOT NULL, receipt TEXT);
678
+ CREATE TABLE IF NOT EXISTS station_restore_writes(
679
+ restore_id TEXT NOT NULL REFERENCES station_restores(id), n INTEGER NOT NULL, path TEXT NOT NULL,
680
+ args_json TEXT NOT NULL, state TEXT NOT NULL, result_json TEXT, artifact_id TEXT,
681
+ PRIMARY KEY(restore_id, n));`);
682
+ }
683
+ /** Item 11b: the live workspace through the helper's closed verbs (inspect, search, read). A
684
+ * path is absent only when an exact search finds no entry for it; a file the helper refuses to
685
+ * read (too large, not text) is unreadable, never guessed. */
686
+ async live() {
687
+ const inspected = await this.helper({ action: 'inspect' });
688
+ return {
689
+ tree: String(inspected.tree_digest),
690
+ file: async (path) => {
691
+ let offset = 0, found = false;
692
+ while (offset !== null && !found) {
693
+ const page = await this.helper({
694
+ action: 'search',
695
+ query: path,
696
+ offset,
697
+ });
698
+ found = page.entries.some((e) => e.path === path);
699
+ offset = page.next_offset ?? null;
700
+ }
701
+ if (!found)
702
+ return { state: 'absent' };
703
+ try {
704
+ const read = await this.helper({ action: 'read', path });
705
+ return { state: 'content', content: String(read.content), sha256: String(read.sha256) };
706
+ }
707
+ catch {
708
+ return { state: 'unreadable' };
709
+ }
710
+ },
711
+ };
712
+ }
713
+ /** Item 11b: the journal's answer for a restore request already seen, or null for a new one.
714
+ * A replay returns the saved receipt exactly; the same request for another restore is a
715
+ * conflict; an unfinished one says so. Checked before any plan is recomputed. */
716
+ replayed(session, request, restoreDigest) {
717
+ this.restoreTables();
718
+ const saved = this.store.db
719
+ .prepare('SELECT * FROM station_restores WHERE request_id=?')
720
+ .get(request);
721
+ if (!saved)
722
+ return null;
723
+ if (saved.restore_digest !== restoreDigest || saved.session_id !== session)
724
+ throw new ServiceError('request_conflict', 'This restore request was used for another restore');
725
+ if (saved.receipt)
726
+ return JSON.parse(String(saved.receipt));
727
+ throw new ServiceError('restore_unconfirmed', 'This restore did not finish; review it again');
728
+ }
729
+ /** At start: a restore write that was in flight has an unknown outcome; its restore is partial. */
730
+ recoverRestores() {
731
+ this.restoreTables();
732
+ this.store.transaction(() => {
733
+ for (const w of this.store.db
734
+ .prepare("SELECT restore_id,n FROM station_restore_writes WHERE state='dispatching'")
735
+ .all()) {
736
+ this.store.db
737
+ .prepare("UPDATE station_restore_writes SET state='outcome_unknown' WHERE restore_id=? AND n=?")
738
+ .run(String(w.restore_id), Number(w.n));
739
+ this.store.db
740
+ .prepare("UPDATE station_restores SET state='partial' WHERE id=?")
741
+ .run(String(w.restore_id));
742
+ }
743
+ });
744
+ }
745
+ /**
746
+ * Item 11b: restore files exactly as planned and approved, after the run has ended. The caller
747
+ * checks the ended run and the approval; this writes. It refuses a workspace whose live tree is
748
+ * not the reviewed one, then sends each write through the protected helper in order, chaining
749
+ * the expected tree and each file's current hash, with a receipt per file. The same request
750
+ * replays its receipt. A failure stops the restore; nothing is retried.
751
+ */
752
+ async restore(session, rootDigest, plan, request, approvedBy) {
753
+ this.restoreTables();
754
+ const saved = this.store.db
755
+ .prepare('SELECT * FROM station_restores WHERE request_id=?')
756
+ .get(request);
757
+ if (saved) {
758
+ if (saved.restore_digest !== plan.restore_digest || saved.session_id !== session)
759
+ throw new ServiceError('request_conflict', 'This restore request was used for another restore');
760
+ if (saved.receipt)
761
+ return JSON.parse(String(saved.receipt));
762
+ throw new ServiceError('restore_unconfirmed', 'This restore did not finish; review it again');
763
+ }
764
+ const live = (await this.helper({ action: 'inspect' }));
765
+ if (live.tree_digest !== plan.current_tree)
766
+ throw new ServiceError('restore_changed', 'The workspace changed since this restore was reviewed');
767
+ const id = randomUUID();
768
+ this.store.transaction(() => this.store.db
769
+ .prepare("INSERT INTO station_restores VALUES (?,?,?,?,?,'running',NULL)")
770
+ .run(id, session, request, plan.restore_digest, approvedBy));
771
+ let tree = plan.current_tree;
772
+ const done = [];
773
+ let stopped = null;
774
+ for (const [n, w] of plan.writes.entries()) {
775
+ const args = {
776
+ action: 'patch',
777
+ path: w.path,
778
+ expected_tree: tree,
779
+ expected_hash: w.expected_hash,
780
+ content: w.content,
781
+ // The helper accepts only a 64-hex request id: one per restore write, stable on replay.
782
+ request_id: digest({ restore: id, write: n }),
783
+ deadline_ms: Date.now() + 5000,
784
+ };
785
+ this.store.transaction(() => this.store.db
786
+ .prepare("INSERT INTO station_restore_writes VALUES (?,?,?,?,'dispatching',NULL,NULL)")
787
+ .run(id, n, w.path, canonical(args)));
788
+ let result;
789
+ try {
790
+ result = (await this.helper(args));
791
+ }
792
+ catch (error) {
793
+ const definite = error instanceof StationFilesDefiniteFailure;
794
+ this.store.transaction(() => this.store.db
795
+ .prepare('UPDATE station_restore_writes SET state=?,result_json=? WHERE restore_id=? AND n=?')
796
+ .run(definite ? 'failed' : 'outcome_unknown', canonical({
797
+ code: definite ? error.code : 'station_execution_unconfirmed',
798
+ }), id, n));
799
+ stopped = w.path;
800
+ break;
801
+ }
802
+ const artifact = this.artifacts.prepare(session, canonical({
803
+ version: 1,
804
+ kind: 'station-restore-receipt',
805
+ restore_id: id,
806
+ request_id: request,
807
+ restore_digest: plan.restore_digest,
808
+ approved_by: approvedBy,
809
+ session_id: session,
810
+ root_digest: rootDigest,
811
+ configuration_digest: this.configurationDigest,
812
+ path: w.path,
813
+ patch: {
814
+ content: w.content,
815
+ expected_tree: args.expected_tree,
816
+ expected_hash: w.expected_hash,
817
+ },
818
+ result,
819
+ }));
820
+ this.store.transaction(() => {
821
+ artifact.commit();
822
+ this.store.db
823
+ .prepare("UPDATE station_restore_writes SET state='returned',result_json=?,artifact_id=? WHERE restore_id=? AND n=?")
824
+ .run(canonical(result), artifact.id, id, n);
825
+ });
826
+ done.push({ path: w.path, artifact_id: artifact.id });
827
+ tree = String(result.tree_digest);
828
+ }
829
+ const receipt = {
830
+ version: 1,
831
+ state: stopped ? 'partial' : 'restored',
832
+ restore_id: id,
833
+ restore_digest: plan.restore_digest,
834
+ tree_digest: tree,
835
+ writes: done,
836
+ ...(stopped ? { stopped_at: stopped } : {}),
837
+ };
838
+ this.store.transaction(() => {
839
+ this.store.db
840
+ .prepare('UPDATE station_restores SET state=?,receipt=? WHERE id=?')
841
+ .run(receipt.state, canonical(receipt), id);
842
+ this.store.event(session, 'station.restored', {
843
+ restore_id: id,
844
+ restore_digest: plan.restore_digest,
845
+ approved_by: approvedBy,
846
+ state: receipt.state,
847
+ });
848
+ });
849
+ return receipt;
850
+ }
565
851
  run(id) {
566
852
  const c = this.get(id);
567
853
  if (terminal(c.state) || this.jobs.has(id))
@@ -592,7 +878,7 @@ export class StationWork {
592
878
  if (current.state === 'dispatching')
593
879
  this.store.transaction(() => {
594
880
  const definite = error instanceof StationFilesDefiniteFailure;
595
- this.change(current, current.kind === 'patch' && !definite ? 'outcome_unknown' : 'failed', { code: definite ? 'station_files_changed' : 'station_execution_unconfirmed' });
881
+ this.change(current, current.kind === 'patch' && !definite ? 'outcome_unknown' : 'failed', { code: definite ? error.code : 'station_execution_unconfirmed' });
596
882
  if (current.operation_id) {
597
883
  if (definite)
598
884
  this.managed.workspaceDefiniteFailureInTransaction(current.operation_id);
@@ -622,7 +908,13 @@ export class StationWork {
622
908
  this.guard(c);
623
909
  if (c.kind === 'patch') {
624
910
  const result = await this.helper({
625
- ...args,
911
+ // Only the helper's own fields: it refuses any field it does not know (the agent's
912
+ // reason is for the approval detail and stays in the operation's arguments).
913
+ action: args.action,
914
+ path: args.path,
915
+ expected_tree: args.expected_tree,
916
+ expected_hash: args.expected_hash,
917
+ content: args.content,
626
918
  request_id: id,
627
919
  deadline_ms: Date.now() + this.authority.leaseWindow(c.session_id, c.root_digest, 5000),
628
920
  }, id);
@@ -807,6 +1099,7 @@ export class StationWork {
807
1099
  }
808
1100
  async initialize() {
809
1101
  this.proven = false;
1102
+ this.recoverRestores();
810
1103
  for (const row of this.store.db
811
1104
  .prepare("SELECT id FROM station_calls WHERE helper_state NOT IN ('none','stopped') ORDER BY rowid")
812
1105
  .all())
package/dist/src/store.js CHANGED
@@ -28,14 +28,20 @@ export class Store {
28
28
  cancelChildren;
29
29
  recordTaskMutation;
30
30
  taskGrantCheck;
31
+ /** Provenance: the OS identity that local tool effects run as. */
32
+ toolExecution;
31
33
  jobGrantCheck;
32
34
  taskModelAdmission;
33
35
  taskBudgetDenied;
36
+ /** A durable task's own attempts are bounded by the task's request limit, not the attempt default. */
37
+ taskAttemptRequestLimit;
34
38
  taskChildReserve;
35
39
  taskChildFinished;
36
40
  taskCommandAdmission;
37
41
  taskRemainingRuntime;
38
42
  committed = null;
43
+ activity = new Map();
44
+ openedAt = Date.now();
39
45
  constructor(path) {
40
46
  this.db = new DatabaseSync(path);
41
47
  if (path !== ':memory:')
@@ -341,6 +347,9 @@ export class Store {
341
347
  finally {
342
348
  this.committed = null;
343
349
  }
350
+ const now = Date.now();
351
+ for (const event of events)
352
+ this.activity.set(event.session_id, now);
344
353
  for (const event of events)
345
354
  this.changes.emit('event', event);
346
355
  return result;
@@ -417,6 +426,7 @@ export class Store {
417
426
  local_workspace: true,
418
427
  local_coding: local.coding === true,
419
428
  output_cap: local.outputCap ?? true,
429
+ ...(this.toolExecution ? { tool_execution: this.toolExecution } : {}),
420
430
  }
421
431
  : {}),
422
432
  }, requestId);
@@ -451,8 +461,11 @@ export class Store {
451
461
  throw new ServiceError('not_found', 'Session not found', 404);
452
462
  return { ...row, capabilities: JSON.parse(row.capabilities) };
453
463
  }
454
- forkLocal(id, request, expected, controller, clone) {
455
- return this.request('local-operator', request, { kind: 'fork', id, expected }, () => {
464
+ forkLocal(id, request, expected, controller, clone,
465
+ /** A rewind forks from an earlier conversation leaf. */
466
+ at) {
467
+ const input = { kind: 'fork', id, expected, ...(at ? { leaf: at.leaf } : {}) };
468
+ return this.request('local-operator', request, input, () => {
456
469
  this.authorizeControl(id, controller);
457
470
  const source = this.session(id);
458
471
  if (source.revision !== expected)
@@ -468,12 +481,13 @@ export class Store {
468
481
  const file = clone(source, target);
469
482
  this.db
470
483
  .prepare('INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES(?,?,?,?,?,?)')
471
- .run(target, source.cwd, target, file, source.pi_cursor, source.pi_leaf);
484
+ .run(target, source.cwd, target, file, source.pi_cursor, at ? at.leaf : source.pi_leaf);
472
485
  this.event(target, 'session.created', {
473
486
  cwd: source.cwd,
474
487
  local_workspace: true,
475
488
  local_coding: this.localCoding(id),
476
489
  output_cap: this.localOutputCap(id) ?? true,
490
+ ...(this.toolExecution ? { tool_execution: this.toolExecution } : {}),
477
491
  forked_from: id,
478
492
  }, request);
479
493
  const route = this.providerRoute(id);
@@ -482,7 +496,13 @@ export class Store {
482
496
  this.db
483
497
  .prepare('INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?')
484
498
  .run(target, id);
485
- this.event(target, 'session.forked', { parent: id, parent_revision: expected, leaf: source.pi_leaf, cursor: source.pi_cursor }, request);
499
+ this.event(target, 'session.forked', {
500
+ parent: id,
501
+ parent_revision: expected,
502
+ leaf: at ? at.leaf : source.pi_leaf,
503
+ cursor: source.pi_cursor,
504
+ ...(at ? { rewound: true } : {}),
505
+ }, request);
486
506
  return this.session(target);
487
507
  });
488
508
  }
@@ -502,6 +522,21 @@ export class Store {
502
522
  return { name };
503
523
  });
504
524
  }
525
+ /** Wall-clock time of the last committed event for a session in this process, if any. */
526
+ lastActivity(id) {
527
+ return this.activity.get(id) ?? null;
528
+ }
529
+ /** The newest committed event in this process, or the time the store opened. */
530
+ lastActivityAny() {
531
+ return Math.max(this.openedAt, ...this.activity.values());
532
+ }
533
+ /** The newest prompt submitted to a session, for the session picker. */
534
+ lastPrompt(id) {
535
+ const row = this.db
536
+ .prepare('SELECT text FROM submissions WHERE session_id=? ORDER BY rowid DESC LIMIT 1')
537
+ .get(id);
538
+ return row?.text ?? null;
539
+ }
505
540
  sessions() {
506
541
  return this.db.prepare('SELECT id FROM sessions ORDER BY rowid').all().map((x) => this.session(x.id));
507
542
  }
@@ -554,6 +589,23 @@ export class Store {
554
589
  }
555
590
  });
556
591
  }
592
+ /**
593
+ * The one sanctioned route change: an operator switching model or thinking level in an
594
+ * interactive session between turns. Recorded as provider.route_changed; the worker restarts on
595
+ * the same Pi transcript with the new route.
596
+ */
597
+ switchRoute(id, route) {
598
+ return this.transaction(() => {
599
+ const from = this.providerRoute(id);
600
+ if (!from || !this.localWorkspace(id))
601
+ throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
602
+ this.db
603
+ .prepare('UPDATE session_routes SET route=? WHERE session_id=?')
604
+ .run(canonical(route), id);
605
+ this.event(id, 'provider.route_changed', { from, to: route });
606
+ return { from, to: route };
607
+ });
608
+ }
557
609
  providerRoute(id) {
558
610
  const row = this.db.prepare('SELECT route FROM session_routes WHERE session_id=?').get(id);
559
611
  return row ? JSON.parse(row.route) : null;
@@ -901,6 +953,9 @@ export class Store {
901
953
  const lookup = this.db.prepare('SELECT payload FROM pi_entries WHERE session_id=? AND entry_id=?');
902
954
  let remaining = 256 * 1024;
903
955
  const messages = [];
956
+ // Walking back from the newest entry: tool calls belong just before the message that follows
957
+ // them, so a turn's calls are shown in place rather than gathered at the end.
958
+ let trailing = [];
904
959
  while (cursor &&
905
960
  !seen.has(cursor) &&
906
961
  seen.size < 10000 &&
@@ -915,6 +970,16 @@ export class Store {
915
970
  const message = entry.message;
916
971
  if (!message || !['user', 'assistant'].includes(message.role))
917
972
  continue;
973
+ if (Array.isArray(message.content)) {
974
+ const calls = message.content
975
+ .filter((part) => part.type === 'toolCall')
976
+ .map((part) => String(part.id));
977
+ const later = messages.at(-1);
978
+ if (calls.length && later)
979
+ later.tool_calls = [...calls, ...(later.tool_calls ?? [])];
980
+ else if (calls.length)
981
+ trailing = [...calls, ...trailing];
982
+ }
918
983
  const text = typeof message.content === 'string'
919
984
  ? message.content
920
985
  : message.content
@@ -945,6 +1010,7 @@ export class Store {
945
1010
  attempts: boundedAttempts,
946
1011
  submissions: boundedSubmissions,
947
1012
  messages: messages.reverse(),
1013
+ ...(trailing.length ? { trailing_tool_calls: trailing } : {}),
948
1014
  history_truncated: cursor !== null,
949
1015
  approvals: this.approvals(id),
950
1016
  operations: this.operations(id),