@spexcode/spec-cli 0.6.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 (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. package/templates/spec/project/spec.md +20 -0
@@ -0,0 +1,208 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { headlessTurnFailureShell } from './harness.js';
7
+ import { shQuote } from './sh.js';
8
+ const pexec = promisify(execFile);
9
+ const WAKE_EARLY_EXIT_MS = 5_000;
10
+ const OUTCOME_POLL_MS = 25;
11
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
12
+ function accountLoginShell() {
13
+ try {
14
+ return userInfo().shell || '';
15
+ }
16
+ catch {
17
+ return '';
18
+ }
19
+ }
20
+ // A headless turn owns the pane only while `opencode run` is alive. Returning to a shell keeps the tmux
21
+ // window as the session's durable home without adding a resident controller or an stdin bridge.
22
+ function turnHome(command, outcomePath) {
23
+ const outcomeSetup = outcomePath ? [
24
+ `__spex_outcome_path=${shQuote(outcomePath)}`,
25
+ `__spex_outcome_tmp=${shQuote(`${outcomePath}.tmp`)}`,
26
+ '__spex_outcome() { printf \'%s\\n\' "$1" > "$__spex_outcome_tmp" && mv -f "$__spex_outcome_tmp" "$__spex_outcome_path"; }',
27
+ '__spex_outcome "running:$$" || { printf "[spex opencode-headless] could not create turn outcome marker\\n" >&2; exit 125; }',
28
+ ] : [];
29
+ const failure = outcomePath ? [
30
+ '__spex_cas_rc=0',
31
+ 'if [ "$__spex_rc" -ne 0 ]; then',
32
+ ' __spex_outcome "reporting:$$:$__spex_rc" || true',
33
+ ` ${headlessTurnFailureShell('opencode-headless', false)}`,
34
+ ' __spex_cas_rc=$?',
35
+ 'fi',
36
+ '__spex_outcome "exit:$__spex_rc:cas:$__spex_cas_rc" || printf "[spex opencode-headless] could not finalize turn outcome marker\\n" >&2',
37
+ ] : [
38
+ `if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('opencode-headless')}; fi`,
39
+ ];
40
+ const script = [
41
+ ...outcomeSetup,
42
+ command,
43
+ '__spex_rc=$?',
44
+ ...failure,
45
+ '[ "$__spex_rc" -eq 0 ] || printf "[spex opencode-headless] turn exited rc=%s\\n" "$__spex_rc" >&2',
46
+ 'exec "${SHELL:-/bin/sh}"',
47
+ ].join('\n');
48
+ return `bash -lc ${shQuote(script)} spexcode-opencode-headless`;
49
+ }
50
+ // Launcher profiles carry a base executable plus its configured flags (`opencode --auto`). OpenCode parses
51
+ // `run` as a subcommand, so it must sit between those two halves (`opencode run --auto`), not at the tail.
52
+ function runPrelude(opencodeCmd) {
53
+ const accountShell = accountLoginShell();
54
+ return [
55
+ '__spex_login_shell="${SHELL:-}"',
56
+ `[ -n "$__spex_login_shell" ] || __spex_login_shell=${shQuote(accountShell)}`,
57
+ `__spex_cmd=(${opencodeCmd})`,
58
+ '__spex_env=()',
59
+ 'while [ "${#__spex_cmd[@]}" -gt 0 ] && [[ "${__spex_cmd[0]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do',
60
+ ' __spex_env+=("${__spex_cmd[0]}")',
61
+ ' __spex_cmd=("${__spex_cmd[@]:1}")',
62
+ 'done',
63
+ // Resolve OpenCode in the same login + interactive environment the user relies on. The launcher-leading
64
+ // assignments are applied after shell startup, so its explicit per-session config still wins.
65
+ '__spex_run() { [ -n "$__spex_login_shell" ] || { printf "[spex opencode-headless] no login shell could be resolved - turn NOT started\\n" >&2; return 126; }; "$__spex_login_shell" -ilc \'exec env "$@"\' spexcode-opencode-headless "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
66
+ ];
67
+ }
68
+ export function opencodeHeadlessLaunchCommand(opencodeCmd = 'opencode') {
69
+ const script = [
70
+ ...runPrelude(opencodeCmd),
71
+ 'if [ "${1:-}" = "--resume" ]; then',
72
+ ' export SPEXCODE_OPENCODE_RESUME_ID="$2"',
73
+ ' unset SPEXCODE_OPENCODE_CONTINUE',
74
+ ' __spex_run --session "$2"',
75
+ 'elif [ "${1:-}" = "--continue" ]; then',
76
+ ' unset SPEXCODE_OPENCODE_RESUME_ID',
77
+ ' export SPEXCODE_OPENCODE_CONTINUE=1',
78
+ ' __spex_run --continue',
79
+ 'elif [ -n "${1:-}" ]; then',
80
+ ' unset SPEXCODE_OPENCODE_RESUME_ID SPEXCODE_OPENCODE_CONTINUE',
81
+ ' __spex_run "$1"',
82
+ 'else',
83
+ ' __spex_run',
84
+ 'fi',
85
+ ].join('\n');
86
+ return turnHome(script);
87
+ }
88
+ export function opencodeHeadlessWakeCommand(opencodeCmd, harnessSessionId, text, outcomePath) {
89
+ const resume = harnessSessionId ? [
90
+ `export SPEXCODE_OPENCODE_RESUME_ID=${shQuote(harnessSessionId)}`,
91
+ 'unset SPEXCODE_OPENCODE_CONTINUE',
92
+ `__spex_run --session ${shQuote(harnessSessionId)} ${shQuote(text)}`,
93
+ ] : [
94
+ 'unset SPEXCODE_OPENCODE_RESUME_ID',
95
+ 'export SPEXCODE_OPENCODE_CONTINUE=1',
96
+ `__spex_run --continue ${shQuote(text)}`,
97
+ ];
98
+ return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'), outcomePath);
99
+ }
100
+ function readTurnOutcome(path) {
101
+ let value;
102
+ try {
103
+ value = readFileSync(path, 'utf8').trim();
104
+ }
105
+ catch (error) {
106
+ if (error.code === 'ENOENT')
107
+ return undefined;
108
+ throw error;
109
+ }
110
+ let match = /^running:(\d+)$/.exec(value);
111
+ if (match)
112
+ return { state: 'running', pid: Number(match[1]) };
113
+ match = /^reporting:(\d+):(-?\d+)$/.exec(value);
114
+ if (match)
115
+ return { state: 'reporting', pid: Number(match[1]), code: Number(match[2]) };
116
+ match = /^exit:(-?\d+):cas:(\d+)$/.exec(value);
117
+ if (match)
118
+ return { state: 'exit', code: Number(match[1]), casCode: Number(match[2]) };
119
+ return { state: 'invalid' };
120
+ }
121
+ function turnExited(rec, outcome) {
122
+ if (outcome.code === 0)
123
+ return { ok: true };
124
+ const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`;
125
+ return {
126
+ ok: false,
127
+ error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - immediate wake failed`,
128
+ };
129
+ }
130
+ function pidAlive(pid) {
131
+ if (!Number.isInteger(pid) || pid <= 0)
132
+ return false;
133
+ try {
134
+ process.kill(pid, 0);
135
+ return true;
136
+ }
137
+ catch (error) {
138
+ return error.code === 'EPERM';
139
+ }
140
+ }
141
+ export async function spawnOpenCodeHeadlessTurn(rec, text, opencodeCmd, socketPath) {
142
+ if (!rec.worktreePath)
143
+ return { ok: false, error: `opencode-headless session ${rec.session} has no worktree path - turn NOT started` };
144
+ let outcomeDir;
145
+ try {
146
+ outcomeDir = mkdtempSync(join(tmpdir(), 'spexcode-oh-turn-'));
147
+ }
148
+ catch (error) {
149
+ return { ok: false, error: `opencode-headless could not prepare turn confirmation for session ${rec.session}: ${error.message}` };
150
+ }
151
+ const outcomePath = join(outcomeDir, 'exit-code');
152
+ const tmuxSock = process.env.SPEXCODE_TMUX || 'spexcode';
153
+ const args = [
154
+ '-L', tmuxSock, 'respawn-pane', '-k', '-t', rec.session, '-c', rec.worktreePath,
155
+ '-e', `SPEXCODE_SESSION_ID=${rec.session}`,
156
+ '-e', 'CLAUDE_BG_BACKEND=daemon',
157
+ '-e', `CLAUDE_BG_RENDEZVOUS_SOCK=${socketPath}`,
158
+ ];
159
+ for (const name of ['SPEXCODE_HOME', 'CODEX_HOME']) {
160
+ const value = process.env[name];
161
+ if (value)
162
+ args.push('-e', `${name}=${value}`);
163
+ }
164
+ args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text, outcomePath));
165
+ try {
166
+ await pexec('tmux', args, { timeout: 5_000 });
167
+ const deadline = Date.now() + WAKE_EARLY_EXIT_MS;
168
+ for (;;) {
169
+ const outcome = readTurnOutcome(outcomePath);
170
+ if (outcome?.state === 'exit')
171
+ return turnExited(rec, outcome);
172
+ if (outcome?.state === 'invalid') {
173
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` };
174
+ }
175
+ if (Date.now() >= deadline)
176
+ break;
177
+ await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()));
178
+ }
179
+ const first = readTurnOutcome(outcomePath);
180
+ if (first?.state === 'exit')
181
+ return turnExited(rec, first);
182
+ if (!first)
183
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - immediate wake failed` };
184
+ if (first.state === 'invalid')
185
+ return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - immediate wake not confirmed` };
186
+ if (first.state === 'reporting') {
187
+ return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - immediate wake failed` };
188
+ }
189
+ if (!pidAlive(first.pid)) {
190
+ return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - immediate wake failed` };
191
+ }
192
+ await sleep(OUTCOME_POLL_MS);
193
+ const settled = readTurnOutcome(outcomePath);
194
+ if (settled?.state === 'exit')
195
+ return turnExited(rec, settled);
196
+ if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
197
+ return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - immediate wake failed` };
198
+ }
199
+ return { ok: true };
200
+ }
201
+ catch (error) {
202
+ const detail = error instanceof Error ? error.message : String(error);
203
+ return { ok: false, error: `opencode-headless could not start a turn for session ${rec.session}: ${detail}` };
204
+ }
205
+ finally {
206
+ rmSync(outcomeDir, { recursive: true, force: true });
207
+ }
208
+ }
@@ -0,0 +1,3 @@
1
+ export declare const OPENCODE_EVENTS: readonly ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop"];
2
+ export declare const OPENCODE_TOOL_NAMES: Record<string, string>;
3
+ export declare function opencodePluginSource(dispatch: string, spex: string): string;
@@ -0,0 +1,142 @@
1
+ import { shimRuntimeSource } from './shim-runtime.js';
2
+ // @@@ opencode plugin generator - the opencode adapter's "shim" is not a settings JSON but a generated
3
+ // opencode PLUGIN (.opencode/plugins/spexcode.ts, auto-loaded by opencode from the project tree) that runs
4
+ // inside the agent process. It is a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded
5
+ // verbatim): this generator declares opencode's event-bus mapping (session.created → SessionStart,
6
+ // chat.message → UserPromptSubmit, tool.execute.before/after → Pre/PostToolUse, session.idle → Stop) and its
7
+ // host bindings; the payload synthesis, the block verdict (exit 2 + stdout decision:block JSON), and the
8
+ // rendezvous server come from the runtime. opencode's host-specific verdict consumers ([[opencode-harness]]):
9
+ // a PreToolUse block THROWS (opencode aborts the tool call); a Stop block re-injects the gate's reason as a
10
+ // follow-up prompt via the SDK client, closing the stop-gate loop. What re-enters is always the parsed
11
+ // REASON, never the escaped wire JSON. The rendezvous inject is client.session.prompt into the ROOT session,
12
+ // gated by canInject (a plugin with no adopted session reply-rejects instead of confirming an undeliverable
13
+ // prompt). What genuinely stays opencode-only below: session tracking (root vs subagent children, the
14
+ // agent_id stamp), the minted-id capture, and the two resume seeds.
15
+ // This module is a PURE content producer (no imports from harness.ts — the adapter object lives there, in the
16
+ // one HARNESSES seam) so there is no import cycle.
17
+ // the lifecycle events the generated plugin can emit (drives the shim + hooks-manifest binding). opencode's
18
+ // bus has no failed-stop and no idle/attention notification event, so StopFailure/Notification are genuinely
19
+ // absent — the same five codex binds.
20
+ export const OPENCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'];
21
+ // opencode tool name → Claude tool name, so [[inject-spec-first]] / [[inject-spec-of-file]] and every other
22
+ // claude-family handler read the payload with zero opencode knowledge. Unknown tools pass through untouched
23
+ // (opencode's names are lowercase). Exported for tests.
24
+ export const OPENCODE_TOOL_NAMES = {
25
+ bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', glob: 'Glob', list: 'LS',
26
+ patch: 'Edit', todowrite: 'TodoWrite', todoread: 'TodoRead', webfetch: 'WebFetch', task: 'Task',
27
+ };
28
+ // the generated plugin source. `dispatch` and `spex` are absolute paths baked at materialize time (same as
29
+ // every shim); the 'dispatch.sh' substring doubles as the identity stamp cleanHarness gates removal on. The
30
+ // file is plain TS with no @opencode-ai/plugin type import, so it loads regardless of the host project's
31
+ // node_modules; opencode calls every exported function with its plugin context.
32
+ export function opencodePluginSource(dispatch, spex) {
33
+ return `// spexcode: GENERATED by spex materialize — do not edit (regenerated per clone; see [[opencode-harness]])
34
+ // Bridges opencode's event bus into SpexCode's dispatch.sh (claude-shaped payloads) and serves the
35
+ // per-session rendezvous socket for prompt delivery + liveness.
36
+ import { spawn } from "node:child_process"
37
+ ${shimRuntimeSource('opencode', dispatch, spex)}
38
+
39
+ const TOOL_NAMES = ${JSON.stringify(OPENCODE_TOOL_NAMES)}
40
+
41
+ export const SpexcodePlugin = async (ctx) => {
42
+ const client = ctx && ctx.client
43
+ const cwd = (ctx && (ctx.directory || (ctx.app && ctx.app.path && ctx.app.path.cwd))) || process.cwd()
44
+ const recordId = (process.env.SPEXCODE_SESSION_ID || "").trim()
45
+ const rt = spexShimRuntime({ sessionId: () => recordId, cwd: () => cwd })
46
+
47
+ // session tracking: the ROOT opencode session is this worker's conversation; child sessions (subagents)
48
+ // are stamped agent_id so a parent's declared state stays out of its subagents' reach (the same
49
+ // discriminator claude's Task subagents carry). A RESUMED session re-fires no bus event until poked, so
50
+ // event-driven adoption alone leaves a resumed worker unreachable (the daemon reply-rejects every
51
+ // delivery): a --session resume seeds rootSession from the launch env (the resume marker's id), and a
52
+ // --continue resume falls back to asking the SDK for the newest root session.
53
+ let rootSession = (process.env.SPEXCODE_OPENCODE_RESUME_ID || "").trim()
54
+ const children = new Set()
55
+ const stamp = (sid) => (sid && children.has(sid) ? { agent_id: sid } : {})
56
+ // opencode MINTS its own session id — report it once so the backend stores it as harness_session_id
57
+ // (the id session resume passes to \`opencode --session <id>\`). Fire-and-forget; a failure only costs resume-by-id.
58
+ let captured = !!rootSession // a seeded (resumed) id is already on the record — don't re-report it
59
+ const capture = (sid) => {
60
+ if (captured || !sid || !recordId) return
61
+ captured = true
62
+ try { spawn(SPEX, ["internal", "opencode-capture", sid], { cwd, env: process.env, stdio: "ignore" }) } catch { /* resume falls back to --continue */ }
63
+ }
64
+ const adopt = (sid) => { if (!rootSession && sid) { rootSession = sid; capture(sid) } }
65
+ const injectPrompt = async (text) => {
66
+ if (!client || !rootSession) throw new Error("no session to prompt")
67
+ await client.session.prompt({ path: { id: rootSession }, body: { parts: [{ type: "text", text }] } })
68
+ }
69
+
70
+ // --continue resume: no owned id was ever captured, and a reattached session fires no bus event until
71
+ // poked — ask the SDK for the newest root session (what --continue itself reattached) so deliveries can
72
+ // inject. Gated on the launch marker so a FRESH launch can never adopt a stale session; best-effort with
73
+ // bounded retries (the server may still be loading at plugin init), shape-defensive, and adopt() ignores
74
+ // the late result if a real event won the race.
75
+ if (!rootSession && process.env.SPEXCODE_OPENCODE_CONTINUE && client && client.session && typeof client.session.list === "function") {
76
+ (async () => {
77
+ for (let i = 0; i < 10 && !rootSession; i++) {
78
+ try {
79
+ const res = await client.session.list()
80
+ const arr = (res && (res.data || res)) || []
81
+ const roots = (Array.isArray(arr) ? arr : []).filter((s) => s && s.id && !s.parentID)
82
+ const at = (s) => (s.time && (s.time.updated || s.time.created)) || 0
83
+ roots.sort((a, b) => at(b) - at(a))
84
+ if (roots.length) { adopt(roots[0].id); return }
85
+ } catch { /* not up yet — retry */ }
86
+ await new Promise((r) => setTimeout(r, 1000))
87
+ }
88
+ })()
89
+ }
90
+
91
+ // The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
92
+ // timeline line for its next turn boundary; the SDK injection remains host-owned.
93
+ rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
94
+
95
+ const toolPayload = (input, output) => {
96
+ const sid = (input && input.sessionID) || ""
97
+ return {
98
+ ...stamp(sid), // agent_id must precede tool_input — hp_is_subagent checks keys only before tool_input
99
+ tool_name: TOOL_NAMES[(input && input.tool) || ""] || ((input && input.tool) || ""),
100
+ tool_input: rt.toolInput(output && output.args, "filePath"),
101
+ }
102
+ }
103
+
104
+ return {
105
+ event: async ({ event }) => {
106
+ const t = event && event.type
107
+ const p = (event && event.properties) || {}
108
+ if (t === "session.created") {
109
+ const info = p.info || {}
110
+ if (info.parentID) children.add(info.id)
111
+ else if (!rootSession) { adopt(info.id); await rt.dispatchEvent("SessionStart", { source: "startup" }) }
112
+ } else if (t === "session.idle") {
113
+ const sid = p.sessionID || ""
114
+ if (sid && rootSession && sid !== rootSession) return // a subagent going idle is not this worker's Stop
115
+ adopt(sid)
116
+ // Start the Stop dispatch now, but RETURN this idle callback before a blocked verdict injects its
117
+ // follow-up turn. Awaiting client.session.prompt here re-enters opencode before it has published the
118
+ // just-finished assistant text: the continuation can declare success while the requested final answer
119
+ // disappears. This is opencode host scheduling, not shared dispatchStop semantics (pi correctly awaits
120
+ // its agent_end continuation). dispatchStop catches inject failures; this final catch keeps substrate
121
+ // failures loud without throwing them into opencode after the event callback has returned.
122
+ void rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
123
+ .catch((e) => console.error("spexcode: Stop dispatch failed: " + String(e)))
124
+ }
125
+ },
126
+ "chat.message": async (input, output) => {
127
+ const sid = (output && output.message && output.message.sessionID) || (input && input.sessionID) || ""
128
+ if (!(sid && children.has(sid))) adopt(sid)
129
+ const text = ((output && output.parts) || []).filter((x) => x && x.type === "text").map((x) => x.text).join("\\n")
130
+ await rt.dispatchEvent("UserPromptSubmit", { ...stamp(sid), prompt: text })
131
+ },
132
+ "tool.execute.before": async (input, output) => {
133
+ const r = await rt.dispatchEvent("PreToolUse", toolPayload(input, output))
134
+ if (rt.blocked(r)) throw new Error(rt.blockReason(r, "blocked by a spexcode hook")) // aborts the tool call — the PreToolUse block contract
135
+ },
136
+ "tool.execute.after": async (input, output) => {
137
+ await rt.dispatchEvent("PostToolUse", toolPayload(input, output))
138
+ },
139
+ }
140
+ }
141
+ `;
142
+ }
@@ -0,0 +1,3 @@
1
+ export declare function piExtensionSource(dispatch: string, spex: string): string;
2
+ export declare function writePiTrust(proj: string): string;
3
+ export declare function removePiTrust(proj: string): void;
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { shimRuntimeSource } from './shim-runtime.js';
5
+ // @@@ pi-harness helpers - the pi-ONLY machinery behind harness.ts's piHarness adapter: the generated
6
+ // extension source (pi's shim is a TypeScript extension, not a hooks JSON — pi has no external hook binding;
7
+ // its lifecycle surface IS the extension API) and the trust writer (pi's project-trust store,
8
+ // ~/.pi/agent/trust.json). Kept out of harness.ts so the adapter table stays scannable; imports NOTHING from
9
+ // harness.ts at runtime (no cycle — harness.ts imports us).
10
+ // ---------------------------------------------------------------------------------------------------------
11
+ // the pi shim = a generated extension at .pi/extensions/spexcode.ts (pi runs .ts extensions natively). It is
12
+ // a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded verbatim): this generator declares
13
+ // only pi's event-name mapping and its ExtensionAPI bindings —
14
+ // session_start → SessionStart · input → UserPromptSubmit · tool_call → PreToolUse (blockable) ·
15
+ // tool_result → PostToolUse · agent_end + agent_settled → Stop (dual binding, one shared bit)
16
+ // — while the payload synthesis, the block-verdict parse (exit 2 + stdout decision:block JSON), and the
17
+ // rendezvous server all come from the runtime. pi's two host-specific verdict consumers: a tool_call block
18
+ // returns pi's typed { block: true, reason }; a Stop block has no blocking return, so Stop rides the
19
+ // runtime's dispatchStop on a DUAL binding — agent_end (awaited in the run loop: a queued teach drains as
20
+ // the same prompt's continuation, never an orphan) plus agent_settled (fires once per prompt after every
21
+ // drain: the terminal backstop whose flagged dispatch guarantees the gate's escape runs before settlement
22
+ // completes) — with stop_hook_active synthesized across both and the gate's reason SENT BACK IN as a user
23
+ // message; a no-longer-injectable host is reported loud — never silently dropped (a dropped rejection is
24
+ // a session stuck `active` forever).
25
+ // The rendezvous inject is pi.sendUserMessage({deliverAs: steer}), always able → no canInject gate.
26
+ export function piExtensionSource(dispatch, spex) {
27
+ return `// generated by spexcode materialize — DO NOT EDIT (regenerated per clone; identity: dispatch.sh)
28
+ // SpexCode's pi shim: forwards pi lifecycle events to dispatch.sh as Claude-shaped hook payloads, and serves
29
+ // this session's rendezvous socket for prompt delivery + liveness.
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
31
+ ${shimRuntimeSource('pi', dispatch, spex)}
32
+
33
+ // pi tool names → the Claude tool vocabulary harness.sh parses (unknown names pass through untouched).
34
+ const TOOL: Record<string, string> = { read: "Read", edit: "Edit", write: "Write", bash: "Bash" }
35
+
36
+ export default function spexcode(pi: ExtensionAPI) {
37
+ let sessionId = process.env.SPEXCODE_SESSION_ID || ""
38
+ const rt = spexShimRuntime({ sessionId: () => sessionId })
39
+ const toolFields = (toolName: string, input: unknown) =>
40
+ ({ tool_name: TOOL[toolName] ?? toolName, tool_input: rt.toolInput(input, "path") })
41
+
42
+ // bound as soon as the extension loads so liveness reads online early; only a governed launch carries the
43
+ // socket env (sessions.ts rvEnv) — a self-launched bare \`pi\` skips this.
44
+ const rv = rt.serveRendezvous((text: string) => pi.sendUserMessage(text, { deliverAs: "steer" }))
45
+
46
+ pi.on("session_start", async (_event, ctx) => {
47
+ sessionId = ctx.sessionManager.getSessionId() || sessionId
48
+ process.env.PI_SESSION_ID = sessionId // tool subprocesses inherit it → spex CLI knows its session
49
+ await rt.dispatchEvent("SessionStart")
50
+ })
51
+ pi.on("input", async (event) => { await rt.dispatchEvent("UserPromptSubmit", { prompt: event.text }) })
52
+ pi.on("tool_call", async (event) => {
53
+ const r = await rt.dispatchEvent("PreToolUse", toolFields(event.toolName, event.input))
54
+ if (rt.blocked(r)) return { block: true, reason: rt.blockReason(r, "blocked by a SpexCode hook") }
55
+ })
56
+ pi.on("tool_result", async (event) => { await rt.dispatchEvent("PostToolUse", toolFields(event.toolName, event.input)) })
57
+ // Stop rides the runtime's dispatchStop on TWO bindings that never duplicate: agent_end performs the
58
+ // NORMAL dispatch (pi awaits its listeners inside the run loop and drains a queued teach as the SAME
59
+ // awaited prompt's continuation — never the orphaned settle-time prompt whose late inject threw
60
+ // "extension ctx is stale"), while agent_settled — which fires exactly once per prompt, after every
61
+ // drain — consumes AT MOST ONE PENDING BLOCKED stop (measured gap: in real dispatched runs the drained
62
+ // continuation's own agent_end does not reliably re-reach the extension, so without the backstop the
63
+ // flagged escape never runs). A naturally allowed agent_end leaves no
64
+ // pending state and settle dispatches NOTHING (exactly one gate entry per stop); only a blocked
65
+ // agent_end arms it, and the flagged settle dispatch always ends allowed (the gate's continuation paths
66
+ // terminate), clearing the pending bit through a subprocess write with no duplicate inject. An
67
+ // uninjectable host stays caught-loud.
68
+ const stop = () => rt.dispatchStop(
69
+ async (reason: string) => pi.sendUserMessage(reason, { deliverAs: "steer" }),
70
+ "a SpexCode Stop hook blocked this stop without giving a reason",
71
+ )
72
+ pi.on("agent_end", async () => { await stop() })
73
+ pi.on("agent_settled", async () => { if (rt.stopPending()) await stop() })
74
+ pi.on("session_shutdown", async () => { rv?.close() })
75
+ }
76
+ `;
77
+ }
78
+ // ---------------------------------------------------------------------------------------------------------
79
+ // pi trust — make a dispatched/self-launched pi load our project-local extension with zero prompts. pi gates
80
+ // EVERY project-local resource (.pi/extensions, .pi/skills, .pi/prompts, .pi/settings.json) behind project
81
+ // trust: saved decisions live in ~/.pi/agent/trust.json as a flat { "<canonical dir>": true|false } map, and
82
+ // the CLOSEST saved decision on the cwd's parent chain wins (pi trust-manager findNearestTrustEntry). So ONE
83
+ // entry keyed by the MAIN CHECKOUT covers every .worktrees/<id> worktree beneath it. The launch additionally
84
+ // carries `--approve` (one-run trust) as defence for a worktree living OUTSIDE the checkout. Writes are
85
+ // idempotent and surgical: other projects' decisions are untouched; a corrupt trust.json FAILS LOUD rather
86
+ // than being clobbered; remove only deletes a `true` we could have written, never a user's saved "false".
87
+ // pi has no config-dir env of its own (hardcoded ~/.pi/agent) — SPEXCODE_PI_AGENT_DIR is OUR test seam only.
88
+ const piAgentDir = () => process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent');
89
+ // pi canonicalizes trust keys (realpath); mirror it so our key matches its lookup.
90
+ const canonical = (p) => { try {
91
+ return realpathSync(p);
92
+ }
93
+ catch {
94
+ return p;
95
+ } };
96
+ function readTrust(file) {
97
+ if (!existsSync(file))
98
+ return {};
99
+ const raw = readFileSync(file, 'utf8');
100
+ try {
101
+ return JSON.parse(raw);
102
+ }
103
+ catch (e) {
104
+ throw new Error(`${file} is not valid JSON — refusing to rewrite pi's trust store: ${String(e)}`);
105
+ }
106
+ }
107
+ export function writePiTrust(proj) {
108
+ const dir = piAgentDir();
109
+ const file = join(dir, 'trust.json');
110
+ const key = canonical(proj);
111
+ const data = readTrust(file);
112
+ if (data[key] === true)
113
+ return file; // already trusted — no churn
114
+ data[key] = true;
115
+ mkdirSync(dir, { recursive: true });
116
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
117
+ return file;
118
+ }
119
+ export function removePiTrust(proj) {
120
+ const file = join(piAgentDir(), 'trust.json');
121
+ if (!existsSync(file))
122
+ return;
123
+ const key = canonical(proj);
124
+ const data = readTrust(file);
125
+ if (data[key] !== true)
126
+ return; // absent, or a user's own "do not trust" — never touch it
127
+ delete data[key];
128
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
129
+ }
@@ -0,0 +1,25 @@
1
+ import type { DispatchResult, HarnessDeliveryRecord } from './harness.js';
2
+ /** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
3
+ export declare const piHeadlessSock: (id: string) => string;
4
+ export declare function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: string): string;
5
+ export declare const deliverViaPiHeadless: (rec: HarnessDeliveryRecord, text: string) => Promise<DispatchResult>;
6
+ export declare function piHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
7
+ export declare class PiHeadlessController {
8
+ private readonly id;
9
+ private readonly piCmd;
10
+ private readonly cwd;
11
+ private server;
12
+ private child;
13
+ private controlQueue;
14
+ private closing;
15
+ private readonly socketPath;
16
+ constructor(id: string, _runtimeDir: string, piCmd: string, cwd?: string);
17
+ start(initialPrompt?: string): Promise<void>;
18
+ close(): Promise<void>;
19
+ private terminateTurn;
20
+ private waitForExit;
21
+ private accept;
22
+ private handle;
23
+ private spawnTurn;
24
+ }
25
+ export declare function runPiHeadlessController(id: string, runtimeDir: string, piCmd: string, tail: string[]): Promise<void>;