@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,179 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { sessionStoreDir } from '@spexcode/spec-core';
3
+ import { advanceFollow, followCursor, unreadSince } from './session-cursors.js';
4
+ import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js';
5
+ import { sessionTitle } from './sessions.js';
6
+ // @@@ session-follow - supervision is FOLLOWING a log past a cursor, never polling a derived board. One tick
7
+ // costs ONE stat per target: if timeline.ndjson has not grown, nothing is opened and nothing is parsed. No
8
+ // call here reaches the backend, a rendezvous socket, or tmux, which is the whole point — M followers over N
9
+ // sessions cost the control plane zero, where the old poll cost one board build (a connect + a tmux spawn per
10
+ // live session) per follower per interval.
11
+ // Actionable = a state whose arrival means "a human/supervisor must now act". `offline` is deliberately ABSENT
12
+ // where the old poll had it: liveness is a present-tense probe derivation, never authored, so it can never
13
+ // appear on a log ([[state]]). A follower learns what a session DECLARED and never that it died.
14
+ const ACTIONABLE = new Set(['review', 'done', 'close-pending', 'error', 'asking']);
15
+ const NEXT = {
16
+ review: 'merge | close',
17
+ done: 'merge | close',
18
+ 'close-pending': 'close',
19
+ error: 'resume (relaunch & retry) | show --capture | close',
20
+ asking: 'send "<msg>" | show --capture',
21
+ idle: 'send "<msg>" | show --capture',
22
+ queued: 'waiting for a free slot — starts automatically | close',
23
+ };
24
+ const trunc = (s, n) => (s.length <= n ? s : `${s.slice(0, n - 1)}…`);
25
+ export function sessionEvent(s) {
26
+ const note = s.note ? ` — note: ${s.note}` : '';
27
+ const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : '';
28
+ return `[spex] ${s.status} · ${sessionTitle(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`;
29
+ }
30
+ // @@@ launchEvent - a session's FIRST sighting, emitted once per id whatever its status, so the stream is a
31
+ // complete lifecycle feed: launched → [transitions] → closed. A launch's own first line is `active` (not
32
+ // actionable), so without this the feed would be blind to new sessions starting.
33
+ export function launchEvent(s) {
34
+ const note = s.note ? ` — note: ${s.note}` : '';
35
+ const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : '';
36
+ return `[spex] launched · ${sessionTitle(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`;
37
+ }
38
+ export async function followSessions(emit, opts) {
39
+ const { targets, self = null, statuses, includeIdle = false, intervalMs = 1000, as, take = false, timeoutMs = 1_200_000, onObserved, row } = opts;
40
+ const tag = as ? `[${as}] ` : '';
41
+ const state = new Map();
42
+ const memo = new Map(); // cursors for a follower that has no record of its own to store them in
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ // the no-hang wall: a fixed deadline computed ONCE, checked before EVERY sleep below.
45
+ const deadline = take ? Date.now() + Math.max(1000, timeoutMs) : 0;
46
+ const isActionable = (st) => ACTIONABLE.has(st) || (includeIdle && st === 'idle');
47
+ const passes = (st) => !statuses?.length || statuses.includes(st);
48
+ const anyPath = () => {
49
+ for (const f of state.values())
50
+ if (f.path.length)
51
+ return f.path;
52
+ return [];
53
+ };
54
+ const readCursor = (id) => (self ? followCursor(self, id) : memo.get(id) ?? null);
55
+ const writeCursor = (id, to) => { if (self)
56
+ advanceFollow(self, id, to);
57
+ else
58
+ memo.set(id, to); };
59
+ const line = (id, st, note, first) => {
60
+ const s = row?.(id, st, note);
61
+ if (!s)
62
+ return;
63
+ if (first)
64
+ emit(tag + launchEvent(s));
65
+ else if (passes(st) && isActionable(st))
66
+ emit(tag + sessionEvent(s));
67
+ };
68
+ let first = true;
69
+ let shown = -1; // stream mode: the last inbox line this process printed (see the INBOX read below)
70
+ for (;;) {
71
+ const ids = targets();
72
+ const seen = new Set();
73
+ for (const id of ids) {
74
+ if (id === self)
75
+ continue; // the follower's own log is its INBOX, followed below on its own cursor rule
76
+ seen.add(id);
77
+ if (!existsSync(sessionStoreDir(id))) {
78
+ if (state.delete(id))
79
+ emit(`${tag}[spex] closed · removed [id ${id}]`);
80
+ if (take)
81
+ return { gone: id };
82
+ continue;
83
+ }
84
+ let f = state.get(id);
85
+ if (!f) {
86
+ const evs = timelineEvents(id);
87
+ // A stored cursor always wins — that IS the resume, losing nothing across a follower's death. With
88
+ // none: a target already present when the follow started predates it, so we begin at the log's END
89
+ // (its history is not news); a target that appears on a LATER tick genuinely just launched, so its
90
+ // whole log is read from 0 and its `launched` line reaches the feed.
91
+ const pos = readCursor(id) ?? (first ? evs.length : 0);
92
+ // The ARRIVAL state is the last status line BEFORE the cursor — a fact on the log, not whatever a poll
93
+ // happened to catch. With no such line the first status we read is the arrival instead.
94
+ let prev = null;
95
+ for (let i = Math.min(pos, evs.length) - 1; i >= 0; i--) {
96
+ const e = evs[i];
97
+ if (e.kind === 'status') {
98
+ prev = timelineDisplay(e);
99
+ break;
100
+ }
101
+ }
102
+ f = { pos, prev, path: prev ? [prev] : [], stamp: null };
103
+ state.set(id, f);
104
+ if (prev) {
105
+ onObserved?.(id, prev, null);
106
+ line(id, prev, null, true);
107
+ }
108
+ }
109
+ const stamp = timelineStamp(id);
110
+ if (stamp !== null && stamp === f.stamp)
111
+ continue; // THE cheap tick: nothing appended, nothing parsed
112
+ f.stamp = stamp;
113
+ const evs = timelineEvents(id);
114
+ const slice = unreadSince(evs, f.pos);
115
+ let hit = null;
116
+ for (let k = 0; k < slice.events.length && !hit; k++) {
117
+ const e = slice.events[k];
118
+ if (e.kind === 'sent') {
119
+ const s = row?.(id, f.prev ?? 'unknown', null);
120
+ if (s)
121
+ emit(`${tag}[spex] message · ${sessionTitle(s)} — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${id}]`);
122
+ continue;
123
+ }
124
+ const st = timelineDisplay(e);
125
+ const was = f.prev;
126
+ f.prev = st;
127
+ f.path.push(st);
128
+ onObserved?.(id, st, was);
129
+ line(id, st, e.note, was === null);
130
+ // THE edge: a previously-observed NON-actionable status moving INTO an actionable one. An actionable
131
+ // ARRIVAL (was === null) is not an edge — that standing level is what a level-triggered wait falsely
132
+ // returned on; nor is an actionable→actionable hop (review→done). The rise OUT of non-actionable is the
133
+ // one signal that means "the target needs you AGAIN". The previous state is the previous LINE, so two
134
+ // moves inside one tick are two edges rather than one collapsed sample.
135
+ if (take && was !== null && !isActionable(was) && isActionable(st))
136
+ hit = { reached: st, id, path: f.path };
137
+ // Consume exactly up to the event we stopped on, so the lines behind it stay unread for the next wait.
138
+ if (hit) {
139
+ f.pos = slice.at[k] + 1;
140
+ writeCursor(id, f.pos);
141
+ }
142
+ }
143
+ if (hit)
144
+ return hit;
145
+ f.pos = slice.next;
146
+ writeCursor(id, f.pos);
147
+ }
148
+ for (const id of [...state.keys()]) {
149
+ if (seen.has(id))
150
+ continue;
151
+ state.delete(id);
152
+ emit(`${tag}[spex] closed · removed [id ${id}]`);
153
+ }
154
+ // THE FOLLOWER'S OWN LOG — watched exactly like any other target, on its own entry in `cursors.json`. It is
155
+ // a WATCH, not a delivery: a message reaches the agent as a prompt through the adapter ([[delivery-queue]]),
156
+ // so this position only decides what THIS process has already reported and can never make an agent miss
157
+ // mail. Never advanced here in take mode — the waiter stops on the event and the next wait resumes on it.
158
+ if (self && existsSync(sessionStoreDir(self))) {
159
+ const mine = unreadSince(timelineEvents(self), followCursor(self, self) ?? 0);
160
+ for (let k = 0; k < mine.events.length; k++) {
161
+ const e = mine.events[k];
162
+ if (e.kind !== 'sent')
163
+ continue;
164
+ if (take)
165
+ return { mail: { from: e.from, text: e.text } };
166
+ // a stream has no turn boundary to advance that cursor, so its own high-water mark — process-local,
167
+ // never written — is what keeps an unread line from being re-printed on every tick.
168
+ if (mine.at[k] <= shown)
169
+ continue;
170
+ shown = mine.at[k];
171
+ emit(`${tag}[spex] message · you — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${self}]`);
172
+ }
173
+ }
174
+ first = false;
175
+ if (take && Date.now() >= deadline)
176
+ return { timedOut: true, path: anyPath() };
177
+ await sleep(intervalMs);
178
+ }
179
+ }
@@ -0,0 +1,10 @@
1
+ import { type SessionReparentResult } from './sessions.js';
2
+ export declare class SessionReparentRequestError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function parseReparentRequest(body: unknown): {
6
+ children: string[];
7
+ parent: string | null;
8
+ };
9
+ export declare function reparentRequest(body: unknown): Promise<SessionReparentResult>;
10
+ export declare function reparentSessions(children: string[], parent: string | null): Promise<SessionReparentResult>;
@@ -0,0 +1,33 @@
1
+ import { apiBaseInfo, reparentSessionRecords } from './sessions.js';
2
+ import { BackendError, backendConnectionRefused, clientReparent } from './client.js';
3
+ export class SessionReparentRequestError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'SessionReparentRequestError';
7
+ }
8
+ }
9
+ export function parseReparentRequest(body) {
10
+ if (!body || typeof body !== 'object')
11
+ throw new SessionReparentRequestError('reparent body must be JSON');
12
+ const raw = body;
13
+ if (!Array.isArray(raw.children) || !raw.children.length || raw.children.some((id) => typeof id !== 'string' || !id.trim()))
14
+ throw new SessionReparentRequestError('reparent children must be one or more session ids');
15
+ if (raw.parent !== null && (typeof raw.parent !== 'string' || !raw.parent.trim()))
16
+ throw new SessionReparentRequestError('reparent parent must be a session id or null');
17
+ return { children: raw.children.map((id) => id.trim()), parent: raw.parent === null ? null : raw.parent.trim() };
18
+ }
19
+ export async function reparentRequest(body) {
20
+ const request = parseReparentRequest(body);
21
+ return reparentSessionRecords(request.children, request.parent);
22
+ }
23
+ export async function reparentSessions(children, parent) {
24
+ try {
25
+ return await clientReparent(children, parent);
26
+ }
27
+ catch (error) {
28
+ if (!(error instanceof BackendError) || (await apiBaseInfo()).source === 'flag' || !backendConnectionRefused(error))
29
+ throw error;
30
+ console.error('spex: no backend reachable — reparenting in-process under the session record locks');
31
+ return reparentSessionRecords(children, parent);
32
+ }
33
+ }
@@ -0,0 +1,51 @@
1
+ import type { Lifecycle, Proposal } from './sessions.js';
2
+ import type { ExecutionTurn } from './execution-trace.js';
3
+ export type TimelineEvent = {
4
+ ts: string;
5
+ kind: 'status';
6
+ status: Lifecycle;
7
+ proposal: Proposal | null;
8
+ note: string | null;
9
+ display?: string;
10
+ } | {
11
+ ts: string;
12
+ kind: 'sent';
13
+ mid: string;
14
+ text: string;
15
+ from: string | null;
16
+ replyVia?: 'note';
17
+ };
18
+ export type SentDispatchReceipt = {
19
+ operation: 'merge';
20
+ requestDigest: string;
21
+ payloadHash: string;
22
+ delivery?: {
23
+ text: string;
24
+ from: string | null;
25
+ };
26
+ };
27
+ export declare function recordStatus(id: string, status: Lifecycle, proposal: Proposal | null, note: string | null): void;
28
+ export declare function appendSent(id: string, text: string, from: string | null, replyVia?: 'note', dispatchReceipt?: SentDispatchReceipt): {
29
+ mid: string;
30
+ };
31
+ export type SentDispatchState = {
32
+ mid: string;
33
+ payloadHash: string;
34
+ delivery: SentDispatchReceipt['delivery'] | null;
35
+ delivered: boolean;
36
+ };
37
+ export declare function sentDispatchReceipt(id: string, operation: SentDispatchReceipt['operation'], requestDigest: string): SentDispatchState | null;
38
+ export declare function settleSentDispatch(id: string, mid: string): void;
39
+ export declare function timelineEvents(id: string): TimelineEvent[];
40
+ export declare function timelineStamp(id: string): string | null;
41
+ type DisplayWord = 'working' | 'idle' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued';
42
+ export declare const timelineDisplay: (e: {
43
+ status: Lifecycle;
44
+ proposal: Proposal | null;
45
+ }) => DisplayWord;
46
+ export declare function lastHumanSendVia(id: string): 'note' | null;
47
+ export declare function currentHumanTurn(id: string): ExecutionTurn | null;
48
+ export declare function readTimeline(id: string, limit?: number): {
49
+ events: TimelineEvent[];
50
+ } | null;
51
+ export {};
@@ -0,0 +1,233 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync, readdirSync, openSync, closeSync, readSync } from 'node:fs';
3
+ import { basename, join } from 'node:path';
4
+ import { sessionStoreDir, sessionArtifactPath, readAliasedRawRecord } from '@spexcode/spec-core';
5
+ const timelinePath = (id) => sessionArtifactPath(id, 'timeline.ndjson');
6
+ const segmentsDir = (id) => sessionArtifactPath(id, 'timeline');
7
+ const SEGMENT = /^(\d+)\.ndjson$/;
8
+ const SEGMENT_NAME_WIDTH = 12;
9
+ const TAIL_BLOCK_BYTES = 64 * 1024;
10
+ // One logical timeline is legacy timeline.ndjson followed by immutable numbered segments. The directory
11
+ // listing is its only index: numbering is append order, so there is no mutable manifest to repair.
12
+ function segmentFiles(id) {
13
+ try {
14
+ const dir = segmentsDir(id);
15
+ const names = readdirSync(dir).filter((name) => SEGMENT.test(name)).sort((a, b) => {
16
+ const an = BigInt(SEGMENT.exec(a)[1]), bn = BigInt(SEGMENT.exec(b)[1]);
17
+ return an < bn ? -1 : an > bn ? 1 : 0;
18
+ });
19
+ return names.map((name) => join(dir, name));
20
+ }
21
+ catch { /* no numbered segments yet */ }
22
+ return [];
23
+ }
24
+ function timelineFiles(id) {
25
+ const files = [];
26
+ const legacy = timelinePath(id);
27
+ if (existsSync(legacy))
28
+ files.push(legacy);
29
+ files.push(...segmentFiles(id));
30
+ return files;
31
+ }
32
+ const segmentLimit = () => {
33
+ const configured = Number(process.env.SPEXCODE_TIMELINE_SEGMENT_BYTES);
34
+ return Number.isFinite(configured) ? Math.max(1024, Math.floor(configured)) : 4 * 1024 * 1024;
35
+ };
36
+ function activeSegment(id, bytes) {
37
+ const dir = segmentsDir(id);
38
+ mkdirSync(dir, { recursive: true });
39
+ const segments = segmentFiles(id);
40
+ const current = segments.at(-1);
41
+ if (!current)
42
+ return join(dir, `${String(1).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`);
43
+ try {
44
+ if (statSync(current).size === 0 || statSync(current).size + bytes <= segmentLimit())
45
+ return current;
46
+ }
47
+ catch { /* a vanished active segment is recreated under its next number */ }
48
+ const n = BigInt(SEGMENT.exec(basename(current))[1]) + 1n;
49
+ return join(dir, `${String(n).padStart(SEGMENT_NAME_WIDTH, '0')}.ndjson`);
50
+ }
51
+ function append(id, ev) {
52
+ mkdirSync(sessionStoreDir(id), { recursive: true });
53
+ const line = JSON.stringify(ev) + '\n';
54
+ appendFileSync(activeSegment(id, Buffer.byteLength(line)), line);
55
+ }
56
+ function parseLines(lines) {
57
+ return lines.map((l) => {
58
+ try {
59
+ return JSON.parse(l);
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }).filter((e) => e != null && (e.kind === 'status' || e.kind === 'sent' || e.kind === 'dispatch-settled'));
65
+ }
66
+ function tailPublicEvents(path, limit) {
67
+ let fd = null;
68
+ try {
69
+ const size = statSync(path).size;
70
+ fd = openSync(path, 'r');
71
+ let start = size;
72
+ let text = '';
73
+ while (start > 0) {
74
+ const next = Math.max(0, start - TAIL_BLOCK_BYTES);
75
+ const buf = Buffer.alloc(start - next);
76
+ readSync(fd, buf, 0, buf.length, next);
77
+ text = buf.toString('utf8') + text;
78
+ const publicCount = parseLines(text.split('\n').filter(Boolean)).filter((event) => event.kind !== 'dispatch-settled').length;
79
+ if (publicCount >= limit || next === 0)
80
+ break;
81
+ start = next;
82
+ }
83
+ return parseLines(text.split('\n').filter(Boolean))
84
+ .filter((event) => event.kind !== 'dispatch-settled')
85
+ .slice(-limit);
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ finally {
91
+ if (fd !== null)
92
+ closeSync(fd);
93
+ }
94
+ }
95
+ // Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
96
+ // synchronously before returning, so a later write cannot erase an intermediate declaration note from the
97
+ // conversation. Best-effort: history is an accessory to the state machine, and failing to write it must never
98
+ // break the transition that already happened.
99
+ export function recordStatus(id, status, proposal, note) {
100
+ try {
101
+ append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note });
102
+ }
103
+ catch { /* the record already moved; the history line is the only loss */ }
104
+ }
105
+ // The DELIVERY ([[dispatch]]): appending this line IS the send, so unlike a status line it must fail LOUD —
106
+ // the caller reports the throw rather than a false success. `text` is the message BEFORE any mechanism insert
107
+ // (hints are transport, not conversation); `replyVia` is the effective channel the prompt seam chose. Returns
108
+ // the new line's `mid`, which a best-effort poke carries.
109
+ export function appendSent(id, text, from, replyVia, dispatchReceipt) {
110
+ const mid = randomUUID();
111
+ append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}), ...(dispatchReceipt ? { dispatchReceipt } : {}) });
112
+ return { mid };
113
+ }
114
+ export function sentDispatchReceipt(id, operation, requestDigest) {
115
+ let found = null;
116
+ const settled = new Set();
117
+ for (const path of timelineFiles(id)) {
118
+ for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
119
+ if (event.kind === 'sent' && !found && event.dispatchReceipt?.operation === operation && event.dispatchReceipt.requestDigest === requestDigest) {
120
+ found = { mid: event.mid, payloadHash: event.dispatchReceipt.payloadHash, delivery: event.dispatchReceipt.delivery ?? null };
121
+ }
122
+ else if (event.kind === 'dispatch-settled' && event.operation === operation && event.requestDigest === requestDigest) {
123
+ settled.add(event.mid);
124
+ }
125
+ }
126
+ }
127
+ return found ? { ...found, delivered: settled.has(found.mid) } : null;
128
+ }
129
+ export function settleSentDispatch(id, mid) {
130
+ let receipt = null;
131
+ let settled = false;
132
+ for (const path of timelineFiles(id)) {
133
+ for (const event of parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))) {
134
+ if (event.kind === 'sent' && event.mid === mid && event.dispatchReceipt?.delivery)
135
+ receipt = event.dispatchReceipt;
136
+ if (event.kind === 'dispatch-settled' && event.mid === mid)
137
+ settled = true;
138
+ }
139
+ }
140
+ if (!receipt || settled)
141
+ return;
142
+ append(id, { ts: new Date().toISOString(), kind: 'dispatch-settled', operation: receipt.operation, requestDigest: receipt.requestDigest, mid });
143
+ }
144
+ // The unowned read: any process may take it with nothing but filesystem access, and taking it perturbs
145
+ // nothing. Index = event position, which is what a cursor names ([[session-cursors]]).
146
+ export function timelineEvents(id) {
147
+ try {
148
+ return timelineFiles(id).flatMap((path) => parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)))
149
+ .flatMap((stored) => {
150
+ if (stored.kind === 'dispatch-settled')
151
+ return [];
152
+ if (stored.kind === 'status')
153
+ return [stored];
154
+ const { dispatchReceipt: _receipt, ...event } = stored;
155
+ return [event];
156
+ });
157
+ }
158
+ catch {
159
+ return [];
160
+ }
161
+ }
162
+ // the same L0 read taken as CHEAPLY as it can be: a follower ([[session-follow]]) ticks over many logs, so it
163
+ // stats first and parses only what grew. null = no log yet (a session that has authored nothing).
164
+ export function timelineStamp(id) {
165
+ try {
166
+ const path = timelineFiles(id).at(-1);
167
+ if (!path)
168
+ return null;
169
+ const s = statSync(path);
170
+ return `${path}:${s.size}:${s.mtimeMs}`;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ // the display word for an authored state — the SAME vocabulary every other surface speaks (awaiting → its
177
+ // proposal's label, active → working), duplicated here as a tiny read-time map rather than importing the state
178
+ // machine (sessions.ts imports THIS module for appendSent; a value import back would be a cycle — the
179
+ // Lifecycle/Proposal imports above are type-only, erased at runtime).
180
+ const PROPOSAL_DISPLAY = { merge: 'review', nothing: 'done', close: 'close-pending' };
181
+ export const timelineDisplay = (e) => e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
182
+ : e.status === 'active' ? 'working' : e.status;
183
+ // the channel of the LAST HUMAN send (from == null): 'note' when the note-reply hint rode along, else null.
184
+ // This is what makes the reply-channel hints SYMMETRIC ([[session-timeline]]): a human send with no note flag
185
+ // arriving after a note-send is the "back at a terminal" transition, and the delivery gets the counter-insert.
186
+ // Derived from the durable log — no new state, and it survives a server restart. Agent senders (`from` set)
187
+ // say nothing about where the HUMAN is reading, so they neither set nor clear it.
188
+ export function lastHumanSendVia(id) {
189
+ const evs = timelineEvents(id);
190
+ for (let i = evs.length - 1; i >= 0; i--) {
191
+ const e = evs[i];
192
+ if (e.kind === 'sent' && e.from == null)
193
+ return e.replyVia === 'note' ? 'note' : null;
194
+ }
195
+ return null;
196
+ }
197
+ // The current human turn is a durable fact from the accepted-message log, not a backend-local generation.
198
+ export function currentHumanTurn(id) {
199
+ const evs = timelineEvents(id);
200
+ for (let i = evs.length - 1; i >= 0; i--) {
201
+ const e = evs[i];
202
+ if (e.kind === 'sent' && e.from == null)
203
+ return { token: e.mid, acceptedAt: e.ts };
204
+ }
205
+ return null;
206
+ }
207
+ // the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each status
208
+ // event carrying its composed display word. null = no such session (the route 404s).
209
+ export function readTimeline(id, limit = 500) {
210
+ let raw;
211
+ try {
212
+ raw = readAliasedRawRecord(id);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ if (!raw || !raw.governed)
218
+ return null;
219
+ const wanted = Math.max(1, limit);
220
+ const tail = [];
221
+ for (const path of timelineFiles(id).reverse()) {
222
+ const remaining = wanted - tail.length;
223
+ if (remaining <= 0)
224
+ break;
225
+ tail.unshift(...tailPublicEvents(path, remaining));
226
+ }
227
+ return { events: tail.map((e) => {
228
+ if (e.kind === 'status')
229
+ return { ...e, display: timelineDisplay(e) };
230
+ const { dispatchReceipt: _receipt, ...event } = e;
231
+ return event;
232
+ }) };
233
+ }
@@ -0,0 +1,24 @@
1
+ export declare class SessionWebError extends Error {
2
+ readonly status: 400 | 403 | 404 | 500;
3
+ constructor(status: 400 | 403 | 404 | 500, message: string);
4
+ }
5
+ type SessionWebLock = <T>(id: string, body: () => T) => T;
6
+ export type SessionWeb = {
7
+ url: string;
8
+ key: string;
9
+ };
10
+ export declare function canonicalSessionWebUrl(input: string): string;
11
+ export declare function sessionWebKey(url: string): string;
12
+ export declare function listSessionWebs(id: string, projectRoot?: string): SessionWeb[];
13
+ export declare function readSessionWebs(id: string, projectRoot?: string): SessionWeb[];
14
+ export declare function addSessionWeb(id: string, input: string, lock: SessionWebLock): {
15
+ url: string;
16
+ added: boolean;
17
+ };
18
+ export declare function retractSessionWeb(id: string, input: string, lock: SessionWebLock): {
19
+ url: string;
20
+ removed: boolean;
21
+ };
22
+ export declare function postedSessionWeb(id: string, key: string, projectRoot?: string): URL;
23
+ export declare const sessionWebsPath: (id: string) => string;
24
+ export {};
@@ -0,0 +1,141 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { readRecordEntry, sessionArtifactPath, sessionStoreDir } from '@spexcode/spec-core';
5
+ import { projectRuntimeRoot } from '@spexcode/spec-core';
6
+ export class SessionWebError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message);
10
+ this.status = status;
11
+ this.name = 'SessionWebError';
12
+ }
13
+ }
14
+ const webPath = (id, projectRoot) => projectRoot
15
+ ? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'web.json')
16
+ : sessionArtifactPath(id, 'web.json');
17
+ const recordPath = (id, projectRoot) => projectRoot
18
+ ? join(projectRuntimeRoot(join(projectRoot, '.git')), 'sessions', id, 'session.json')
19
+ : join(sessionStoreDir(id), 'session.json');
20
+ function validSessionId(id) {
21
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id);
22
+ }
23
+ function requireSession(id, projectRoot) {
24
+ if (!validSessionId(id))
25
+ throw new SessionWebError(404, `session ${id} does not exist`);
26
+ if (!projectRoot) {
27
+ const record = readRecordEntry(id);
28
+ if (record.kind === 'absent')
29
+ throw new SessionWebError(404, `session ${id} does not exist`);
30
+ if (record.kind === 'corrupt')
31
+ throw new SessionWebError(500, `session ${id} has an unreadable record: ${record.error}`);
32
+ return;
33
+ }
34
+ const path = recordPath(id, projectRoot);
35
+ let record;
36
+ try {
37
+ record = JSON.parse(readFileSync(path, 'utf8'));
38
+ }
39
+ catch (error) {
40
+ if (error.code === 'ENOENT')
41
+ throw new SessionWebError(404, `session ${id} does not exist`);
42
+ throw new SessionWebError(500, `session ${id} has an unreadable record: ${error instanceof Error ? error.message : String(error)}`);
43
+ }
44
+ if (!record || typeof record !== 'object' || record.session_id !== id)
45
+ throw new SessionWebError(500, `session ${id} has an unreadable record`);
46
+ }
47
+ export function canonicalSessionWebUrl(input) {
48
+ let url;
49
+ try {
50
+ url = new URL(input.trim());
51
+ }
52
+ catch {
53
+ throw new SessionWebError(400, `invalid local web URL: ${input}`);
54
+ }
55
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
56
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '::1'].includes(host) || !url.port || url.username || url.password) {
57
+ throw new SessionWebError(400, 'web URL must be http://127.0.0.1:<port>/..., http://localhost:<port>/..., or http://[::1]:<port>/...');
58
+ }
59
+ if (url.search || url.hash)
60
+ throw new SessionWebError(400, 'web URL must not include a query or fragment; publish the service base URL');
61
+ return url.href;
62
+ }
63
+ export function sessionWebKey(url) {
64
+ return createHash('sha256').update(url).digest('base64url');
65
+ }
66
+ function readWebs(id, projectRoot) {
67
+ const path = webPath(id, projectRoot);
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
71
+ }
72
+ catch (error) {
73
+ if (error.code === 'ENOENT')
74
+ return [];
75
+ throw new SessionWebError(500, `session web list is unreadable: ${path} — ${error instanceof Error ? error.message : String(error)}`);
76
+ }
77
+ if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== 'string'))
78
+ throw new SessionWebError(500, `session web list is invalid: ${path}`);
79
+ try {
80
+ if (parsed.some((value) => canonicalSessionWebUrl(value) !== value))
81
+ throw new Error('not canonical');
82
+ }
83
+ catch {
84
+ throw new SessionWebError(500, `session web list is invalid: ${path}`);
85
+ }
86
+ return [...new Set(parsed)];
87
+ }
88
+ function writeWebs(id, webs) {
89
+ const path = webPath(id);
90
+ const tmp = `${path}.${process.pid}.tmp`;
91
+ try {
92
+ writeFileSync(tmp, `${JSON.stringify(webs, null, 2)}\n`);
93
+ renameSync(tmp, path);
94
+ }
95
+ finally {
96
+ try {
97
+ unlinkSync(tmp);
98
+ }
99
+ catch { /* rename already consumed the temporary file */ }
100
+ }
101
+ }
102
+ const toWeb = (url) => ({ url, key: sessionWebKey(url) });
103
+ export function listSessionWebs(id, projectRoot) {
104
+ requireSession(id, projectRoot);
105
+ return readSessionWebs(id, projectRoot);
106
+ }
107
+ // Session projections already hold a parsed record; re-checking session.json here would make an in-memory
108
+ // projection depend on the public route's existence guard. The route-facing list keeps that guard above.
109
+ export function readSessionWebs(id, projectRoot) {
110
+ return readWebs(id, projectRoot).map(toWeb);
111
+ }
112
+ export function addSessionWeb(id, input, lock) {
113
+ const url = canonicalSessionWebUrl(input);
114
+ return lock(id, () => {
115
+ requireSession(id);
116
+ const webs = readWebs(id);
117
+ if (webs.includes(url))
118
+ return { url, added: false };
119
+ writeWebs(id, [...webs, url]);
120
+ return { url, added: true };
121
+ });
122
+ }
123
+ export function retractSessionWeb(id, input, lock) {
124
+ const url = canonicalSessionWebUrl(input);
125
+ return lock(id, () => {
126
+ requireSession(id);
127
+ const webs = readWebs(id);
128
+ if (!webs.includes(url))
129
+ return { url, removed: false };
130
+ writeWebs(id, webs.filter((web) => web !== url));
131
+ return { url, removed: true };
132
+ });
133
+ }
134
+ export function postedSessionWeb(id, key, projectRoot) {
135
+ requireSession(id, projectRoot);
136
+ const web = readWebs(id, projectRoot).map(toWeb).find((entry) => entry.key === key);
137
+ if (!web)
138
+ throw new SessionWebError(403, 'that web service was not posted by this session');
139
+ return new URL(web.url);
140
+ }
141
+ export const sessionWebsPath = (id) => webPath(id);