@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,404 @@
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { promisify } from 'node:util';
4
+ import { alive, withSessionInputLock } from './sessions.js';
5
+ const pexec = promisify(execFile);
6
+ const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
7
+ const HELPER = fileURLToPath(new URL('./pty-helper.mjs', import.meta.url));
8
+ const subscribers = new Map();
9
+ const BSU = Buffer.from('\x1b[?2026h');
10
+ const ESU = Buffer.from('\x1b[?2026l');
11
+ const GEOMETRY_STABILIZATION_MS = 400;
12
+ // A hidden but still-alive browser keeps its own client briefly so a quick return is continuous. A dead
13
+ // socket bypasses this window and detaches immediately.
14
+ const LINGER_MS = Number(process.env.SPEXCODE_TERM_LINGER_MS) > 0 ? Number(process.env.SPEXCODE_TERM_LINGER_MS) : 30_000;
15
+ function subscriptionMap(id) {
16
+ let map = subscribers.get(id);
17
+ if (!map)
18
+ subscribers.set(id, map = new Map());
19
+ return map;
20
+ }
21
+ function currentSubscription(id, viewer) {
22
+ return subscribers.get(id)?.get(viewer);
23
+ }
24
+ function isCurrent(bridge) {
25
+ return currentSubscription(bridge.id, bridge.viewer)?.bridge === bridge;
26
+ }
27
+ function deliver(bridge, data) {
28
+ const subscription = currentSubscription(bridge.id, bridge.viewer);
29
+ if (!subscription || (!subscription.visible && !subscription.lingering))
30
+ return;
31
+ try {
32
+ bridge.viewer.send(data);
33
+ }
34
+ catch { /* socket close or heartbeat expiry owns removal */ }
35
+ }
36
+ function commitSize(bridge) {
37
+ const subscription = currentSubscription(bridge.id, bridge.viewer);
38
+ if (!subscription?.visible)
39
+ return;
40
+ try {
41
+ bridge.viewer.commitSize?.(bridge.cols, bridge.rows);
42
+ }
43
+ catch { /* socket close owns removal */ }
44
+ }
45
+ async function tmux(args) {
46
+ try {
47
+ const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args]);
48
+ return stdout.trim();
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ function onHelperOutput(bridge, chunk) {
55
+ if (!isCurrent(bridge))
56
+ return;
57
+ if (bridge.delivery === 'stream') {
58
+ deliver(bridge, chunk);
59
+ return;
60
+ }
61
+ bridge.refreshBuf = bridge.refreshBuf.length ? Buffer.concat([bridge.refreshBuf, chunk]) : chunk;
62
+ releaseInitialRefresh(bridge);
63
+ }
64
+ function sendControl(bridge, message) {
65
+ try {
66
+ bridge.proc.stdin.write(`${JSON.stringify(message)}\n`);
67
+ }
68
+ catch { /* exit recovery owns retry */ }
69
+ }
70
+ function onHelperStderr(bridge, chunk) {
71
+ bridge.stderr += chunk.toString('utf8');
72
+ let newline;
73
+ while ((newline = bridge.stderr.indexOf('\n')) >= 0) {
74
+ const line = bridge.stderr.slice(0, newline);
75
+ bridge.stderr = bridge.stderr.slice(newline + 1);
76
+ const ready = line.match(/^READY (\d+)$/);
77
+ if (ready) {
78
+ bridge.ptyPid = Number(ready[1]);
79
+ const subscription = currentSubscription(bridge.id, bridge.viewer);
80
+ if (subscription)
81
+ subscription.startupError = undefined;
82
+ if (bridge.delivery === 'initial') {
83
+ armDeliveryBoundary(bridge);
84
+ queueRefresh(bridge);
85
+ }
86
+ continue;
87
+ }
88
+ const resized = line.match(/^RESIZED (\d+) (\d+)$/);
89
+ if (resized) {
90
+ if (bridge.delivery === 'initial'
91
+ && Number(resized[1]) === bridge.cols
92
+ && Number(resized[2]) === bridge.rows) {
93
+ queueRefresh(bridge);
94
+ }
95
+ }
96
+ else if (line) {
97
+ const failed = line.match(/^ERROR (.+)$/);
98
+ if (failed)
99
+ reportStartupError(bridge, failed[1]);
100
+ console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`);
101
+ }
102
+ }
103
+ }
104
+ function reportStartupError(bridge, detail) {
105
+ const subscription = currentSubscription(bridge.id, bridge.viewer);
106
+ if (!subscription || subscription.bridge !== bridge)
107
+ return;
108
+ const message = boundedStartupError(detail);
109
+ bridge.startupError = message;
110
+ if (!message || subscription.startupError === message)
111
+ return;
112
+ subscription.startupError = message;
113
+ deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'));
114
+ }
115
+ function boundedStartupError(detail) {
116
+ return (detail instanceof Error ? detail.message : String(detail))
117
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start';
118
+ }
119
+ function ensureBridge(id, viewer, subscription, cols, rows) {
120
+ if (subscription.bridge)
121
+ return { bridge: subscription.bridge, created: false };
122
+ let proc;
123
+ try {
124
+ proc = spawn(process.execPath, [HELPER, id, String(cols), String(rows)], {
125
+ stdio: ['pipe', 'pipe', 'pipe'],
126
+ env: process.env,
127
+ });
128
+ }
129
+ catch (error) {
130
+ try {
131
+ proc?.kill();
132
+ }
133
+ catch { /* spawn did not complete */ }
134
+ const detail = boundedStartupError(error);
135
+ if (subscription.startupError !== detail) {
136
+ subscription.startupError = detail;
137
+ try {
138
+ viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8'));
139
+ }
140
+ catch { /* socket closed */ }
141
+ }
142
+ return { bridge: null, created: false };
143
+ }
144
+ const bridge = {
145
+ id, viewer, proc, cols, rows, stderr: '', delivery: 'initial',
146
+ refreshBuf: Buffer.alloc(0), refreshPending: false, refreshRunning: false,
147
+ };
148
+ subscription.bridge = bridge;
149
+ proc.stdout.on('data', (data) => onHelperOutput(bridge, data));
150
+ proc.stderr.on('data', (data) => onHelperStderr(bridge, data));
151
+ let reaped = false;
152
+ const gone = (detail) => {
153
+ if (reaped)
154
+ return;
155
+ reaped = true;
156
+ const current = currentSubscription(id, viewer);
157
+ if (current?.bridge !== bridge)
158
+ return;
159
+ if (!bridge.ptyPid && !bridge.startupError)
160
+ reportStartupError(bridge, detail || 'helper exited before native PTY startup');
161
+ current.bridge = undefined;
162
+ clearDelivery(bridge);
163
+ try {
164
+ bridge.proc.kill();
165
+ }
166
+ catch { /* already gone */ }
167
+ scheduleRestore(id, viewer, current);
168
+ };
169
+ proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`));
170
+ proc.on('error', (error) => gone(`helper process failed: ${error.message}`));
171
+ return { bridge, created: true };
172
+ }
173
+ function cancelLinger(subscription) {
174
+ if (subscription.lingerTimer)
175
+ clearTimeout(subscription.lingerTimer);
176
+ subscription.lingerTimer = undefined;
177
+ subscription.lingering = false;
178
+ }
179
+ function cancelRestore(subscription) {
180
+ if (subscription.restoreTimer)
181
+ clearTimeout(subscription.restoreTimer);
182
+ subscription.restoreTimer = undefined;
183
+ }
184
+ function killBridge(subscription) {
185
+ cancelLinger(subscription);
186
+ cancelRestore(subscription);
187
+ const bridge = subscription.bridge;
188
+ if (!bridge)
189
+ return;
190
+ subscription.bridge = undefined;
191
+ clearDelivery(bridge);
192
+ try {
193
+ bridge.proc.stdin.end();
194
+ }
195
+ catch { /* already gone */ }
196
+ const kill = setTimeout(() => { try {
197
+ bridge.proc.kill();
198
+ }
199
+ catch { /* already gone */ } }, 250);
200
+ kill.unref();
201
+ }
202
+ function beginDelivery(bridge) {
203
+ if (bridge.delivery === 'stream')
204
+ bridge.refreshBuf = Buffer.alloc(0);
205
+ bridge.delivery = 'initial';
206
+ bridge.refreshOffset = undefined;
207
+ armDeliveryBoundary(bridge);
208
+ }
209
+ function armDeliveryBoundary(bridge) {
210
+ if (bridge.deliveryTimer)
211
+ clearTimeout(bridge.deliveryTimer);
212
+ bridge.deliveryTimer = setTimeout(() => finishDelivery(bridge), GEOMETRY_STABILIZATION_MS);
213
+ bridge.deliveryTimer.unref();
214
+ }
215
+ function clearDelivery(bridge) {
216
+ bridge.delivery = 'stream';
217
+ if (bridge.deliveryTimer)
218
+ clearTimeout(bridge.deliveryTimer);
219
+ bridge.deliveryTimer = undefined;
220
+ bridge.refreshBuf = Buffer.alloc(0);
221
+ bridge.refreshPending = false;
222
+ bridge.refreshOffset = undefined;
223
+ }
224
+ function completeTransactionEnd(buffer, offset) {
225
+ const begin = buffer.indexOf(BSU, offset);
226
+ const end = begin >= 0 ? buffer.indexOf(ESU, begin + BSU.length) : -1;
227
+ return end >= 0 ? end + ESU.length : undefined;
228
+ }
229
+ function releaseInitialRefresh(bridge) {
230
+ if (bridge.delivery !== 'initial' || bridge.refreshOffset === undefined)
231
+ return;
232
+ const end = completeTransactionEnd(bridge.refreshBuf, bridge.refreshOffset);
233
+ if (end === undefined)
234
+ return;
235
+ const batch = bridge.refreshBuf.subarray(0, end);
236
+ bridge.refreshBuf = bridge.refreshBuf.subarray(end);
237
+ bridge.delivery = 'quarantine';
238
+ commitSize(bridge);
239
+ if (batch.length)
240
+ deliver(bridge, batch);
241
+ }
242
+ function finishDelivery(bridge) {
243
+ if (bridge.delivery === 'stream' || !isCurrent(bridge))
244
+ return;
245
+ const failOpen = bridge.delivery === 'initial';
246
+ const batch = bridge.refreshBuf;
247
+ clearDelivery(bridge);
248
+ if (!failOpen && !batch.length)
249
+ return;
250
+ // The control commit and following binary frame are one browser render transaction, including fail-open.
251
+ commitSize(bridge);
252
+ deliver(bridge, batch);
253
+ }
254
+ async function refreshBridge(bridge) {
255
+ if (!isCurrent(bridge) || !bridge.ptyPid)
256
+ return false;
257
+ const tty = await clientTty(bridge);
258
+ if (!tty)
259
+ return false;
260
+ await tmux(['refresh-client', '-t', tty]);
261
+ return true;
262
+ }
263
+ function queueRefresh(bridge) {
264
+ if (bridge.delivery !== 'initial')
265
+ return;
266
+ bridge.refreshPending = true;
267
+ if (bridge.refreshRunning)
268
+ return;
269
+ bridge.refreshPending = false;
270
+ bridge.refreshRunning = true;
271
+ void refreshBridge(bridge).then((refreshed) => {
272
+ bridge.refreshRunning = false;
273
+ if (!isCurrent(bridge) || bridge.delivery !== 'initial')
274
+ return;
275
+ if (bridge.refreshPending) {
276
+ queueRefresh(bridge);
277
+ return;
278
+ }
279
+ if (!refreshed)
280
+ return;
281
+ bridge.refreshOffset = bridge.refreshBuf.length;
282
+ releaseInitialRefresh(bridge);
283
+ });
284
+ }
285
+ async function clientTty(bridge) {
286
+ if (bridge.clientTty)
287
+ return bridge.clientTty;
288
+ if (!bridge.ptyPid)
289
+ return undefined;
290
+ const clients = await tmux(['list-clients', '-t', bridge.id, '-F', '#{client_pid} #{client_tty}']);
291
+ for (const line of clients.split('\n')) {
292
+ const space = line.indexOf(' ');
293
+ if (space > 0 && Number(line.slice(0, space)) === bridge.ptyPid) {
294
+ return (bridge.clientTty = line.slice(space + 1).trim());
295
+ }
296
+ }
297
+ return undefined;
298
+ }
299
+ function resize(bridge, cols, rows) {
300
+ if (bridge.cols === cols && bridge.rows === rows)
301
+ return;
302
+ beginDelivery(bridge);
303
+ bridge.cols = cols;
304
+ bridge.rows = rows;
305
+ sendControl(bridge, { t: 'resize', cols, rows });
306
+ }
307
+ export function attachViewer(id, viewer) {
308
+ const map = subscriptionMap(id);
309
+ const previous = map.get(viewer);
310
+ if (previous)
311
+ killBridge(previous);
312
+ map.set(viewer, { visible: false, lingering: false, cols: 0, rows: 0 });
313
+ }
314
+ export function hideViewer(id, viewer) {
315
+ const subscription = currentSubscription(id, viewer);
316
+ if (!subscription)
317
+ return;
318
+ subscription.visible = false;
319
+ if (!subscription.bridge)
320
+ return;
321
+ subscription.lingering = true;
322
+ if (subscription.lingerTimer)
323
+ return;
324
+ subscription.lingerTimer = setTimeout(() => {
325
+ subscription.lingerTimer = undefined;
326
+ if (!subscription.visible && currentSubscription(id, viewer) === subscription)
327
+ killBridge(subscription);
328
+ }, LINGER_MS);
329
+ subscription.lingerTimer.unref();
330
+ }
331
+ export function detachViewer(id, viewer) {
332
+ const map = subscribers.get(id);
333
+ const subscription = map?.get(viewer);
334
+ if (!map || !subscription)
335
+ return;
336
+ // A closed/dead socket is not a hidden live tab: remove its native client now, never after linger.
337
+ killBridge(subscription);
338
+ map.delete(viewer);
339
+ if (map.size === 0)
340
+ subscribers.delete(id);
341
+ }
342
+ export function resizeBridge(id, viewer, colsValue, rowsValue) {
343
+ const cols = Math.floor(colsValue), rows = Math.floor(rowsValue);
344
+ if (!(cols > 0 && rows > 0))
345
+ return;
346
+ const subscription = currentSubscription(id, viewer);
347
+ if (!subscription)
348
+ return;
349
+ const seamless = subscription.lingering && !!subscription.bridge
350
+ && subscription.bridge.cols === cols && subscription.bridge.rows === rows;
351
+ cancelLinger(subscription);
352
+ subscription.visible = true;
353
+ subscription.cols = cols;
354
+ subscription.rows = rows;
355
+ const { bridge, created } = ensureBridge(id, viewer, subscription, cols, rows);
356
+ if (!bridge || created || seamless)
357
+ return;
358
+ if (bridge.cols === cols && bridge.rows === rows) {
359
+ beginDelivery(bridge);
360
+ queueRefresh(bridge);
361
+ }
362
+ else {
363
+ resize(bridge, cols, rows);
364
+ }
365
+ }
366
+ const MAX_INPUT_BYTES = 64 * 1024;
367
+ export function forwardInput(id, viewer, data) {
368
+ const subscription = currentSubscription(id, viewer);
369
+ if (!subscription?.visible || !subscription.bridge || !data || Buffer.byteLength(data, 'utf8') > MAX_INPUT_BYTES)
370
+ return false;
371
+ return withSessionInputLock(id, () => {
372
+ sendControl(subscription.bridge, { t: 'input', data });
373
+ return true;
374
+ }) ?? false;
375
+ }
376
+ async function restoreBridge(id, viewer, subscription) {
377
+ subscription.restoreTimer = undefined;
378
+ if (currentSubscription(id, viewer) !== subscription || subscription.bridge || !subscription.visible || !(await alive(id)))
379
+ return;
380
+ if (!(subscription.cols > 0 && subscription.rows > 0))
381
+ return;
382
+ ensureBridge(id, viewer, subscription, subscription.cols, subscription.rows);
383
+ }
384
+ function scheduleRestore(id, viewer, subscription) {
385
+ if (currentSubscription(id, viewer) !== subscription || !subscription.visible || subscription.bridge || subscription.restoreTimer)
386
+ return;
387
+ subscription.restoreTimer = setTimeout(() => void restoreBridge(id, viewer, subscription), 750);
388
+ subscription.restoreTimer.unref();
389
+ }
390
+ let supervising = false;
391
+ export function superviseBridges(intervalMs = 4000) {
392
+ if (supervising)
393
+ return;
394
+ supervising = true;
395
+ const tick = () => {
396
+ for (const [id, viewers] of subscribers) {
397
+ for (const [viewer, subscription] of viewers)
398
+ scheduleRestore(id, viewer, subscription);
399
+ }
400
+ const timer = setTimeout(tick, intervalMs);
401
+ timer.unref();
402
+ };
403
+ tick();
404
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,101 @@
1
+ import * as pty from 'node-pty';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs';
4
+ const [id, colsArg, rowsArg] = process.argv.slice(2);
5
+ const cols = Number(colsArg);
6
+ const rows = Number(rowsArg);
7
+ const socket = process.env.SPEXCODE_TMUX || 'spexcode';
8
+ if (!id || !(cols > 0 && rows > 0)) {
9
+ process.stderr.write('ERROR invalid helper arguments\n');
10
+ process.exit(2);
11
+ }
12
+ // Establish the outer-terminal contract before this process owns a PTY. tmux must preserve hyperlinks and
13
+ // wrap client updates in DEC 2026 for xterm 6; doing the setup before forkpty preserves helper isolation.
14
+ try {
15
+ // The dashboard renders the pane, not tmux's client chrome. Keeping this as a tmux session option makes
16
+ // the requested PTY grid the pane grid too; filtering a coloured final row in xterm would corrupt real
17
+ // pane content. SpexCode owns these sessions, so a later foreground attach sees the same status-free pane.
18
+ execFileSync('tmux', ['-L', socket, 'set-option', '-t', id, 'status', 'off']);
19
+ // Control follows the person actually driving: `latest` sizes the window to the most recently
20
+ // active client, so a small screen takes the grid the moment its user interacts and sees the WHOLE
21
+ // pane, while an idle larger viewer letterboxes. `largest` optimized the big display's real estate
22
+ // but left a small concurrent viewer a cropped corner it could never take back.
23
+ execFileSync('tmux', ['-L', socket, 'set-window-option', '-t', id, 'window-size', 'latest']);
24
+ execFileSync('tmux', ['-L', socket, 'set-option', '-g', 'mouse', 'on']);
25
+ execFileSync('tmux', ['-L', socket, 'set-option', '-g', 'history-limit', '50000']);
26
+ // Wheel routing stays tmux-default: copy-mode history for a plain pane, pass-through to a
27
+ // mouse-owning TUI. Claude's TUI virtual-scrolls its own transcript on those reports (it lives on
28
+ // the alternate screen and repaints in place, so tmux history cannot substitute — an in-place
29
+ // renderer never scrolls lines off the top). Restore the default bindings idempotently in case an
30
+ // earlier release rebound them server-wide; the unbind may already be clean, so it fails quietly.
31
+ execFileSync('tmux', ['-L', socket, 'bind-key', '-n', 'WheelUpPane', 'if', '-F', '-t=', '#{||:#{pane_in_mode},#{mouse_any_flag}}', 'send-keys -M', 'copy-mode -et=']);
32
+ try {
33
+ execFileSync('tmux', ['-L', socket, 'unbind-key', '-n', 'WheelDownPane']);
34
+ }
35
+ catch { /* already unbound */ }
36
+ const features = execFileSync('tmux', ['-L', socket, 'show-options', '-gsv', 'terminal-features'], { encoding: 'utf8' });
37
+ const xtermFeatures = new Set(features.split('\n').filter((line) => line.startsWith('xterm*:')).flatMap((line) => line.split(':').slice(1)));
38
+ const missing = ['sync', 'hyperlinks'].filter((feature) => !xtermFeatures.has(feature));
39
+ if (missing.length) {
40
+ execFileSync('tmux', ['-L', socket, 'set-option', '-as', 'terminal-features', `,xterm*:${missing.join(':')}`]);
41
+ }
42
+ }
43
+ catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
44
+ let terminal;
45
+ try {
46
+ ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native));
47
+ terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
48
+ name: 'xterm-256color',
49
+ cols,
50
+ rows,
51
+ env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
52
+ });
53
+ }
54
+ catch (error) {
55
+ const detail = (error instanceof Error ? error.message : String(error))
56
+ .replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500);
57
+ process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`);
58
+ process.exit(1);
59
+ }
60
+ terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')));
61
+ terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1));
62
+ process.stderr.write(`READY ${terminal.pid}\n`);
63
+ let input = '';
64
+ process.stdin.setEncoding('utf8');
65
+ process.stdin.on('data', (chunk) => {
66
+ input += chunk;
67
+ let newline;
68
+ while ((newline = input.indexOf('\n')) >= 0) {
69
+ const line = input.slice(0, newline);
70
+ input = input.slice(newline + 1);
71
+ if (!line)
72
+ continue;
73
+ try {
74
+ const message = JSON.parse(line);
75
+ if (message?.t === 'resize' && message.cols > 0 && message.rows > 0) {
76
+ const nextCols = Math.floor(message.cols);
77
+ const nextRows = Math.floor(message.rows);
78
+ terminal.resize(nextCols, nextRows);
79
+ process.stderr.write(`RESIZED ${nextCols} ${nextRows}\n`);
80
+ }
81
+ else if (message?.t === 'input' && typeof message.data === 'string' && Buffer.byteLength(message.data, 'utf8') <= 64 * 1024) {
82
+ terminal.write(message.data);
83
+ }
84
+ }
85
+ catch { /* malformed controls cannot become terminal input */ }
86
+ }
87
+ });
88
+ let closing = false;
89
+ function close() {
90
+ if (closing)
91
+ return;
92
+ closing = true;
93
+ try {
94
+ terminal.kill();
95
+ }
96
+ catch { /* already gone */ }
97
+ setTimeout(() => process.exit(0), 100).unref();
98
+ }
99
+ process.stdin.on('end', close);
100
+ process.on('SIGTERM', close);
101
+ process.on('SIGINT', close);
@@ -0,0 +1,2 @@
1
+ export function nodePtySpawnHelperPath(nativeModule: any): string;
2
+ export function ensureExecutableIfPresent(path: any): void;
@@ -0,0 +1,23 @@
1
+ import { chmodSync, statSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ export function nodePtySpawnHelperPath(nativeModule) {
6
+ const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename;
7
+ if (!nativeAddon)
8
+ throw new Error('cannot locate node-pty loaded native addon');
9
+ return join(dirname(nativeAddon), 'spawn-helper');
10
+ }
11
+ export function ensureExecutableIfPresent(path) {
12
+ let mode;
13
+ try {
14
+ mode = statSync(path).mode & 0o777;
15
+ }
16
+ catch (error) {
17
+ if (error?.code === 'ENOENT')
18
+ return;
19
+ throw error;
20
+ }
21
+ if ((mode & 0o111) !== 0o111)
22
+ chmodSync(path, mode | 0o111);
23
+ }
@@ -0,0 +1,46 @@
1
+ export declare const PUBLIC_GRAPH_SCHEMA: "spexcode.public-spec-graph/v1";
2
+ export declare const PUBLIC_GRAPH_PAYLOAD_NAME: "public-graph.json";
3
+ export type PublicGraphNode = Readonly<{
4
+ id: string;
5
+ parent: string | null;
6
+ path: string;
7
+ title: string;
8
+ status: string;
9
+ fmStatus: string | null;
10
+ hue: number;
11
+ desc: string;
12
+ code: readonly string[];
13
+ related: readonly string[];
14
+ version: number;
15
+ lastEdited: string | null;
16
+ drift: number;
17
+ driftFiles: readonly {
18
+ file: string;
19
+ behind: number;
20
+ }[];
21
+ }>;
22
+ export type PublicGraph = Readonly<{
23
+ schema: typeof PUBLIC_GRAPH_SCHEMA;
24
+ payloadName: typeof PUBLIC_GRAPH_PAYLOAD_NAME;
25
+ revision: string;
26
+ sourceRoot: string;
27
+ identity: Readonly<{
28
+ title: string;
29
+ icon: string;
30
+ }>;
31
+ nodes: readonly PublicGraphNode[];
32
+ }>;
33
+ export type PublicGraphDocument = Readonly<{
34
+ schema: 'spexcode.public-spec-document/v1';
35
+ revision: string;
36
+ id: string;
37
+ body: string;
38
+ parts: unknown;
39
+ }>;
40
+ export type PublicGraphArtifact = Readonly<{
41
+ graph: PublicGraph;
42
+ documents: readonly PublicGraphDocument[];
43
+ }>;
44
+ export declare function buildPublicGraphArtifact(): Promise<PublicGraphArtifact>;
45
+ export declare function buildPublicGraph(): Promise<PublicGraph>;
46
+ export declare function publicGraphJson(graph: PublicGraph): string;
@@ -0,0 +1,56 @@
1
+ import { loadSpecs, repoRoot, resolveProjectIdentity, gitA } from '@spexcode/spec-core';
2
+ export const PUBLIC_GRAPH_SCHEMA = 'spexcode.public-spec-graph/v1';
3
+ export const PUBLIC_GRAPH_PAYLOAD_NAME = 'public-graph.json';
4
+ function stableNode(node) {
5
+ return {
6
+ id: node.id,
7
+ parent: node.parent,
8
+ path: node.path,
9
+ title: node.title,
10
+ status: node.status,
11
+ fmStatus: node.fmStatus,
12
+ hue: node.hue,
13
+ desc: node.desc,
14
+ code: [...node.code],
15
+ related: [...node.related],
16
+ version: node.version,
17
+ lastEdited: node.lastEdited,
18
+ drift: node.drift,
19
+ driftFiles: node.driftFiles.map((entry) => ({ file: entry.file, behind: entry.behind })),
20
+ };
21
+ }
22
+ export async function buildPublicGraphArtifact() {
23
+ const root = repoRoot();
24
+ const [nodes, revision] = await Promise.all([
25
+ loadSpecs(root),
26
+ gitA(['-C', root, 'rev-parse', 'HEAD']).then((value) => value.trim()),
27
+ ]);
28
+ if (!revision)
29
+ throw new Error('public graph cannot be published without a Git revision');
30
+ const identity = resolveProjectIdentity(root, root);
31
+ const graph = Object.freeze({
32
+ schema: PUBLIC_GRAPH_SCHEMA,
33
+ payloadName: PUBLIC_GRAPH_PAYLOAD_NAME,
34
+ revision,
35
+ // A public artifact must be relocatable and must not disclose its producer's checkout layout.
36
+ sourceRoot: '.',
37
+ identity: Object.freeze({ title: identity.title, icon: identity.icon }),
38
+ nodes: Object.freeze(nodes.map(stableNode).sort((a, b) => a.path.localeCompare(b.path))),
39
+ });
40
+ const documents = Object.freeze(nodes
41
+ .map((node) => Object.freeze({
42
+ schema: 'spexcode.public-spec-document/v1',
43
+ revision,
44
+ id: node.id,
45
+ body: node.body,
46
+ parts: node.parts,
47
+ }))
48
+ .sort((a, b) => a.id.localeCompare(b.id)));
49
+ return Object.freeze({ graph, documents });
50
+ }
51
+ export async function buildPublicGraph() {
52
+ return (await buildPublicGraphArtifact()).graph;
53
+ }
54
+ export function publicGraphJson(graph) {
55
+ return `${JSON.stringify(graph, null, 2)}\n`;
56
+ }
@@ -0,0 +1,15 @@
1
+ export declare function terms(query: string): string[];
2
+ export type RankInput<T> = {
3
+ ref: T;
4
+ name: string;
5
+ desc: string;
6
+ body: string;
7
+ };
8
+ export type Ranked<T> = {
9
+ ref: T;
10
+ score: number;
11
+ snippet: string;
12
+ };
13
+ export declare function rankDocs<T>(query: string, inputs: RankInput<T>[], opts?: {
14
+ limit?: number;
15
+ }): Ranked<T>[];