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.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/claude-headless.ts +105 -16
  3. package/spec-cli/src/cli.ts +13 -2
  4. package/spec-cli/src/harness.ts +21 -1
  5. package/spec-cli/src/host.ts +1 -0
  6. package/spec-cli/src/opencode-headless.ts +119 -5
  7. package/spec-cli/src/opencode.ts +8 -4
  8. package/spec-cli/src/pi-headless.ts +1 -0
  9. package/spec-cli/src/pty-bridge.ts +31 -4
  10. package/spec-cli/src/pty-helper.mjs +16 -6
  11. package/spec-cli/src/pty-native-helper.mjs +22 -0
  12. package/spec-cli/src/session-timeline.ts +23 -17
  13. package/spec-cli/src/sessions.ts +25 -4
  14. package/spec-dashboard/dist/assets/{Dashboard-C_w_wdk5.js → Dashboard-CTAuTyZ3.js} +3 -3
  15. package/spec-dashboard/dist/assets/{EvalsPage-5_nfIYll.js → EvalsPage-KbMMownG.js} +2 -2
  16. package/spec-dashboard/dist/assets/{IssuesPage-By-u--95.js → IssuesPage-DmyLb9Rj.js} +1 -1
  17. package/spec-dashboard/dist/assets/{MobileApp-CVEwjHr9.js → MobileApp-D2RZGt4Z.js} +2 -2
  18. package/spec-dashboard/dist/assets/{Modal-BqgvzMJD.js → Modal-3brXUhM0.js} +1 -1
  19. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CadAKuSy.js} +1 -1
  20. package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +1 -0
  21. package/spec-dashboard/dist/assets/{SessionInterface-Bh3vq8SU.js → SessionInterface-BtrzlOPs.js} +1 -1
  22. package/spec-dashboard/dist/assets/{SessionWindow-BuJ5mzjC.js → SessionWindow-BWH5O0jh.js} +1 -1
  23. package/spec-dashboard/dist/assets/{Settings-B8KFocsz.js → Settings-COgdKTJB.js} +1 -1
  24. package/spec-dashboard/dist/assets/{TimelineChat-K0wdlweB.js → TimelineChat-DQ21GSJK.js} +1 -1
  25. package/spec-dashboard/dist/assets/{index-BKaTHjmU.js → index-D6HBvKkJ.js} +2 -2
  26. package/spec-dashboard/dist/assets/{index-DcnCaBAC.css → index-DFdlYy4H.css} +1 -1
  27. package/spec-dashboard/dist/index.html +2 -2
  28. 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
- // WHY an observer, not writer instrumentation: the lifecycle has a writer the TS layer never sees — the
12
- // mark-active hook value-replaces status/proposal/note in session.json with pure-shell sed ([[state]]).
13
- // Instrumenting every writer would always miss that one, so the recorder OBSERVES the store instead: one
14
- // fs.watch on the sessions root (debounced) plus a slow reconcile tick (the fs.watch is best-effort, same
15
- // stance as [[graph-stream]]'s source 1), and on each tick it diffs every governed record's
16
- // (status, proposal, note) against the last seen and appends what moved. One mechanism covers every writer
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 recorder runs ONLY in the serve process (superviseTimeline is called from index.ts) so exactly one
21
- // process appends; timestamps are observation times, honest to within the debounce. Only the AUTHORED axis
22
- // is recorded liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
23
- // per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
24
- // the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
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
- append(id, { ts: new Date().toISOString(), kind: 'status', status, proposal, note })
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: TWO serve processes
148
- // observing one store (a throwaway worktree/eval serve beside the live one) each keep their own lastSeen,
149
- // so a single record move can append twice — cross-process write locking isn't worth buying, so the log
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>
@@ -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
- // one-line insert tells the agent exactly that, so its next stop carries the complete answer in `--note`
898
- // instead of prose that dies in an unseen output stream. composeSessionPrompt is the only production caller
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— sent from a terminal-free client: the sender CANNOT see your terminal output. When you next stop (done/ask/park), put your COMPLETE reply to this message in the declaration's --note (e.g. spex session done --note "<full answer>") the note is the only text that reaches them. This notice is PER-MESSAGE: a later message arriving WITHOUT it means the sender is back at a terminal and reads your normal output again.`
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