spexcode 0.5.1 → 0.5.2
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 +1 -1
- package/spec-cli/src/claude-headless.ts +105 -16
- package/spec-cli/src/cli.ts +13 -2
- package/spec-cli/src/harness.ts +21 -1
- package/spec-cli/src/host.ts +1 -0
- package/spec-cli/src/opencode-headless.ts +119 -5
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +1 -0
- package/spec-cli/src/pty-bridge.ts +31 -4
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- package/spec-cli/src/session-timeline.ts +23 -17
- package/spec-cli/src/sessions.ts +25 -4
- package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
- package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
- package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
- package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
|
@@ -8,20 +8,19 @@ import type { Lifecycle, Proposal } from './sessions.js'
|
|
|
8
8
|
// [[mobile-ui]]) renders instead of a live pane: without the terminal, the declaration notes ARE the agent's
|
|
9
9
|
// replies, and the timeline is the whole conversation.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// fs.watch on the sessions root (debounced) plus a
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// by construction. Granularity is the debounce window — a flap faster than ~100ms can collapse, exactly like
|
|
18
|
-
// the board itself.
|
|
11
|
+
// A declaration note is conversation content, so TS lifecycle writes append moved state at the same write
|
|
12
|
+
// boundary instead of asking a later sample of mutable session.json to reconstruct it. The observer remains
|
|
13
|
+
// because the lifecycle also has a writer the TS layer never sees: the mark-active hook value-replaces
|
|
14
|
+
// status/proposal/note with pure-shell sed ([[state]]). One fs.watch on the sessions root (debounced) plus a
|
|
15
|
+
// slow reconcile tick repairs those external writes. Direct append + observation may duplicate one move;
|
|
16
|
+
// readTimeline folds adjacent duplicates without making history mutable.
|
|
19
17
|
//
|
|
20
|
-
// The
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
25
24
|
// comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
|
|
26
25
|
// input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
|
|
27
26
|
// composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
|
|
@@ -40,6 +39,13 @@ function append(id: string, ev: TimelineEvent): void {
|
|
|
40
39
|
} catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
// Record a lifecycle value that has already landed in session.json. TypeScript state writers call this
|
|
43
|
+
// synchronously before returning, so a later write cannot erase an intermediate declaration note from the
|
|
44
|
+
// conversation. The serve observer calls the same sink for shell-authored state.
|
|
45
|
+
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 })
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
function readEvents(id: string): TimelineEvent[] {
|
|
44
50
|
try {
|
|
45
51
|
const p = timelinePath(id)
|
|
@@ -90,7 +96,7 @@ function scan(): void {
|
|
|
90
96
|
if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
|
|
91
97
|
}
|
|
92
98
|
lastSeen.set(id, fp)
|
|
93
|
-
|
|
99
|
+
recordStatus(id, status, proposal, note)
|
|
94
100
|
} catch { /* one bad record must not stall the sweep */ }
|
|
95
101
|
}
|
|
96
102
|
const live = new Set(ids)
|
|
@@ -144,9 +150,9 @@ export function recordSent(id: string, text: string, from: string | null, replyV
|
|
|
144
150
|
|
|
145
151
|
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
|
|
146
152
|
// status event carrying its composed display word. null = no such session (the route 404s).
|
|
147
|
-
// Adjacent status lines with identical (status, proposal, note) fold into their first:
|
|
148
|
-
// observing one store (a throwaway worktree/eval serve beside the live
|
|
149
|
-
//
|
|
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
|
|
150
156
|
// stays best-effort append-only and the read is where duplicates die, same stance as the board.
|
|
151
157
|
export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
|
|
152
158
|
let raw: ReturnType<typeof readAliasedRawRecord>
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { loadConfig, loadSpecs, type ConfigPreset, type SpecLite } from './specs
|
|
|
10
10
|
import { defaultHarness, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
11
11
|
import { materialize } from './materialize.js'
|
|
12
12
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
13
|
-
import { recordSent, lastHumanSendVia } from './session-timeline.js'
|
|
13
|
+
import { recordSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
14
14
|
import { stripRefSigil } from './mentions.js'
|
|
15
15
|
|
|
16
16
|
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
@@ -294,6 +294,8 @@ export function fromRaw(raw: RawRecord & { launch_owner?: string }): SessRec {
|
|
|
294
294
|
// pure-shell hot-path hook (mark-active) relies on: it value-replaces `"status"`/`"proposal"`/`"note"` with a
|
|
295
295
|
// single sed and never needs jq on the user's box. So do NOT switch to conditional keys or a compact dump.
|
|
296
296
|
function writeRecord(rec: SessRec): void {
|
|
297
|
+
let previous: SessRec | null = null
|
|
298
|
+
try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
|
|
297
299
|
const obj = {
|
|
298
300
|
session_id: rec.session,
|
|
299
301
|
governed: rec.governed,
|
|
@@ -320,6 +322,13 @@ function writeRecord(rec: SessRec): void {
|
|
|
320
322
|
}
|
|
321
323
|
mkdirSync(sessionStoreDir(rec.session), { recursive: true })
|
|
322
324
|
writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
|
|
325
|
+
// session.json is only the CURRENT projection. Persist each moved lifecycle value before this writer
|
|
326
|
+
// returns, so a later write cannot erase a declaration note between observer samples. New-record genesis
|
|
327
|
+
// stays with superviseTimeline; metadata-only writes do not manufacture status events.
|
|
328
|
+
if (rec.governed && previous && (previous.status !== rec.status
|
|
329
|
+
|| previous.proposal !== rec.proposal || previous.note !== rec.note)) {
|
|
330
|
+
recordStatus(rec.session, rec.status, rec.proposal, rec.note)
|
|
331
|
+
}
|
|
323
332
|
}
|
|
324
333
|
|
|
325
334
|
// @@@ fail-loud enumeration - the worktree set is the board's EXISTENCE truth, so a failed enumeration must
|
|
@@ -894,15 +903,16 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
894
903
|
}
|
|
895
904
|
// @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
|
|
896
905
|
// terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
|
|
897
|
-
//
|
|
898
|
-
//
|
|
906
|
+
// insert tells the agent exactly that, so its next stop carries the complete answer in `--note` instead of
|
|
907
|
+
// prose that dies in an unseen output stream. The declaration is TRANSPORT, not task work: a raw prompt's
|
|
908
|
+
// "no tools" / "only print" constraint does not cancel it. composeSessionPrompt is the only production caller
|
|
899
909
|
// deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
|
|
900
910
|
// default. The notice declares itself
|
|
901
911
|
// PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
|
|
902
912
|
// note-replied a few times keeps note-replying from context inertia long after the human is back at a
|
|
903
913
|
// terminal — the sticky-note failure this pair exists to prevent.
|
|
904
914
|
export const withNoteReplyHint = (text: string): string =>
|
|
905
|
-
`${text}\n\n—
|
|
915
|
+
`${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For a simple answer awaiting the next message, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`
|
|
906
916
|
// @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
|
|
907
917
|
// note→terminal transition (a human send with no note flag whose previous human send carried one —
|
|
908
918
|
// [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
|
|
@@ -1480,6 +1490,17 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
|
|
|
1480
1490
|
}
|
|
1481
1491
|
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
1482
1492
|
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
1493
|
+
// @@@ headless turn outcome - a harness turn is an ephemeral child, so its non-zero exit is the one external
|
|
1494
|
+
// runtime fact that must become visible on the durable board. Compare-and-set only an undeclared active record:
|
|
1495
|
+
// a zero exit is never routed here, and a declaration that landed before teardown is authoritative.
|
|
1496
|
+
export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
|
|
1497
|
+
if (exitCode === '0') return false
|
|
1498
|
+
const rec = readRecord(sessionId)
|
|
1499
|
+
if (!rec || rec.status !== 'active') return false
|
|
1500
|
+
const outcome = /^\d+$/.test(exitCode) ? `exit code ${exitCode}` : `signal ${exitCode}`
|
|
1501
|
+
writeRecord({ ...rec, status: 'error', proposal: null, note: `${harness} turn exited with ${outcome}` })
|
|
1502
|
+
return true
|
|
1503
|
+
}
|
|
1483
1504
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
1484
1505
|
const id = sessionId || ownSessionId()
|
|
1485
1506
|
if (!id || !harnessSessionId) return false
|