spexcode 0.5.8 → 0.5.9
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.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { sessionStoreDir } from './layout.js'
|
|
3
|
+
import { advanceFollow, followCursor, inboxCursor, unreadSince } from './session-cursors.js'
|
|
4
|
+
import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
|
|
5
|
+
import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
|
|
6
|
+
|
|
7
|
+
// @@@ session-follow - supervision is FOLLOWING a log past a cursor, never polling a derived board. One tick
|
|
8
|
+
// costs ONE stat per target: if timeline.ndjson has not grown, nothing is opened and nothing is parsed. No
|
|
9
|
+
// call here reaches the backend, a rendezvous socket, or tmux, which is the whole point — M followers over N
|
|
10
|
+
// sessions cost the control plane zero, where the old poll cost one board build (a connect + a tmux spawn per
|
|
11
|
+
// live session) per follower per interval.
|
|
12
|
+
|
|
13
|
+
// Actionable = a state whose arrival means "a human/supervisor must now act". `offline` is deliberately ABSENT
|
|
14
|
+
// where the old poll had it: liveness is a present-tense probe derivation, never authored, so it can never
|
|
15
|
+
// appear on a log ([[state]]). A follower learns what a session DECLARED and never that it died.
|
|
16
|
+
const ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'error', 'asking'])
|
|
17
|
+
const NEXT: Record<string, string> = {
|
|
18
|
+
review: 'merge | close',
|
|
19
|
+
done: 'merge | close',
|
|
20
|
+
'close-pending': 'close',
|
|
21
|
+
error: 'resume (relaunch & retry) | show --capture | close',
|
|
22
|
+
asking: 'send "<msg>" | show --capture',
|
|
23
|
+
idle: 'send "<msg>" | show --capture',
|
|
24
|
+
queued: 'waiting for a free slot — starts automatically | close',
|
|
25
|
+
}
|
|
26
|
+
const trunc = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(0, n - 1)}…`)
|
|
27
|
+
|
|
28
|
+
export function sessionEvent(s: Session): string {
|
|
29
|
+
const note = s.note ? ` — note: ${s.note}` : ''
|
|
30
|
+
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
31
|
+
return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
|
|
32
|
+
}
|
|
33
|
+
// @@@ launchEvent - a session's FIRST sighting, emitted once per id whatever its status, so the stream is a
|
|
34
|
+
// complete lifecycle feed: launched → [transitions] → closed. A launch's own first line is `active` (not
|
|
35
|
+
// actionable), so without this the feed would be blind to new sessions starting.
|
|
36
|
+
export function launchEvent(s: Session): string {
|
|
37
|
+
const note = s.note ? ` — note: ${s.note}` : ''
|
|
38
|
+
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
39
|
+
return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// `targets` is re-read every tick so a BROAD follow picks up sessions that launch while it runs; an explicit
|
|
43
|
+
// selector resolves once and hands back a fixed list. `self` is the follower's own session: its inbox rides the
|
|
44
|
+
// same follow, and it is where the durable cursors live — a follower with no session record (a human shell)
|
|
45
|
+
// keeps them in memory for the life of the process.
|
|
46
|
+
export type FollowOpts = {
|
|
47
|
+
targets: () => string[]
|
|
48
|
+
self?: string | null
|
|
49
|
+
statuses?: string[]
|
|
50
|
+
includeIdle?: boolean
|
|
51
|
+
intervalMs?: number
|
|
52
|
+
as?: string
|
|
53
|
+
take?: boolean
|
|
54
|
+
timeoutMs?: number
|
|
55
|
+
onObserved?: (id: string, status: DisplayStatus, previous: DisplayStatus | null) => void
|
|
56
|
+
row?: (id: string, status: DisplayStatus, note: string | null) => Session | null
|
|
57
|
+
}
|
|
58
|
+
// Only `take` mode (what `spex session wait` runs) resolves; a stream follow never returns. There is no
|
|
59
|
+
// transport outcome, because there is no transport: following is reading a file, so the failure that once
|
|
60
|
+
// needed its own vocabulary — a backend that could not be reached, misread as a session verdict — cannot occur.
|
|
61
|
+
export type FollowOutcome =
|
|
62
|
+
| { reached: DisplayStatus; id: string; path: DisplayStatus[] }
|
|
63
|
+
| { mail: { from: string | null; text: string } }
|
|
64
|
+
| { timedOut: true; path: DisplayStatus[] }
|
|
65
|
+
| { gone: string }
|
|
66
|
+
|
|
67
|
+
type FollowState = { pos: number; prev: DisplayStatus | null; path: DisplayStatus[]; stamp: string | null }
|
|
68
|
+
|
|
69
|
+
export async function followSessions(emit: (line: string) => void, opts: FollowOpts): Promise<FollowOutcome> {
|
|
70
|
+
const { targets, self = null, statuses, includeIdle = false, intervalMs = 1000, as, take = false, timeoutMs = 1_200_000, onObserved, row } = opts
|
|
71
|
+
const tag = as ? `[${as}] ` : ''
|
|
72
|
+
const state = new Map<string, FollowState>()
|
|
73
|
+
const memo = new Map<string, number>() // cursors for a follower that has no record of its own to store them in
|
|
74
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
75
|
+
// the no-hang wall: a fixed deadline computed ONCE, checked before EVERY sleep below.
|
|
76
|
+
const deadline = take ? Date.now() + Math.max(1000, timeoutMs) : 0
|
|
77
|
+
const isActionable = (st: DisplayStatus) => ACTIONABLE.has(st) || (includeIdle && st === 'idle')
|
|
78
|
+
const passes = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
79
|
+
const anyPath = (): DisplayStatus[] => {
|
|
80
|
+
for (const f of state.values()) if (f.path.length) return f.path
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
const readCursor = (id: string): number | null => (self ? followCursor(self, id) : memo.get(id) ?? null)
|
|
84
|
+
const writeCursor = (id: string, to: number): void => { if (self) advanceFollow(self, id, to); else memo.set(id, to) }
|
|
85
|
+
const line = (id: string, st: DisplayStatus, note: string | null, first: boolean): void => {
|
|
86
|
+
const s = row?.(id, st, note)
|
|
87
|
+
if (!s) return
|
|
88
|
+
if (first) emit(tag + launchEvent(s))
|
|
89
|
+
else if (passes(st) && isActionable(st)) emit(tag + sessionEvent(s))
|
|
90
|
+
}
|
|
91
|
+
let first = true
|
|
92
|
+
let shown = -1 // stream mode: the last inbox line this process printed (see the INBOX read below)
|
|
93
|
+
|
|
94
|
+
for (;;) {
|
|
95
|
+
const ids = targets()
|
|
96
|
+
const seen = new Set<string>()
|
|
97
|
+
for (const id of ids) {
|
|
98
|
+
if (id === self) continue // the follower's own log is its INBOX, followed below on its own cursor rule
|
|
99
|
+
seen.add(id)
|
|
100
|
+
if (!existsSync(sessionStoreDir(id))) {
|
|
101
|
+
if (state.delete(id)) emit(`${tag}[spex] closed · removed [id ${id}]`)
|
|
102
|
+
if (take) return { gone: id }
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
let f = state.get(id)
|
|
106
|
+
if (!f) {
|
|
107
|
+
const evs = timelineEvents(id)
|
|
108
|
+
// A stored cursor always wins — that IS the resume, losing nothing across a follower's death. With
|
|
109
|
+
// none: a target already present when the follow started predates it, so we begin at the log's END
|
|
110
|
+
// (its history is not news); a target that appears on a LATER tick genuinely just launched, so its
|
|
111
|
+
// whole log is read from 0 and its `launched` line reaches the feed.
|
|
112
|
+
const pos = readCursor(id) ?? (first ? evs.length : 0)
|
|
113
|
+
// The ARRIVAL state is the last status line BEFORE the cursor — a fact on the log, not whatever a poll
|
|
114
|
+
// happened to catch. With no such line the first status we read is the arrival instead.
|
|
115
|
+
let prev: DisplayStatus | null = null
|
|
116
|
+
for (let i = Math.min(pos, evs.length) - 1; i >= 0; i--) {
|
|
117
|
+
const e = evs[i]
|
|
118
|
+
if (e.kind === 'status') { prev = timelineDisplay(e); break }
|
|
119
|
+
}
|
|
120
|
+
f = { pos, prev, path: prev ? [prev] : [], stamp: null }
|
|
121
|
+
state.set(id, f)
|
|
122
|
+
if (prev) { onObserved?.(id, prev, null); line(id, prev, null, true) }
|
|
123
|
+
}
|
|
124
|
+
const stamp = timelineStamp(id)
|
|
125
|
+
if (stamp !== null && stamp === f.stamp) continue // THE cheap tick: nothing appended, nothing parsed
|
|
126
|
+
f.stamp = stamp
|
|
127
|
+
const evs = timelineEvents(id)
|
|
128
|
+
const slice = unreadSince(evs, f.pos)
|
|
129
|
+
let hit: FollowOutcome | null = null
|
|
130
|
+
for (let k = 0; k < slice.events.length && !hit; k++) {
|
|
131
|
+
const e = slice.events[k]
|
|
132
|
+
if (e.kind === 'sent') {
|
|
133
|
+
const s = row?.(id, f.prev ?? 'unknown', null)
|
|
134
|
+
if (s) emit(`${tag}[spex] message · ${sessionLabel(s)} — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${id}]`)
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
const st = timelineDisplay(e)
|
|
138
|
+
const was = f.prev
|
|
139
|
+
f.prev = st
|
|
140
|
+
f.path.push(st)
|
|
141
|
+
onObserved?.(id, st, was)
|
|
142
|
+
line(id, st, e.note, was === null)
|
|
143
|
+
// THE edge: a previously-observed NON-actionable status moving INTO an actionable one. An actionable
|
|
144
|
+
// ARRIVAL (was === null) is not an edge — that standing level is what a level-triggered wait falsely
|
|
145
|
+
// returned on; nor is an actionable→actionable hop (review→done). The rise OUT of non-actionable is the
|
|
146
|
+
// one signal that means "the target needs you AGAIN". The previous state is the previous LINE, so two
|
|
147
|
+
// moves inside one tick are two edges rather than one collapsed sample.
|
|
148
|
+
if (take && was !== null && !isActionable(was) && isActionable(st)) hit = { reached: st, id, path: f.path }
|
|
149
|
+
// Consume exactly up to the event we stopped on, so the lines behind it stay unread for the next wait.
|
|
150
|
+
if (hit) { f.pos = slice.at[k] + 1; writeCursor(id, f.pos) }
|
|
151
|
+
}
|
|
152
|
+
if (hit) return hit
|
|
153
|
+
f.pos = slice.next
|
|
154
|
+
writeCursor(id, f.pos)
|
|
155
|
+
}
|
|
156
|
+
for (const id of [...state.keys()]) {
|
|
157
|
+
if (seen.has(id)) continue
|
|
158
|
+
state.delete(id)
|
|
159
|
+
emit(`${tag}[spex] closed · removed [id ${id}]`)
|
|
160
|
+
}
|
|
161
|
+
// THE INBOX — the follower's own log, read past the cursor `cursors.json` already holds and NEVER advanced
|
|
162
|
+
// here: the turn-boundary mark-active hook is the inbox's one reader ([[session-timeline]]), and advancing
|
|
163
|
+
// behind its back would wake this process on a message the agent is then never shown. Re-read every tick, so
|
|
164
|
+
// a line that hook has since injected stops counting as unread and cannot wake a later wait twice.
|
|
165
|
+
if (self && existsSync(sessionStoreDir(self))) {
|
|
166
|
+
const mine = unreadSince(timelineEvents(self), inboxCursor(self))
|
|
167
|
+
for (let k = 0; k < mine.events.length; k++) {
|
|
168
|
+
const e = mine.events[k]
|
|
169
|
+
if (e.kind !== 'sent') continue
|
|
170
|
+
if (take) return { mail: { from: e.from, text: e.text } }
|
|
171
|
+
// a stream has no turn boundary to advance that cursor, so its own high-water mark — process-local,
|
|
172
|
+
// never written — is what keeps an unread line from being re-printed on every tick.
|
|
173
|
+
if (mine.at[k] <= shown) continue
|
|
174
|
+
shown = mine.at[k]
|
|
175
|
+
emit(`${tag}[spex] message · you — from ${e.from ?? 'human'}: ${trunc(e.text, 120)} [id ${self}]`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
first = false
|
|
179
|
+
if (take && Date.now() >= deadline) return { timedOut: true, path: anyPath() }
|
|
180
|
+
await sleep(intervalMs)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,52 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync, appendFileSync, mkdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { sessionStoreDir, sessionArtifactPath, readAliasedRawRecord } from './layout.js'
|
|
3
4
|
import type { Lifecycle, Proposal } from './sessions.js'
|
|
4
5
|
|
|
5
|
-
// @@@ session-timeline - the
|
|
6
|
-
// transition (status + proposal + the FULL note text) and every
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// replies, and the timeline is the whole conversation.
|
|
6
|
+
// @@@ session-timeline - the session's append-only log in its global store dir: every authored-lifecycle
|
|
7
|
+
// transition (status + proposal + the FULL note text) and every message addressed to it. It is what a
|
|
8
|
+
// terminal-free surface renders as the conversation, and it is the DELIVERY itself — a message is delivered
|
|
9
|
+
// when its bytes are in this file.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// slow reconcile tick repairs those external writes. Direct append + observation may duplicate one move;
|
|
16
|
-
// readTimeline folds adjacent duplicates without making history mutable.
|
|
17
|
-
//
|
|
18
|
-
// The observer runs ONLY in the serve process (superviseTimeline is called from index.ts); lifecycle writers
|
|
19
|
-
// and confirmed senders append from whichever process owns that write. Direct events use the write time;
|
|
20
|
-
// observed shell events use an observation time honest to within the debounce. Only the AUTHORED axis is
|
|
21
|
-
// recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived per
|
|
22
|
-
// probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from the
|
|
23
|
-
// board row. The timeline lives and dies with the session record (close sweeps the store dir), like
|
|
24
|
-
// comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
|
|
25
|
-
// input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
|
|
26
|
-
// composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
|
|
27
|
-
// null = a human surface.
|
|
11
|
+
// There is exactly ONE writer path for the authored axis: every lifecycle hook shells to `spex internal
|
|
12
|
+
// session-*`, the same TypeScript writer the CLI declarations use, so no move reaches session.json without
|
|
13
|
+
// reaching this log. That is why there is no observer process, no repair tick, and no read-time folding of a
|
|
14
|
+
// move recorded twice — nothing writes state behind this module's back.
|
|
28
15
|
|
|
29
16
|
export type TimelineEvent =
|
|
30
17
|
| { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
|
|
31
|
-
| { ts: string; kind: 'sent'; text: string; from: string | null; replyVia?: 'note' }
|
|
18
|
+
| { ts: string; kind: 'sent'; mid: string; text: string; from: string | null; replyVia?: 'note' }
|
|
32
19
|
|
|
33
20
|
const timelinePath = (id: string): string => sessionArtifactPath(id, 'timeline.ndjson')
|
|
34
21
|
|
|
35
22
|
function append(id: string, ev: TimelineEvent): void {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
appendFileSync(timelinePath(id), JSON.stringify(ev) + '\n')
|
|
39
|
-
} catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
|
|
23
|
+
mkdirSync(sessionStoreDir(id), { recursive: true })
|
|
24
|
+
appendFileSync(timelinePath(id), JSON.stringify(ev) + '\n')
|
|
40
25
|
}
|
|
41
26
|
|
|
42
27
|
// Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
|
|
43
28
|
// synchronously before returning, so a later write cannot erase an intermediate declaration note from the
|
|
44
|
-
// conversation.
|
|
29
|
+
// conversation. Best-effort: history is an accessory to the state machine, and failing to write it must never
|
|
30
|
+
// break the transition that already happened.
|
|
45
31
|
export function recordStatus(id: string, status: Lifecycle, proposal: Proposal | null, note: string | null): void {
|
|
46
|
-
append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
|
|
32
|
+
try { append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note }) }
|
|
33
|
+
catch { /* the record already moved; the history line is the only loss */ }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The DELIVERY ([[dispatch]]): appending this line IS the send, so unlike a status line it must fail LOUD —
|
|
37
|
+
// the caller reports the throw rather than a false success. `text` is the message BEFORE any mechanism insert
|
|
38
|
+
// (hints are transport, not conversation); `replyVia` is the effective channel the prompt seam chose. Returns
|
|
39
|
+
// the new line's `mid`, which a best-effort poke carries.
|
|
40
|
+
export function appendSent(id: string, text: string, from: string | null, replyVia?: 'note'): { mid: string } {
|
|
41
|
+
const mid = randomUUID()
|
|
42
|
+
append(id, { ts: new Date().toISOString(), kind: 'sent', mid, text, from, ...(replyVia ? { replyVia } : {}) })
|
|
43
|
+
return { mid }
|
|
47
44
|
}
|
|
48
45
|
|
|
49
|
-
|
|
46
|
+
// The unowned read: any process may take it with nothing but filesystem access, and taking it perturbs
|
|
47
|
+
// nothing. Index = event position, which is what a cursor names ([[session-cursors]]).
|
|
48
|
+
export function timelineEvents(id: string): TimelineEvent[] {
|
|
50
49
|
try {
|
|
51
50
|
const p = timelinePath(id)
|
|
52
51
|
if (!existsSync(p)) return []
|
|
@@ -56,82 +55,30 @@ function readEvents(id: string): TimelineEvent[] {
|
|
|
56
55
|
} catch { return [] }
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
// the
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const displayOf = (e: { status: Lifecycle; proposal: Proposal | null }): string =>
|
|
65
|
-
e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
|
|
66
|
-
: e.status === 'active' ? 'working' : e.status
|
|
67
|
-
|
|
68
|
-
// ---- the recorder (serve-process only) ----
|
|
69
|
-
|
|
70
|
-
// id → fingerprint of the last recorded (status, proposal, note); seeded per id from the persisted last
|
|
71
|
-
// status line so a server restart appends nothing for a session that didn't move while the server was down —
|
|
72
|
-
// and DOES append (with an honest observed-now timestamp) when it did.
|
|
73
|
-
const lastSeen = new Map<string, string>()
|
|
74
|
-
const fpOf = (status: string, proposal: string | null, note: string | null): string => JSON.stringify([status, proposal, note])
|
|
75
|
-
|
|
76
|
-
function lastStatusEvent(id: string): { status: string; proposal: string | null; note: string | null } | null {
|
|
77
|
-
const evs = readEvents(id)
|
|
78
|
-
for (let i = evs.length - 1; i >= 0; i--) { const e = evs[i]; if (e.kind === 'status') return e }
|
|
79
|
-
return null
|
|
58
|
+
// the same L0 read taken as CHEAPLY as it can be: a follower ([[session-follow]]) ticks over many logs, so it
|
|
59
|
+
// stats first and parses only what grew. null = no log yet (a session that has authored nothing).
|
|
60
|
+
export function timelineStamp(id: string): string | null {
|
|
61
|
+
try { const s = statSync(timelinePath(id)); return `${s.size}:${s.mtimeMs}` }
|
|
62
|
+
catch { return null }
|
|
80
63
|
}
|
|
81
64
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const note = entry.raw.note || null
|
|
92
|
-
const fp = fpOf(status, proposal, note)
|
|
93
|
-
if (lastSeen.get(id) === fp) continue
|
|
94
|
-
if (!lastSeen.has(id)) {
|
|
95
|
-
const last = lastStatusEvent(id)
|
|
96
|
-
if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
|
|
97
|
-
}
|
|
98
|
-
lastSeen.set(id, fp)
|
|
99
|
-
recordStatus(id, status, proposal, note)
|
|
100
|
-
} catch { /* one bad record must not stall the sweep */ }
|
|
101
|
-
}
|
|
102
|
-
const live = new Set(ids)
|
|
103
|
-
for (const k of [...lastSeen.keys()]) if (!live.has(k)) lastSeen.delete(k)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
let watcher: FSWatcher | null = null
|
|
107
|
-
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
108
|
-
let reconcile: ReturnType<typeof setInterval> | null = null
|
|
109
|
-
|
|
110
|
-
// start the recorder: one debounced fs.watch on the store (a lifecycle write lands as a session.json write)
|
|
111
|
-
// backstopped by a slow reconcile tick, plus an immediate first sweep. Idempotent; never throws — the
|
|
112
|
-
// timeline is an accessory record, and its failure must never take the server down.
|
|
113
|
-
export function superviseTimeline(): void {
|
|
114
|
-
if (!reconcile) reconcile = setInterval(scan, 60000)
|
|
115
|
-
if (!watcher) {
|
|
116
|
-
const root = sessionsRoot()
|
|
117
|
-
try { mkdirSync(root, { recursive: true }) } catch { /* best-effort */ }
|
|
118
|
-
try {
|
|
119
|
-
watcher = watch(root, { recursive: true }, () => {
|
|
120
|
-
if (debounce) return
|
|
121
|
-
debounce = setTimeout(() => { debounce = null; scan() }, 100)
|
|
122
|
-
})
|
|
123
|
-
} catch { watcher = null /* the reconcile tick still covers */ }
|
|
124
|
-
}
|
|
125
|
-
scan()
|
|
126
|
-
}
|
|
65
|
+
// the display word for an authored state — the SAME vocabulary every other surface speaks (awaiting → its
|
|
66
|
+
// proposal's label, active → working), duplicated here as a tiny read-time map rather than importing the state
|
|
67
|
+
// machine (sessions.ts imports THIS module for appendSent; a value import back would be a cycle — the
|
|
68
|
+
// Lifecycle/Proposal imports above are type-only, erased at runtime).
|
|
69
|
+
const PROPOSAL_DISPLAY: Record<string, DisplayWord> = { merge: 'review', nothing: 'done', close: 'close-pending' }
|
|
70
|
+
type DisplayWord = 'working' | 'idle' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued'
|
|
71
|
+
export const timelineDisplay = (e: { status: Lifecycle; proposal: Proposal | null }): DisplayWord =>
|
|
72
|
+
e.status === 'awaiting' ? (PROPOSAL_DISPLAY[e.proposal ?? 'nothing'] ?? 'done')
|
|
73
|
+
: e.status === 'active' ? 'working' : e.status
|
|
127
74
|
|
|
128
75
|
// the channel of the LAST HUMAN send (from == null): 'note' when the note-reply hint rode along, else null.
|
|
129
|
-
// This is what makes the reply-channel hints SYMMETRIC ([[
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
76
|
+
// This is what makes the reply-channel hints SYMMETRIC ([[session-timeline]]): a human send with no note flag
|
|
77
|
+
// arriving after a note-send is the "back at a terminal" transition, and the delivery gets the counter-insert.
|
|
78
|
+
// Derived from the durable log — no new state, and it survives a server restart. Agent senders (`from` set)
|
|
79
|
+
// say nothing about where the HUMAN is reading, so they neither set nor clear it.
|
|
133
80
|
export function lastHumanSendVia(id: string): 'note' | null {
|
|
134
|
-
const evs =
|
|
81
|
+
const evs = timelineEvents(id)
|
|
135
82
|
for (let i = evs.length - 1; i >= 0; i--) {
|
|
136
83
|
const e = evs[i]
|
|
137
84
|
if (e.kind === 'sent' && e.from == null) return e.replyVia === 'note' ? 'note' : null
|
|
@@ -139,32 +86,13 @@ export function lastHumanSendVia(id: string): 'note' | null {
|
|
|
139
86
|
return null
|
|
140
87
|
}
|
|
141
88
|
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// `replyVia` is the effective channel chosen by the shared prompt seam, whether explicit or derived from the
|
|
145
|
-
// target adapter, so the durable history records where the reply was actually readable.
|
|
146
|
-
export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
|
|
147
|
-
try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
|
|
148
|
-
append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
|
|
152
|
-
// status event carrying its composed display word. null = no such session (the route 404s).
|
|
153
|
-
// Adjacent status lines with identical (status, proposal, note) fold into their first: a direct writer and
|
|
154
|
-
// observer, or TWO serve processes observing one store (a throwaway worktree/eval serve beside the live
|
|
155
|
-
// one), can append a single record move twice. Cross-process write locking isn't worth buying, so the log
|
|
156
|
-
// stays best-effort append-only and the read is where duplicates die, same stance as the board.
|
|
89
|
+
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each status
|
|
90
|
+
// event carrying its composed display word. null = no such session (the route 404s).
|
|
157
91
|
export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
|
|
158
92
|
let raw: ReturnType<typeof readAliasedRawRecord>
|
|
159
93
|
try { raw = readAliasedRawRecord(id) } catch { return null }
|
|
160
94
|
if (!raw || !raw.governed) return null
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if (e.kind === 'status' && prev?.kind === 'status' && prev.status === e.status
|
|
165
|
-
&& (prev.proposal ?? null) === (e.proposal ?? null) && (prev.note ?? null) === (e.note ?? null)) continue
|
|
166
|
-
folded.push(e)
|
|
167
|
-
}
|
|
168
|
-
const tail = folded.slice(Math.max(0, folded.length - Math.max(1, limit)))
|
|
169
|
-
return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: displayOf(e) } : e)) }
|
|
95
|
+
const evs = timelineEvents(id)
|
|
96
|
+
const tail = evs.slice(Math.max(0, evs.length - Math.max(1, limit)))
|
|
97
|
+
return { events: tail.map((e) => (e.kind === 'status' ? { ...e, display: timelineDisplay(e) } : e)) }
|
|
170
98
|
}
|