@superjackfruitlabs/salaam 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/src/acp-desk.js +140 -0
  3. package/dist/src/acp-outcome.js +23 -19
  4. package/dist/src/acp.js +258 -11
  5. package/dist/src/approval-rules.js +287 -0
  6. package/dist/src/attach.js +3 -1
  7. package/dist/src/autonomy.js +80 -9
  8. package/dist/src/cli-help.js +34 -4
  9. package/dist/src/cli.js +55 -3
  10. package/dist/src/command-danger.js +797 -0
  11. package/dist/src/command-sandbox.js +116 -6
  12. package/dist/src/custom-commands.js +159 -0
  13. package/dist/src/danger-classifier.js +286 -0
  14. package/dist/src/git-helpers.js +160 -0
  15. package/dist/src/hooks.js +351 -0
  16. package/dist/src/host.js +96 -4
  17. package/dist/src/house-extension.js +133 -7
  18. package/dist/src/local-coding.js +553 -58
  19. package/dist/src/local-edits.js +168 -6
  20. package/dist/src/mentions.js +277 -0
  21. package/dist/src/pi-sessions.js +5 -1
  22. package/dist/src/project-instructions.js +195 -0
  23. package/dist/src/protocol.js +4 -1
  24. package/dist/src/service.js +342 -5
  25. package/dist/src/session-pick.js +77 -0
  26. package/dist/src/shell-passthrough.js +39 -0
  27. package/dist/src/store.js +51 -4
  28. package/dist/src/supervisor.js +233 -22
  29. package/dist/src/task-context.js +8 -2
  30. package/dist/src/terminal-status.js +88 -0
  31. package/dist/src/tui-activity.js +99 -0
  32. package/dist/src/tui-classifier.js +25 -0
  33. package/dist/src/tui-client.js +186 -5
  34. package/dist/src/tui-commands.js +23 -5
  35. package/dist/src/tui-composer.js +113 -0
  36. package/dist/src/tui-keymap.js +116 -0
  37. package/dist/src/tui-mentions.js +38 -0
  38. package/dist/src/tui-modals.js +157 -34
  39. package/dist/src/tui-notify.js +62 -0
  40. package/dist/src/tui-review.js +55 -0
  41. package/dist/src/tui-turn-keys.js +52 -0
  42. package/dist/src/tui-view.js +243 -69
  43. package/dist/src/tui.js +875 -118
  44. package/dist/src/turn-checkpoints.js +221 -0
  45. package/dist/src/turn-rewind.js +168 -0
  46. package/dist/src/web-fetch.js +114 -0
  47. package/dist/src/web-guard.js +163 -0
  48. package/dist/src/web-search.js +302 -0
  49. package/npm-shrinkwrap.json +2 -2
  50. package/package.json +1 -1
  51. package/src/acp-desk.ts +155 -0
  52. package/src/acp-outcome.ts +25 -21
  53. package/src/acp.ts +338 -28
  54. package/src/approval-rules.ts +360 -0
  55. package/src/attach.ts +4 -1
  56. package/src/autonomy.ts +111 -10
  57. package/src/cli-help.ts +33 -4
  58. package/src/cli.ts +66 -2
  59. package/src/command-danger.ts +886 -0
  60. package/src/command-sandbox.ts +128 -8
  61. package/src/custom-commands.ts +175 -0
  62. package/src/danger-classifier.ts +321 -0
  63. package/src/git-helpers.ts +177 -0
  64. package/src/hooks.ts +425 -0
  65. package/src/host.ts +122 -1
  66. package/src/house-extension.ts +142 -7
  67. package/src/local-coding.ts +674 -75
  68. package/src/local-edits.ts +191 -8
  69. package/src/mentions.ts +304 -0
  70. package/src/pi-sessions.ts +9 -1
  71. package/src/project-instructions.ts +229 -0
  72. package/src/protocol.ts +19 -2
  73. package/src/service.ts +488 -5
  74. package/src/session-pick.ts +102 -0
  75. package/src/shell-passthrough.ts +38 -0
  76. package/src/store.ts +52 -3
  77. package/src/supervisor.ts +262 -24
  78. package/src/task-context.ts +9 -2
  79. package/src/terminal-status.ts +103 -0
  80. package/src/tui-activity.ts +133 -0
  81. package/src/tui-classifier.ts +38 -0
  82. package/src/tui-client.ts +239 -8
  83. package/src/tui-commands.ts +49 -10
  84. package/src/tui-composer.ts +124 -0
  85. package/src/tui-keymap.ts +152 -0
  86. package/src/tui-mentions.ts +43 -0
  87. package/src/tui-modals.ts +173 -43
  88. package/src/tui-notify.ts +77 -0
  89. package/src/tui-review.ts +76 -0
  90. package/src/tui-turn-keys.ts +77 -0
  91. package/src/tui-view.ts +341 -88
  92. package/src/tui.ts +1047 -137
  93. package/src/turn-checkpoints.ts +253 -0
  94. package/src/turn-rewind.ts +258 -0
  95. package/src/web-fetch.ts +140 -0
  96. package/src/web-guard.ts +195 -0
  97. package/src/web-search.ts +402 -0
package/dist/src/store.js CHANGED
@@ -461,8 +461,11 @@ export class Store {
461
461
  throw new ServiceError('not_found', 'Session not found', 404);
462
462
  return { ...row, capabilities: JSON.parse(row.capabilities) };
463
463
  }
464
- forkLocal(id, request, expected, controller, clone) {
465
- 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, () => {
466
469
  this.authorizeControl(id, controller);
467
470
  const source = this.session(id);
468
471
  if (source.revision !== expected)
@@ -478,7 +481,7 @@ export class Store {
478
481
  const file = clone(source, target);
479
482
  this.db
480
483
  .prepare('INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES(?,?,?,?,?,?)')
481
- .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);
482
485
  this.event(target, 'session.created', {
483
486
  cwd: source.cwd,
484
487
  local_workspace: true,
@@ -493,7 +496,13 @@ export class Store {
493
496
  this.db
494
497
  .prepare('INSERT INTO pi_entries SELECT ?,entry_id,payload FROM pi_entries WHERE session_id=?')
495
498
  .run(target, id);
496
- 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);
497
506
  return this.session(target);
498
507
  });
499
508
  }
@@ -521,6 +530,13 @@ export class Store {
521
530
  lastActivityAny() {
522
531
  return Math.max(this.openedAt, ...this.activity.values());
523
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
+ }
524
540
  sessions() {
525
541
  return this.db.prepare('SELECT id FROM sessions ORDER BY rowid').all().map((x) => this.session(x.id));
526
542
  }
@@ -573,6 +589,23 @@ export class Store {
573
589
  }
574
590
  });
575
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
+ }
576
609
  providerRoute(id) {
577
610
  const row = this.db.prepare('SELECT route FROM session_routes WHERE session_id=?').get(id);
578
611
  return row ? JSON.parse(row.route) : null;
@@ -920,6 +953,9 @@ export class Store {
920
953
  const lookup = this.db.prepare('SELECT payload FROM pi_entries WHERE session_id=? AND entry_id=?');
921
954
  let remaining = 256 * 1024;
922
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 = [];
923
959
  while (cursor &&
924
960
  !seen.has(cursor) &&
925
961
  seen.size < 10000 &&
@@ -934,6 +970,16 @@ export class Store {
934
970
  const message = entry.message;
935
971
  if (!message || !['user', 'assistant'].includes(message.role))
936
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
+ }
937
983
  const text = typeof message.content === 'string'
938
984
  ? message.content
939
985
  : message.content
@@ -964,6 +1010,7 @@ export class Store {
964
1010
  attempts: boundedAttempts,
965
1011
  submissions: boundedSubmissions,
966
1012
  messages: messages.reverse(),
1013
+ ...(trailing.length ? { trailing_tool_calls: trailing } : {}),
967
1014
  history_truncated: cursor !== null,
968
1015
  approvals: this.approvals(id),
969
1016
  operations: this.operations(id),
@@ -1,4 +1,5 @@
1
1
  import { BoardReads } from './board-reads.js';
2
+ import { takeImages } from './mentions.js';
2
3
  import { HostBreaker, callThroughBreaker } from './host-breaker.js';
3
4
  import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
5
  import { execFileSync } from 'node:child_process';
@@ -19,6 +20,28 @@ import { digest } from './protocol.js';
19
20
  import { readLocalWorkspace } from './local-workspace.js';
20
21
  import { repositoryPath } from './repository-snapshots.js';
21
22
  import { acpFailure, credentialStrings, parseAcpFailure, providerOutcome, redactFailureText, toolMetadata, } from './acp-outcome.js';
23
+ /** Errors a local tool call returns to the model as feedback; any other one fails the worker.
24
+ * Every code src/local-edits.ts can throw belongs here (a failed replacement is not a crash). */
25
+ export const LOCAL_TOOL_ERRORS = [
26
+ 'local_busy',
27
+ 'workspace_read_failed',
28
+ 'unsafe_path',
29
+ 'edit_mismatch',
30
+ 'workspace_changed',
31
+ 'workspace_busy',
32
+ 'workspace_limit',
33
+ 'workspace_conflict',
34
+ 'not_found',
35
+ 'binary_file',
36
+ 'line_limit',
37
+ 'invalid_field',
38
+ 'request_conflict',
39
+ 'command_conflict',
40
+ 'command_limit',
41
+ 'invalid_cursor',
42
+ 'shell_binding',
43
+ 'listen_unreachable',
44
+ ];
22
45
  export function processIdentity(pid) {
23
46
  if (!Number.isSafeInteger(pid) || pid < 1)
24
47
  return null;
@@ -72,6 +95,35 @@ export function processIdentity(pid) {
72
95
  return null;
73
96
  }
74
97
  }
98
+ /**
99
+ * When a process started, keyed to this boot. Unlike processIdentity it survives exec, so it
100
+ * names a background task whose gated shell has since become the user's command.
101
+ */
102
+ export function processStart(pid) {
103
+ if (!Number.isSafeInteger(pid) || pid < 1)
104
+ return null;
105
+ try {
106
+ if (process.platform === 'linux') {
107
+ const value = readFileSync(`/proc/${pid}/stat`, 'utf8');
108
+ const fields = value
109
+ .slice(value.lastIndexOf(') ') + 2)
110
+ .trim()
111
+ .split(/\s+/);
112
+ const boot = readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
113
+ return /^\d+$/.test(fields[19] ?? '') ? `linux-start-v1:${pid}:${boot}:${fields[19]}` : null;
114
+ }
115
+ const started = execFileSync('/bin/ps', ['-p', String(pid), '-o', 'lstart='], {
116
+ encoding: 'utf8',
117
+ stdio: ['ignore', 'pipe', 'ignore'],
118
+ timeout: 2000,
119
+ killSignal: 'SIGKILL',
120
+ }).trim();
121
+ return started ? `ps-start-v1:${pid}:${started}` : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
75
127
  function groupExists(pid) {
76
128
  try {
77
129
  process.kill(-pid, 0);
@@ -87,6 +139,10 @@ export class Supervisor extends EventEmitter {
87
139
  store;
88
140
  options;
89
141
  localCoding;
142
+ /** Built-in web search and fetch for interactive coding (operator ruling 2026-10-31). */
143
+ web;
144
+ /** Desk-session hooks (Phase 4b); absent means none run. */
145
+ hooks;
90
146
  tasks;
91
147
  compactionJobs = new Set();
92
148
  fetchBreaker = new HostBreaker();
@@ -429,6 +485,16 @@ export class Supervisor extends EventEmitter {
429
485
  worker.pi.on('activity', (observation) => {
430
486
  if (!worker.attempt || worker.stopping)
431
487
  return;
488
+ // PostToolUse runs after the tool and cannot change it; it is recorded like any hook.
489
+ if (observation.phase === 'finished' && this.hooks) {
490
+ const input = parsedInput(observation.input);
491
+ void this.hook(id, 'PostToolUse', {
492
+ tool_name: observation.tool,
493
+ tool_input: input,
494
+ tool_use_id: observation.tool_call_id,
495
+ tool_response: { output: observation.output ?? '', failed: !!observation.failed },
496
+ }, [observation.tool, ...hookAliases(observation.tool, input)]).catch(() => { });
497
+ }
432
498
  try {
433
499
  this.store.request(`tool-activity:${id}:${epoch}:${worker.attempt}`, `${observation.phase}:${observation.tool_call_id}`, observation, () => {
434
500
  this.store.event(id, `tool.${observation.phase}`, {
@@ -605,6 +671,28 @@ export class Supervisor extends EventEmitter {
605
671
  return w;
606
672
  }
607
673
  /** Is this attempt a task step that has acceptance checks to decide it? */
674
+ /** A person's own local coding session: not a delegated child and not AgentPod-admitted work,
675
+ * whose strict semantics stay as they are. */
676
+ isInteractiveCoding(id) {
677
+ return this.interactiveCoding(id);
678
+ }
679
+ /** Run this desk session's hooks for `event`; other sessions (board, station, children) run none. */
680
+ async hook(id, event, payload, names = []) {
681
+ if (!this.hooks || !this.interactiveCoding(id))
682
+ return null;
683
+ return this.hooks.fire(id, event, payload, names);
684
+ }
685
+ /** A PreToolUse hook that blocks turns the tool call into an error the model reads. */
686
+ async preToolUse(id, tool, input, toolCallId) {
687
+ const out = await this.hook(id, 'PreToolUse', { tool_name: tool, tool_input: input, tool_use_id: toolCallId }, [tool, ...hookAliases(tool, input)]);
688
+ if (out?.blocked)
689
+ throw new ServiceError('hook_blocked', `Blocked by a PreToolUse hook: ${out.blocked}`);
690
+ }
691
+ interactiveCoding(id) {
692
+ return (this.store.localCoding(id) &&
693
+ !this.children?.owned(id) &&
694
+ !this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id));
695
+ }
608
696
  checkedStepAttempt(attempt) {
609
697
  const row = this.store.db
610
698
  .prepare('SELECT checks FROM task_steps WHERE attempt_id=?')
@@ -895,6 +983,77 @@ export class Supervisor extends EventEmitter {
895
983
  return this.station.view(c.id);
896
984
  throw new ServiceError('invalid_field', 'Unsupported station method');
897
985
  }
986
+ if (method === 'web.search' || method === 'web.fetch') {
987
+ if (!this.web || !this.store.localCoding(id) || this.children?.owned(id) || !w.attempt)
988
+ throw new ServiceError('web_denied', 'Web search is not admitted for this session', 403);
989
+ this.assertActive(id, w.epoch, w.attempt);
990
+ const args = object(body.args);
991
+ const toolCallId = string(body.tool_call_id, 'tool_call_id', 512);
992
+ await this.preToolUse(id, method === 'web.search' ? 'salaam_web_search' : 'salaam_web_fetch', args, toolCallId);
993
+ if (method === 'web.search') {
994
+ const out = await this.web.search({
995
+ query: string(args.query, 'query', 300),
996
+ ...(typeof args.kind === 'string'
997
+ ? { kind: args.kind }
998
+ : {}),
999
+ ...(typeof args.limit === 'number' ? { limit: args.limit } : {}),
1000
+ });
1001
+ // Recorded: kind, backends tried, which answered and how long. Never a key or the query.
1002
+ this.store.transaction(() => this.store.event(id, 'web.search', {
1003
+ attempt_id: w.attempt,
1004
+ tool_call_id: toolCallId,
1005
+ kind: out.kind,
1006
+ cached: !!out.cached,
1007
+ results: out.results.length,
1008
+ tried: out.tried,
1009
+ }));
1010
+ return out;
1011
+ }
1012
+ const url = string(args.url, 'url', 2000);
1013
+ let host = '';
1014
+ try {
1015
+ host = new URL(url).hostname;
1016
+ }
1017
+ catch {
1018
+ /* fetchPage refuses it */
1019
+ }
1020
+ try {
1021
+ const page = await (await import('./web-fetch.js')).fetchPage(url);
1022
+ this.store.transaction(() => this.store.event(id, 'web.fetch', {
1023
+ attempt_id: w.attempt,
1024
+ tool_call_id: toolCallId,
1025
+ host,
1026
+ status: page.status,
1027
+ chars: page.text.length,
1028
+ truncated: page.truncated,
1029
+ }));
1030
+ const { renderFetched } = await import('./web-fetch.js');
1031
+ return { ok: true, url: page.url, text: renderFetched(page) };
1032
+ }
1033
+ catch (error) {
1034
+ const message = String(error.message ?? error).slice(0, 300);
1035
+ this.store.transaction(() => this.store.event(id, 'web.fetch', {
1036
+ attempt_id: w.attempt,
1037
+ tool_call_id: toolCallId,
1038
+ host,
1039
+ error: message,
1040
+ }));
1041
+ return { ok: false, url, error: message };
1042
+ }
1043
+ }
1044
+ if (method.startsWith('bgtask.')) {
1045
+ if (!this.localCoding || !this.interactiveCoding(id) || !w.attempt)
1046
+ throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
1047
+ this.assertActive(id, w.epoch, w.attempt);
1048
+ if (method === 'bgtask.list')
1049
+ return { tasks: this.localCoding.tasks(id) };
1050
+ const task = string(body.task_id, 'task_id', 64);
1051
+ if (method === 'bgtask.output')
1052
+ return this.localCoding.taskOutput(id, task);
1053
+ if (method === 'bgtask.stop')
1054
+ return this.localCoding.stopTask(id, task);
1055
+ throw new ServiceError('invalid_field', 'Unsupported background task method');
1056
+ }
898
1057
  if (method.startsWith('local.') && method !== 'local.read') {
899
1058
  if (!this.localCoding ||
900
1059
  !this.store.localCoding(id) ||
@@ -902,8 +1061,21 @@ export class Supervisor extends EventEmitter {
902
1061
  !w.attempt)
903
1062
  throw new ServiceError('local_denied', 'Local coding is not admitted', 403);
904
1063
  this.assertActive(id, w.epoch, w.attempt);
905
- if (method === 'local.prepare')
906
- return this.localCoding.prepare(id, w.epoch, w.attempt, string(body.tool_call_id, 'tool_call_id', 512), object(body.args));
1064
+ if (method === 'local.prepare') {
1065
+ const toolCall = string(body.tool_call_id, 'tool_call_id', 512);
1066
+ const args = object(body.args);
1067
+ // A retried prepare returns the recorded call; its hooks already ran.
1068
+ const fresh = !this.localCoding.hasCall(id, w.epoch, toolCall);
1069
+ if (fresh)
1070
+ await this.preToolUse(id, 'salaam_local', args, toolCall);
1071
+ const prepared = await this.localCoding.prepareReviewed(id, w.epoch, w.attempt, toolCall, args);
1072
+ if (fresh && prepared.approval?.state === 'pending')
1073
+ void this.hook(id, 'Notification', {
1074
+ notification_type: 'permission_prompt',
1075
+ message: `Salaam needs your approval for a ${String(args.action ?? 'local')} request`,
1076
+ }).catch(() => { });
1077
+ return prepared;
1078
+ }
907
1079
  const c = this.localCoding.get(string(body.call_id, 'call_id'));
908
1080
  if (c.session_id !== id || c.epoch !== w.epoch || c.attempt_id !== w.attempt)
909
1081
  throw new ServiceError('local_scope', 'Operation belongs to another invocation', 403);
@@ -919,6 +1091,7 @@ export class Supervisor extends EventEmitter {
919
1091
  this.assertActive(id, w.epoch, w.attempt);
920
1092
  this.store.taskGrantCheck?.(id);
921
1093
  const args = object(body.args);
1094
+ await this.preToolUse(id, 'salaam_workspace', args, string(body.tool_call_id, 'tool_call_id', 512));
922
1095
  const result = readLocalWorkspace(this.store.session(id).cwd, args);
923
1096
  this.store.transaction(() => this.store.event(id, 'workspace.read', {
924
1097
  attempt_id: w.attempt,
@@ -1146,25 +1319,13 @@ export class Supervisor extends EventEmitter {
1146
1319
  'request_conflict',
1147
1320
  ].includes(error.code))
1148
1321
  throw error;
1322
+ // A hook that blocked a tool call is the person's policy speaking, not a broken control: the
1323
+ // model reads the reason as the tool's result (Phase 4b).
1324
+ if (error instanceof ServiceError && error.code === 'hook_blocked')
1325
+ throw error;
1149
1326
  if ((method.startsWith('coding.') || method.startsWith('local.')) &&
1150
1327
  error instanceof ServiceError &&
1151
- [
1152
- 'local_busy',
1153
- 'workspace_read_failed',
1154
- 'unsafe_path',
1155
- 'workspace_changed',
1156
- 'workspace_busy',
1157
- 'workspace_limit',
1158
- 'workspace_conflict',
1159
- 'not_found',
1160
- 'binary_file',
1161
- 'line_limit',
1162
- 'invalid_field',
1163
- 'request_conflict',
1164
- 'command_conflict',
1165
- 'command_limit',
1166
- 'invalid_cursor',
1167
- ].includes(error.code))
1328
+ LOCAL_TOOL_ERRORS.includes(error.code))
1168
1329
  throw error;
1169
1330
  if (method === 'history' &&
1170
1331
  error instanceof ServiceError &&
@@ -1212,6 +1373,7 @@ export class Supervisor extends EventEmitter {
1212
1373
  const state = {
1213
1374
  ...this.store.session(id),
1214
1375
  provider_route: this.store.providerRoute(id) ?? undefined,
1376
+ project_instructions: this.tasks.instructions?.summary(id) ?? undefined,
1215
1377
  provider_capabilities: cap === undefined
1216
1378
  ? this.providerCapabilities()
1217
1379
  : {
@@ -1478,10 +1640,13 @@ export class Supervisor extends EventEmitter {
1478
1640
  // A checked task step is decided by its checks, not by a tool error the model went on
1479
1641
  // to recover from (operator decision, 2026-10-23). The error stays in the journal
1480
1642
  // (tool.finished failed=true). A delegated child turn, whose denied probes are its
1481
- // boundary proof, and any attempt outside a checked step still fail on it.
1643
+ // boundary proof, and any attempt outside a checked step still fail on it, except an
1644
+ // interactive coding turn: at a desk a failed command is feedback, and the turn is
1645
+ // decided by how it ends (step 0, 2026-10-31).
1482
1646
  if (value.type === 'tool_execution_end' &&
1483
1647
  value.isError === true &&
1484
- (this.children?.owned(id) || !this.checkedStepAttempt(work.attempt.id)))
1648
+ (this.children?.owned(id) ||
1649
+ (!this.checkedStepAttempt(work.attempt.id) && !this.interactiveCoding(id))))
1485
1650
  outcome ??= {
1486
1651
  version: 1,
1487
1652
  stopReason: 'error',
@@ -1490,7 +1655,31 @@ export class Supervisor extends EventEmitter {
1490
1655
  };
1491
1656
  w.pi.on('record', record);
1492
1657
  try {
1493
- const response = await w.pi.command('prompt', { message: work.submission.text });
1658
+ // Interactive sessions pick up AGENTS.md edits at the start of each turn (recorded as a new
1659
+ // instructions.loaded revision). A discovery failure never blocks the turn.
1660
+ try {
1661
+ this.tasks.instructions?.refresh(id);
1662
+ }
1663
+ catch {
1664
+ /* the previously loaded guidance stays in force */
1665
+ }
1666
+ // The folder as it was before this turn, so /rewind can return to it.
1667
+ if (this.interactiveCoding(id))
1668
+ this.tasks.checkpoints?.capture(id, work.attempt.id);
1669
+ // UserPromptSubmit hooks may stop the prompt or add context to it (Phase 4b).
1670
+ let message = work.submission.text;
1671
+ const gate = await this.hook(id, 'UserPromptSubmit', { prompt: message });
1672
+ if (gate?.blocked) {
1673
+ this.store.finish(work.attempt.id, 'failed', null, 'Blocked by a UserPromptSubmit hook: ' + gate.blocked);
1674
+ return true;
1675
+ }
1676
+ if (gate?.context)
1677
+ message += '\n\n<hook-context>\n' + gate.context + '\n</hook-context>';
1678
+ const images = takeImages(this.store.db, work.submission.id);
1679
+ const response = await w.pi.command('prompt', {
1680
+ message,
1681
+ ...(images.length ? { images } : {}),
1682
+ });
1494
1683
  const disposition = object(response.data ?? {}).disposition;
1495
1684
  if (disposition !== 'started')
1496
1685
  throw new Error('Pi did not start the dispatched prompt');
@@ -1502,6 +1691,7 @@ export class Supervisor extends EventEmitter {
1502
1691
  const result = object((await w.pi.command('get_last_assistant_text')).data);
1503
1692
  this.childBoundary(id, w.epoch);
1504
1693
  this.store.finish(work.attempt.id, outcome ? 'failed' : 'completed', String(result.text ?? ''), outcome?.failure?.message ?? null, { epoch: w.epoch, ...entries }, outcome);
1694
+ void this.hook(id, 'Stop', { outcome: outcome ? 'failed' : 'completed' }).catch(() => { });
1505
1695
  }
1506
1696
  catch (error) {
1507
1697
  const message = redactFailureText(error.message, w.secrets);
@@ -1586,3 +1776,24 @@ export function toolFailureMessage(toolName) {
1586
1776
  ? `Tool execution failed (tool ${toolName})`
1587
1777
  : 'Tool execution failed';
1588
1778
  }
1779
+ /** Claude Code names for Salaam's tools, so a `Bash` or `Edit|Write` matcher works unchanged. */
1780
+ export function hookAliases(tool, input) {
1781
+ if (tool === 'salaam_local')
1782
+ return input.action === 'command' ? ['Bash'] : input.action === 'edit' ? ['Edit', 'Write'] : [];
1783
+ if (tool === 'salaam_web_search')
1784
+ return ['WebSearch'];
1785
+ if (tool === 'salaam_web_fetch')
1786
+ return ['WebFetch'];
1787
+ if (tool === 'salaam_workspace')
1788
+ return ['Read'];
1789
+ return [];
1790
+ }
1791
+ function parsedInput(input) {
1792
+ try {
1793
+ const value = JSON.parse(input ?? '{}');
1794
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
1795
+ }
1796
+ catch {
1797
+ return {};
1798
+ }
1799
+ }
@@ -14,6 +14,10 @@ export class TaskContext {
14
14
  artifacts;
15
15
  work;
16
16
  admitted;
17
+ /** Interactive sessions only: AGENTS.md guidance loaded automatically (project-instructions.ts). */
18
+ instructions;
19
+ /** Interactive sessions only: a folder checkpoint before every turn (turn-rewind.ts). */
20
+ checkpoints;
17
21
  constructor(store, artifacts) {
18
22
  this.store = store;
19
23
  this.artifacts = artifacts;
@@ -65,11 +69,13 @@ export class TaskContext {
65
69
  if (Buffer.byteLength(frame) > TASK_FRAME_BYTES)
66
70
  throw new ServiceError('context_capacity', 'Mandatory task state exceeds the context budget');
67
71
  const admittedContext = this.admitted?.frame(id);
68
- return admittedContext
72
+ const projectInstructions = this.instructions?.frame(id);
73
+ return admittedContext || projectInstructions
69
74
  ? 'SALAAM_TASK_STATE ' +
70
75
  canonical({
71
76
  ...JSON.parse(frame.slice('SALAAM_TASK_STATE '.length)),
72
- admitted_context: admittedContext,
77
+ ...(admittedContext ? { admitted_context: admittedContext } : {}),
78
+ ...(projectInstructions ? { project_instructions: projectInstructions } : {}),
73
79
  })
74
80
  : frame;
75
81
  }
@@ -0,0 +1,88 @@
1
+ import { execFile } from 'node:child_process';
2
+ export function terminalState(snapshot) {
3
+ if (!snapshot)
4
+ return 'idle';
5
+ if ((snapshot.approvals ?? []).some((a) => a.state === 'pending'))
6
+ return 'blocked';
7
+ if ((snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state)))
8
+ return 'working';
9
+ return 'idle';
10
+ }
11
+ export function titleFor(state, name) {
12
+ // eslint-disable-next-line no-control-regex
13
+ const clean = name
14
+ ?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
15
+ .trim()
16
+ .slice(0, 32);
17
+ if (!clean)
18
+ return 'Salaam';
19
+ const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
20
+ return `Salaam · ${clean} · ${words}`;
21
+ }
22
+ export function statusEnabled(env = process.env) {
23
+ return env.SALAAM_TERMINAL_STATUS !== 'off';
24
+ }
25
+ const run = (bin, args, done) => {
26
+ // Short timeout, failures ignored: Herdr must never slow Salaam down.
27
+ execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
28
+ };
29
+ /** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
30
+ export class HerdrReporter {
31
+ env;
32
+ exec;
33
+ now;
34
+ enabled;
35
+ seq = 0;
36
+ busy = false;
37
+ queued;
38
+ last;
39
+ constructor(env = process.env, exec = run, now = Date.now) {
40
+ this.env = env;
41
+ this.exec = exec;
42
+ this.now = now;
43
+ this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
44
+ }
45
+ nextSeq() {
46
+ this.seq = Math.max(this.seq + 1, this.now());
47
+ return String(this.seq);
48
+ }
49
+ head(verb) {
50
+ return ['pane', verb, this.env.HERDR_PANE_ID, '--source', 'salaam', '--agent', 'salaam'];
51
+ }
52
+ report(state, session) {
53
+ if (!this.enabled)
54
+ return;
55
+ const key = state + ' ' + (session ?? '');
56
+ if (key === this.last)
57
+ return;
58
+ this.last = key;
59
+ this.queued = [
60
+ ...this.head('report-agent'),
61
+ '--state',
62
+ state,
63
+ '--seq',
64
+ '',
65
+ ...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
66
+ ...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
67
+ ];
68
+ this.flush();
69
+ }
70
+ release() {
71
+ if (!this.enabled)
72
+ return;
73
+ this.queued = undefined;
74
+ this.exec(this.env.HERDR_BIN_PATH, [...this.head('release-agent'), '--seq', this.nextSeq()], () => { });
75
+ }
76
+ flush() {
77
+ if (this.busy || !this.queued)
78
+ return;
79
+ const args = this.queued;
80
+ this.queued = undefined;
81
+ args[args.indexOf('--seq') + 1] = this.nextSeq();
82
+ this.busy = true;
83
+ this.exec(this.env.HERDR_BIN_PATH, args, () => {
84
+ this.busy = false;
85
+ this.flush();
86
+ });
87
+ }
88
+ }