@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
@@ -8,10 +8,12 @@ import {
8
8
  writeFileSync,
9
9
  fsyncSync,
10
10
  realpathSync,
11
+ mkdirSync,
11
12
  } from 'node:fs';
12
13
  import { join } from 'node:path';
13
14
  import type { Store } from './store.js';
14
- import { canonical, digest, ServiceError, object, integer } from './protocol.js';
15
+ import { canonical, digest, ServiceError, object, integer, type Session } from './protocol.js';
16
+ import { forkPiSession } from './pi-sessions.js';
15
17
  import {
16
18
  agentpodBinding,
17
19
  AGENTPOD_LEASE_MS,
@@ -80,10 +82,45 @@ export function readAgentPodPins(configPath: string, state: string): AgentPodPin
80
82
  }
81
83
 
82
84
  /** A local node admission, not an Accounts token issuer or a provider login. */
85
+ /** An agent's standing "Always allow" rule (approval scopes). v1 knows one scope: workspace
86
+ * writes, never deletes, hidden paths, commands, network or anything outside the workspace. */
87
+ export interface ApprovalRule {
88
+ id: string;
89
+ scope: 'workspace-write';
90
+ set_by: string;
91
+ }
92
+ const ruleId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
93
+ /** Closed: 1 to 16 rules, exact keys, a known scope, unique ids. Absent means none. */
94
+ export function approvalRules(value: unknown): ApprovalRule[] {
95
+ if (value === undefined) return [];
96
+ const bad = () => {
97
+ throw new ServiceError('invalid_approval_rules', 'Malformed approval rules', 400);
98
+ };
99
+ if (!Array.isArray(value) || value.length < 1 || value.length > 16) bad();
100
+ const rules = (value as unknown[]).map((r) => {
101
+ if (!r || typeof r !== 'object' || Array.isArray(r)) bad();
102
+ const v = r as Record<string, unknown>;
103
+ if (
104
+ Object.keys(v).sort().join(',') !== 'id,scope,set_by' ||
105
+ typeof v.id !== 'string' ||
106
+ !ruleId.test(v.id) ||
107
+ v.scope !== 'workspace-write' ||
108
+ typeof v.set_by !== 'string' ||
109
+ v.set_by.length < 1 ||
110
+ v.set_by.length > 128 ||
111
+ /[\u0000-\u001f\u007f]/.test(v.set_by)
112
+ )
113
+ bad();
114
+ return { id: v.id as string, scope: 'workspace-write' as const, set_by: v.set_by as string };
115
+ });
116
+ if (new Set(rules.map((r) => r.id)).size !== rules.length) bad();
117
+ return rules.sort((a, b) => a.id.localeCompare(b.id));
118
+ }
83
119
  export class AgentPodAuthority {
84
120
  readonly boot = randomUUID();
85
121
  readonly pins: AgentPodPins;
86
122
  private key: Buffer;
123
+ private readonly stateDir: string;
87
124
  constructor(
88
125
  readonly store: Store,
89
126
  state: string,
@@ -92,6 +129,7 @@ export class AgentPodAuthority {
92
129
  readonly ownedResourcesStopped: (session: string) => boolean = () => true,
93
130
  ) {
94
131
  this.pins = readAgentPodPins(configPath, state);
132
+ this.stateDir = state;
95
133
  const keyPath = join(state, 'agentpod-control-key');
96
134
  try {
97
135
  const fd = openSync(keyPath, 'wx', 0o600);
@@ -112,6 +150,9 @@ export class AgentPodAuthority {
112
150
  }
113
151
  this.key = privateBytes(keyPath);
114
152
  if (this.key.length !== 32) throw Error('Invalid AgentPod control key');
153
+ store.db.exec(
154
+ 'CREATE TABLE IF NOT EXISTS agentpod_approval_rules(admission_id TEXT NOT NULL,rule_id TEXT NOT NULL,scope TEXT NOT NULL,set_by TEXT NOT NULL,revoked_at TEXT,PRIMARY KEY(admission_id,rule_id))',
155
+ );
115
156
  store.agentpodAuthorityCheck = (session) => this.assert(this.bySession(session));
116
157
  store.requireAgentPodAuthority = true;
117
158
  }
@@ -158,6 +199,19 @@ export class AgentPodAuthority {
158
199
  );
159
200
  return remaining;
160
201
  }
202
+ /** The session's pinned approval rules that no renewal has revoked. */
203
+ approvalRules(session: string): ApprovalRule[] {
204
+ return this.store.db
205
+ .prepare(
206
+ 'SELECT r.rule_id AS id,r.scope,r.set_by FROM agentpod_approval_rules r JOIN agentpod_admissions a ON a.id=r.admission_id WHERE a.session_id=? AND r.revoked_at IS NULL ORDER BY r.rule_id',
207
+ )
208
+ .all(session)
209
+ .map((r) => ({
210
+ id: String(r.id),
211
+ scope: 'workspace-write' as const,
212
+ set_by: String(r.set_by),
213
+ }));
214
+ }
161
215
  private checked(input: unknown): AgentPodBinding {
162
216
  const b = agentpodBinding(input);
163
217
  for (const k of Object.keys(this.pins) as (keyof AgentPodPins)[])
@@ -247,12 +301,30 @@ export class AgentPodAuthority {
247
301
  this.assert(row);
248
302
  return { session_id: row.session_id, binding: JSON.parse(row.binding) };
249
303
  }
250
- admit(input: unknown, request: string) {
304
+ /**
305
+ * `continueFrom` names an earlier interactive admission (its hub session id) whose conversation
306
+ * this new admission carries on. The earlier execution stays revoked: the new admission gets a
307
+ * NEW session, its own lease and its own authority, seeded with a branch of the old Pi
308
+ * transcript and its entries. Only a revoked, stopped and reconciled interactive admission for
309
+ * the same organization, station, occupant and workspace can be continued, and only once.
310
+ */
311
+ admit(input: unknown, request: string, continueFrom?: string, rulesInput?: unknown) {
251
312
  const b = this.checked(input);
313
+ const rules = approvalRules(rulesInput);
314
+ if (
315
+ continueFrom !== undefined &&
316
+ (typeof continueFrom !== 'string' || !continueFrom || continueFrom.length > 128)
317
+ )
318
+ throw new ServiceError('invalid_continuation', 'continue_from must be a hub session id', 400);
252
319
  const admitted = this.store.request(
253
320
  'agentpod-node',
254
321
  request,
255
- { kind: 'admit', binding: b },
322
+ {
323
+ kind: 'admit',
324
+ binding: b,
325
+ ...(continueFrom === undefined ? {} : { continue_from: continueFrom }),
326
+ ...(rules.length ? { approval_rules: rules } : {}),
327
+ },
256
328
  () => {
257
329
  const old = this.store.db
258
330
  .prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
@@ -303,17 +375,56 @@ export class AgentPodAuthority {
303
375
  'INSERT INTO agentpod_station_epochs VALUES (?,?,?) ON CONFLICT(station_id) DO UPDATE SET occupancy_epoch=excluded.occupancy_epoch,principal_id=excluded.principal_id',
304
376
  )
305
377
  .run(b.station_id, b.occupancy_epoch, b.principal_id);
378
+ const prior = continueFrom === undefined ? undefined : this.continuable(b, continueFrom);
306
379
  const session = randomUUID(),
307
380
  id = randomUUID();
308
- this.store.db
309
- .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
310
- .run(session, b.workspace, session);
311
- this.store.event(
312
- session,
313
- 'session.created',
314
- { cwd: b.workspace, authority: 'agentpod-v1' },
315
- request,
316
- );
381
+ if (prior) {
382
+ const source = this.store.session(prior.session_id);
383
+ const file = source.pi_session_file
384
+ ? (() => {
385
+ const dir = join(this.stateDir, 'workers', session, 'sessions');
386
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
387
+ return forkPiSession(source.pi_session_file, source.cwd, dir, session);
388
+ })()
389
+ : null;
390
+ this.store.db
391
+ .prepare(
392
+ 'INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES (?,?,?,?,?,?)',
393
+ )
394
+ .run(session, b.workspace, session, file, source.pi_cursor, source.pi_leaf);
395
+ this.store.event(
396
+ session,
397
+ 'session.created',
398
+ { cwd: b.workspace, authority: 'agentpod-v1', forked_from: prior.session_id },
399
+ request,
400
+ );
401
+ this.store.db
402
+ .prepare(
403
+ 'INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?',
404
+ )
405
+ .run(session, prior.session_id);
406
+ this.store.event(
407
+ session,
408
+ 'session.forked',
409
+ {
410
+ parent: prior.session_id,
411
+ parent_revision: source.revision,
412
+ leaf: source.pi_leaf,
413
+ cursor: source.pi_cursor,
414
+ },
415
+ request,
416
+ );
417
+ } else {
418
+ this.store.db
419
+ .prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
420
+ .run(session, b.workspace, session);
421
+ this.store.event(
422
+ session,
423
+ 'session.created',
424
+ { cwd: b.workspace, authority: 'agentpod-v1' },
425
+ request,
426
+ );
427
+ }
317
428
  this.store.db
318
429
  .prepare(
319
430
  'INSERT INTO agentpod_admissions(id,session_id,hub_session_id,run_key,binding,digest,pins_digest,expires_at) VALUES (?,?,?,?,?,?,?,?)',
@@ -328,10 +439,20 @@ export class AgentPodAuthority {
328
439
  digest(this.pins),
329
440
  Date.now() + AGENTPOD_LEASE_MS,
330
441
  );
442
+ // The agent's standing rules, pinned for this run; a renewal may add or revoke one.
443
+ for (const r of rules)
444
+ this.store.db
445
+ .prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
446
+ .run(id, r.id, r.scope, r.set_by);
331
447
  this.store.event(
332
448
  session,
333
449
  'agentpod.admitted',
334
- { admission_id: id, binding_digest: digest(b), binding: b },
450
+ {
451
+ admission_id: id,
452
+ binding_digest: digest(b),
453
+ binding: b,
454
+ ...(prior ? { continued_from: continueFrom, continued_admission: prior.id } : {}),
455
+ },
335
456
  request,
336
457
  );
337
458
  return { admission_id: id };
@@ -340,13 +461,14 @@ export class AgentPodAuthority {
340
461
  const row = this.row(admitted.admission_id);
341
462
  return { ...this.status(row), token: this.token(row.id) };
342
463
  }
343
- renew(input: unknown, request: string, sequence: number) {
464
+ renew(input: unknown, request: string, sequence: number, rulesInput?: unknown) {
344
465
  const b = this.checked(input);
345
466
  integer(sequence, 'lease_sequence');
467
+ const rules = approvalRules(rulesInput);
346
468
  return this.store.request(
347
469
  'agentpod-node',
348
470
  request,
349
- { kind: 'renew', binding: b, sequence },
471
+ { kind: 'renew', binding: b, sequence, ...(rules.length ? { approval_rules: rules } : {}) },
350
472
  () => {
351
473
  const row = this.bound(b);
352
474
  if (
@@ -375,6 +497,44 @@ export class AgentPodAuthority {
375
497
  performance.now() + AGENTPOD_LEASE_MS,
376
498
  row.id,
377
499
  );
500
+ // Revocation is immediate: a pinned rule the hub no longer lists ends now, for good.
501
+ const listed = new Set(rules.map((r) => r.id));
502
+ for (const pinned of this.store.db
503
+ .prepare(
504
+ 'SELECT rule_id FROM agentpod_approval_rules WHERE admission_id=? AND revoked_at IS NULL',
505
+ )
506
+ .all(row.id)) {
507
+ if (listed.has(String(pinned.rule_id))) continue;
508
+ this.store.db
509
+ .prepare(
510
+ 'UPDATE agentpod_approval_rules SET revoked_at=? WHERE admission_id=? AND rule_id=?',
511
+ )
512
+ .run(new Date().toISOString(), row.id, String(pinned.rule_id));
513
+ this.store.event(
514
+ row.session_id,
515
+ 'approval_rule.revoked',
516
+ { admission_id: row.id, rule_id: pinned.rule_id },
517
+ request,
518
+ );
519
+ }
520
+ // A rule the hub newly lists joins the run now (operator ruling 2026-10-28: a person who
521
+ // chose "Always allow" mid-run is not asked again). One this run already holds, or held
522
+ // and lost to revocation, is left as it is: revocation stays final for the run.
523
+ for (const r of rules) {
524
+ const known = this.store.db
525
+ .prepare('SELECT 1 FROM agentpod_approval_rules WHERE admission_id=? AND rule_id=?')
526
+ .get(row.id, r.id);
527
+ if (known) continue;
528
+ this.store.db
529
+ .prepare('INSERT INTO agentpod_approval_rules VALUES (?,?,?,?,NULL)')
530
+ .run(row.id, r.id, r.scope, r.set_by);
531
+ this.store.event(
532
+ row.session_id,
533
+ 'approval_rule.added',
534
+ { admission_id: row.id, rule_id: r.id, scope: r.scope, set_by: r.set_by },
535
+ request,
536
+ );
537
+ }
378
538
  this.store.event(
379
539
  row.session_id,
380
540
  'agentpod.renewed',
@@ -385,6 +545,59 @@ export class AgentPodAuthority {
385
545
  },
386
546
  );
387
547
  }
548
+ /** The earlier admission a new interactive admission may continue, or a refusal. */
549
+ private continuable(b: AgentPodBinding, continueFrom: string): Admission {
550
+ if (b.run.source !== 'agentpod')
551
+ throw new ServiceError(
552
+ 'continuation_refused',
553
+ 'Only an interactive admission can continue an earlier conversation',
554
+ );
555
+ const row = this.store.db
556
+ .prepare('SELECT id FROM agentpod_admissions WHERE hub_session_id=?')
557
+ .get(continueFrom);
558
+ if (!row)
559
+ throw new ServiceError(
560
+ 'admission_not_found',
561
+ 'The session to continue has no admission',
562
+ 404,
563
+ );
564
+ const prior = this.row(String(row.id));
565
+ const before = JSON.parse(prior.binding) as AgentPodBinding;
566
+ if (
567
+ prior.binding !== canonical(before) ||
568
+ prior.digest !== digest(before) ||
569
+ before.run.source !== 'agentpod' ||
570
+ before.organization_id !== b.organization_id ||
571
+ before.station_id !== b.station_id ||
572
+ before.principal_id !== b.principal_id ||
573
+ before.workspace !== b.workspace ||
574
+ before.hub_session_id === b.hub_session_id
575
+ )
576
+ throw new ServiceError(
577
+ 'continuation_refused',
578
+ 'Only the same station, occupant and workspace can continue this conversation',
579
+ );
580
+ const status = this.status(prior);
581
+ if (
582
+ prior.state !== 'revoked' ||
583
+ !status.revoked ||
584
+ !status.owned_execution_stopped ||
585
+ status.unresolved_operations.length
586
+ )
587
+ throw new ServiceError(
588
+ 'execution_unconfirmed',
589
+ 'The conversation to continue must be revoked, stopped and reconciled first',
590
+ );
591
+ if (
592
+ this.store.db
593
+ .prepare(
594
+ "SELECT 1 FROM events WHERE kind='agentpod.admitted' AND json_extract(payload,'$.continued_admission')=? LIMIT 1",
595
+ )
596
+ .get(prior.id)
597
+ )
598
+ throw new ServiceError('already_continued', 'This conversation was already continued');
599
+ return prior;
600
+ }
388
601
  revoke(input: unknown, request: string, reason: string) {
389
602
  const b = this.checked(input);
390
603
  this.store.request('agentpod-node', request, { kind: 'revoke', binding: b, reason }, () => {
@@ -482,9 +695,15 @@ export class AgentPodAuthority {
482
695
  this.assert(row);
483
696
  authorized = true;
484
697
  } catch {}
698
+ const continued = this.store.db
699
+ .prepare(
700
+ "SELECT json_extract(payload,'$.continued_from') AS source FROM events WHERE session_id=? AND kind='agentpod.admitted' LIMIT 1",
701
+ )
702
+ .get(row.session_id) as { source: string | null } | undefined;
485
703
  return {
486
704
  admission_id: row.id,
487
705
  session_id: row.session_id,
706
+ continued_from: continued?.source ?? null,
488
707
  binding: JSON.parse(row.binding) as AgentPodBinding,
489
708
  binding_digest: row.digest,
490
709
  lease_sequence: row.lease_sequence,
@@ -0,0 +1,16 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * The AgentPod pins a node operator drops for this user, used when
6
+ * `service start` names no --agentpod-config. Without it a daemon an AgentPod
7
+ * node expects to bind starts unbound and the node never detects a station.
8
+ */
9
+ export function defaultAgentPodConfig(
10
+ env: NodeJS.ProcessEnv,
11
+ exists: (path: string) => boolean,
12
+ ): string | undefined {
13
+ const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
14
+ const path = join(base, 'salaam', 'agentpod-pins.json');
15
+ return exists(path) ? path : undefined;
16
+ }
@@ -12,7 +12,27 @@ export interface WorkDecision {
12
12
  approval_revision: number;
13
13
  expires_at: number;
14
14
  question: string;
15
- options: [{ id: 'approve'; title: string }, { id: 'deny'; title: string }];
15
+ /** Readable approvals: what is being approved, as structured fields. */
16
+ detail?: WorkDecisionDetail;
17
+ /** approve, [approve-run], [always], deny, in that order. "Always allow" (operator ruling
18
+ * 2026-10-28) is a workspace owner's or admin's choice only: `requires` says so to the board, and
19
+ * the hub checks the answerer's role and sets the standing rule before it forwards the answer. */
20
+ options: WorkDecisionOption[];
21
+ }
22
+ export type WorkDecisionOption =
23
+ | { id: 'approve' | 'approve-run' | 'deny'; title: string }
24
+ | { id: 'always'; title: string; requires: 'workspace-admin' };
25
+ export interface WorkDecisionDetail {
26
+ version: 1;
27
+ summary: string;
28
+ reason: string | null;
29
+ files: {
30
+ path: string;
31
+ change: 'create' | 'edit' | 'delete';
32
+ bytes: number | null;
33
+ preview: string | null;
34
+ }[];
35
+ digest: string;
16
36
  }
17
37
  export interface WorkDecisionAnswer {
18
38
  version: 1;
@@ -20,7 +40,7 @@ export interface WorkDecisionAnswer {
20
40
  decision: WorkDecision;
21
41
  decision_digest: string;
22
42
  elicitation_id: string;
23
- option_id: 'approve' | 'deny';
43
+ option_id: 'approve' | 'approve-run' | 'always' | 'deny';
24
44
  answered_by: string;
25
45
  answered_at: string;
26
46
  }
@@ -57,7 +77,9 @@ export function workDecision(value: unknown): WorkDecision {
57
77
  'expires_at',
58
78
  'question',
59
79
  'options',
80
+ ...('detail' in v ? ['detail'] : []),
60
81
  ]);
82
+ if ('detail' in v) workDecisionDetail(v.detail);
61
83
  if (
62
84
  v.version !== 1 ||
63
85
  !['request_id', 'work_request_id', 'task_id', 'operation_id', 'approval_id'].every((k) =>
@@ -71,13 +93,25 @@ export function workDecision(value: unknown): WorkDecision {
71
93
  ) ||
72
94
  !text(v.question, 8192) ||
73
95
  !Array.isArray(v.options) ||
74
- v.options.length !== 2
96
+ ![2, 3, 4].includes(v.options.length)
97
+ )
98
+ fail();
99
+ // approve, [approve-run], [always], deny. Nothing else, in that order.
100
+ const ids = v.options.map((o) => String(object(o).id));
101
+ if (
102
+ ![
103
+ 'approve,deny',
104
+ 'approve,approve-run,deny',
105
+ 'approve,always,deny',
106
+ 'approve,approve-run,always,deny',
107
+ ].includes(ids.join(','))
75
108
  )
76
109
  fail();
77
- for (let i = 0; i < 2; i++) {
78
- const opt = object(v.options[i]);
79
- keys(opt, ['id', 'title']);
80
- if (opt.id !== ['approve', 'deny'][i] || !text(opt.title, 256)) fail();
110
+ for (const value of v.options) {
111
+ const opt = object(value);
112
+ keys(opt, opt.id === 'always' ? ['id', 'title', 'requires'] : ['id', 'title']);
113
+ if (!text(opt.title, 256) || (opt.id === 'always' && opt.requires !== 'workspace-admin'))
114
+ fail();
81
115
  }
82
116
  return v as unknown as WorkDecision;
83
117
  }
@@ -101,7 +135,7 @@ export function workDecisionAnswer(value: unknown): WorkDecisionAnswer {
101
135
  !/^[a-f0-9]{64}$/.test(v.decision_digest) ||
102
136
  !text(v.elicitation_id, 128) ||
103
137
  !/^elc_[A-Za-z0-9]{6,}$/.test(v.elicitation_id) ||
104
- !['approve', 'deny'].includes(String(v.option_id)) ||
138
+ !['approve', 'approve-run', 'always', 'deny'].includes(String(v.option_id)) ||
105
139
  !text(v.answered_by, 128) ||
106
140
  typeof v.answered_at !== 'string' ||
107
141
  !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?Z$/.test(v.answered_at) ||
@@ -111,3 +145,30 @@ export function workDecisionAnswer(value: unknown): WorkDecisionAnswer {
111
145
  fail();
112
146
  return v as unknown as WorkDecisionAnswer;
113
147
  }
148
+
149
+ function workDecisionDetail(value: unknown): WorkDecisionDetail {
150
+ const d = object(value);
151
+ keys(d, ['version', 'summary', 'reason', 'files', 'digest']);
152
+ if (
153
+ d.version !== 1 ||
154
+ !text(d.summary, 200) ||
155
+ (d.reason !== null && !text(d.reason, 300)) ||
156
+ typeof d.digest !== 'string' ||
157
+ !/^[a-f0-9]{64}$/.test(d.digest) ||
158
+ !Array.isArray(d.files) ||
159
+ d.files.length > 16
160
+ )
161
+ fail();
162
+ for (const f of d.files as unknown[]) {
163
+ const file = object(f);
164
+ keys(file, ['path', 'change', 'bytes', 'preview']);
165
+ if (
166
+ !text(file.path, 512) ||
167
+ !['create', 'edit', 'delete'].includes(String(file.change)) ||
168
+ (file.bytes !== null && (!Number.isSafeInteger(file.bytes) || Number(file.bytes) < 0)) ||
169
+ (file.preview !== null && (typeof file.preview !== 'string' || file.preview.length > 1200))
170
+ )
171
+ fail();
172
+ }
173
+ return d as unknown as WorkDecisionDetail;
174
+ }
@@ -2,14 +2,43 @@ import { randomUUID } from 'node:crypto';
2
2
  import type { AgentPodWork } from './agentpod-work.js';
3
3
  import type { ManagedWork } from './managed.js';
4
4
  import { canonical, digest, ServiceError } from './protocol.js';
5
- import { workDecisionAnswer, type WorkDecision } from './agentpod-work-decision-protocol.js';
5
+ import { type RunGrants, runGrantCovers, runGrantPrefix } from './run-grants.js';
6
+ import {
7
+ workDecisionAnswer,
8
+ type WorkDecision,
9
+ type WorkDecisionDetail,
10
+ } from './agentpod-work-decision-protocol.js';
6
11
  /** Private authenticated Hub control only. Answers choose an existing approval;
7
12
  * they never execute an operation or supply a second effect grant. */
8
13
  export class AgentPodWorkDecisions {
9
14
  constructor(
10
15
  readonly work: AgentPodWork,
11
16
  readonly managed: ManagedWork,
17
+ readonly grants?: RunGrants,
12
18
  ) {}
19
+ /** The run directory a grant for this operation would cover, when it is a covered write. */
20
+ private grantable(binding: unknown, op: { kind: string; value: string }): string | null {
21
+ if (!this.grants || op.kind !== 'workspace') return null;
22
+ const run = (binding as { run?: { id?: unknown } } | null)?.run;
23
+ const prefix = runGrantPrefix(typeof run?.id === 'string' ? run.id : '');
24
+ let args: Record<string, unknown> = {};
25
+ try {
26
+ args = JSON.parse(op.value) as Record<string, unknown>;
27
+ } catch {
28
+ return null;
29
+ }
30
+ return prefix && runGrantCovers(prefix, args) ? prefix : null;
31
+ }
32
+ /** "Always allow" covers exactly what the standing workspace-write rule answers: a workspace
33
+ * write anywhere in the workspace, never a delete or a hidden path. */
34
+ private alwaysAllowable(op: { kind: string; value: string }): boolean {
35
+ if (op.kind !== 'workspace') return false;
36
+ try {
37
+ return runGrantCovers('', JSON.parse(op.value) as Record<string, unknown>);
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
13
42
  private owner(binding: unknown) {
14
43
  const root = this.work.authority.inspect(binding);
15
44
  const row = this.work.store.db
@@ -51,10 +80,23 @@ export class AgentPodWorkDecisions {
51
80
  version: 1,
52
81
  request_id: randomUUID(),
53
82
  ...identity,
54
- question: 'Approve this exact operation? ' + op.kind + ' ' + op.action_key,
83
+ question: approvalDetail(op).summary + '?',
84
+ detail: approvalDetail(op),
55
85
  options: [
56
- { id: 'approve', title: 'Approve once' },
57
- { id: 'deny', title: 'Deny' },
86
+ { id: 'approve' as const, title: 'Approve once' },
87
+ ...(this.grantable(root.binding, op)
88
+ ? [{ id: 'approve-run' as const, title: 'Approve for this run' }]
89
+ : []),
90
+ ...(this.alwaysAllowable(op)
91
+ ? [
92
+ {
93
+ id: 'always' as const,
94
+ title: 'Always allow this agent to write in its workspace',
95
+ requires: 'workspace-admin' as const,
96
+ },
97
+ ]
98
+ : []),
99
+ { id: 'deny' as const, title: 'Deny' },
58
100
  ],
59
101
  }),
60
102
  );
@@ -97,13 +139,45 @@ export class AgentPodWorkDecisions {
97
139
  approval.expires_at !== offered.expires_at
98
140
  )
99
141
  throw new ServiceError('approval_mismatch', 'Approval ownership changed');
142
+ // "Approve for this run" must have been offered, and still be grantable, to be chosen.
143
+ const prefix =
144
+ answer.option_id === 'approve-run'
145
+ ? this.grantable(root.binding, this.managed.operation(offered.operation_id))
146
+ : null;
147
+ if (
148
+ answer.option_id === 'approve-run' &&
149
+ (!prefix || !offered.options.some((o) => o.id === 'approve-run'))
150
+ )
151
+ throw new ServiceError('approval_mismatch', 'This approval cannot cover the run');
152
+ // "Always allow" approves this write; the standing rule is the hub's, set before it forwards.
153
+ if (
154
+ answer.option_id === 'always' &&
155
+ (!offered.options.some((o) => o.id === 'always') ||
156
+ !this.alwaysAllowable(this.managed.operation(offered.operation_id)))
157
+ )
158
+ throw new ServiceError(
159
+ 'approval_mismatch',
160
+ 'Always allow was not offered for this write',
161
+ );
100
162
  this.managed.decide(
101
163
  offered.approval_id,
102
164
  answer.request_id,
103
165
  offered.approval_revision,
104
166
  offered.operation_digest,
105
- answer.option_id,
167
+ answer.option_id === 'deny' ? 'deny' : 'approve',
106
168
  );
169
+ const grant =
170
+ answer.option_id === 'approve-run'
171
+ ? this.grants!.createInTransaction({
172
+ session_id: root.session_id,
173
+ task_id: task.id,
174
+ plan_revision: offered.plan_revision,
175
+ binding_digest: offered.binding_digest,
176
+ path_prefix: prefix!,
177
+ approval_id: offered.approval_id,
178
+ granted_by: answer.answered_by,
179
+ })
180
+ : null;
107
181
  this.work.store.execution.assert(root.session_id);
108
182
  return {
109
183
  version: 1 as const,
@@ -111,8 +185,52 @@ export class AgentPodWorkDecisions {
111
185
  decision_digest: answer.decision_digest,
112
186
  approval_id: offered.approval_id,
113
187
  decision: answer.option_id,
188
+ ...(grant ? { grant_id: grant.id } : {}),
114
189
  };
115
190
  },
116
191
  );
117
192
  }
118
193
  }
194
+
195
+ /** Readable approvals: what a person approves, from the operation's own canonical arguments only.
196
+ * A workspace write names its file, the change, its size, a short preview and the agent's one-line
197
+ * reason; anything else names its kind and target. The digest still binds the exact operation. */
198
+ export function approvalDetail(op: {
199
+ kind: string;
200
+ action_key: string;
201
+ target: string;
202
+ value: string;
203
+ operation_digest: string;
204
+ }): WorkDecisionDetail {
205
+ const base = { version: 1 as const, reason: null, files: [], digest: op.operation_digest };
206
+ if (op.kind === 'review') return { ...base, summary: 'Accept this step for review' };
207
+ if (op.kind !== 'workspace')
208
+ return { ...base, summary: ('Run ' + op.kind + ' ' + op.target).slice(0, 200) };
209
+ let args: Record<string, unknown> = {};
210
+ try {
211
+ args = JSON.parse(op.value) as Record<string, unknown>;
212
+ } catch {
213
+ /* not a patch: the generic form below */
214
+ }
215
+ const path = typeof args.path === 'string' ? args.path : op.target.replace(/^station:/, '');
216
+ const content = typeof args.content === 'string' ? args.content : null;
217
+ const change = args.content === null ? 'delete' : args.expected_hash === null ? 'create' : 'edit';
218
+ const verb = { create: 'Create', edit: 'Edit', delete: 'Delete' }[change];
219
+ const reason =
220
+ typeof args.reason === 'string' && args.reason.trim()
221
+ ? args.reason.replace(/\s+/g, ' ').trim().slice(0, 300)
222
+ : null;
223
+ return {
224
+ ...base,
225
+ summary: (verb + ' ' + path + ' in the workspace').slice(0, 200),
226
+ reason,
227
+ files: [
228
+ {
229
+ path: path.slice(0, 512),
230
+ change,
231
+ bytes: content === null ? null : Buffer.byteLength(content),
232
+ preview: content === null ? null : content.slice(0, 1200),
233
+ },
234
+ ],
235
+ };
236
+ }