spexcode 0.5.9 → 0.6.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spexcode",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
6
6
  "license": "MIT",
@@ -660,7 +660,7 @@ if (cmd === 'serve') {
660
660
  } else if (sub === 'wait') {
661
661
  const selectors = positionals(4)
662
662
  const kit = await followKit(selectors, 'spex session wait')
663
- const named = selectors.join(' ') || 'your inbox'
663
+ const named = selectors.join(' ') || 'your own log'
664
664
  // point-of-use turn-freeze warning ([[session-follow]]): a managed agent that runs this wait in the FOREGROUND
665
665
  // freezes its whole turn until the target produces an edge — a warning that used to live only in help
666
666
  // prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
@@ -1043,28 +1043,6 @@ if (cmd === 'serve') {
1043
1043
  const st = process.argv[4] as any
1044
1044
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
1045
1045
  console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
1046
- } else if (sub === 'session-cursor') {
1047
- // the turn-boundary mail reader advances its own inbox cursor here ([[session-cursors]]) — the same
1048
- // one-writer discipline as session-state: shell reads the file, it never rewrites it, so a follower's
1049
- // entries in the same file cannot be clobbered by a partial shell write.
1050
- const to = Number(flag('to'))
1051
- const sess = flag('session')
1052
- if (process.argv[4] !== 'inbox' || !sess || !Number.isFinite(to)) {
1053
- console.error('usage: spex internal session-cursor inbox --session <id> --to <event-index>')
1054
- process.exit(2)
1055
- }
1056
- const { advanceInbox, inboxCursor } = await import('./session-cursors.js')
1057
- const { readAliasedRawRecord } = await import('./layout.js')
1058
- // the hook may address a codex THREAD id; the cursor file is keyed by the record id, so resolve the alias
1059
- // through the one seam that owns that rule.
1060
- const record = readAliasedRawRecord(sess)
1061
- if (!record) console.log('noop (no session record)')
1062
- else {
1063
- advanceInbox(record.session_id, to)
1064
- // report where the cursor ACTUALLY is: advancing is monotonic, so a lower offer is ignored, and
1065
- // echoing the request back would confirm a move that did not happen.
1066
- console.log(`inbox -> ${inboxCursor(record.session_id)}`)
1067
- }
1068
1046
  } else if (sub === 'session-fail') {
1069
1047
  // StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
1070
1048
  // stop that landed first is authoritative, just as it is for Codex notifications and headless exits.
@@ -0,0 +1,107 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
4
+
5
+ // @@@ delivery-queue - what a session still OWES its agent. The log ([[session-timeline]]) is the record and
6
+ // grows forever; this is the debt and is consumed, so it lives in its own small file whose resting state is
7
+ // EMPTY. Nothing here reads the log: an entry carries the text it will hand over, so history could be trimmed
8
+ // or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
9
+ // queue is only ever filled by an enqueue — which is why no backlog migration exists.
10
+
11
+ export type PendingMessage = { mid: string; text: string; from: string | null }
12
+
13
+ const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
14
+
15
+ // @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
16
+ // is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
17
+ // record lock could never span that call — a native turn runs lifecycle hooks that re-enter the record writer,
18
+ // and holding it there deadlocks the adapter's own confirmation. Nothing in the delivery path takes this one,
19
+ // so spanning the insert costs no contention. PID liveness reclaims a lock whose holder died mid-insert.
20
+ const lockRoot = (): string => join(runtimeRoot(), '.delivery-locks')
21
+ const lockPath = (id: string): string => join(lockRoot(), `${id}.lock`)
22
+
23
+ const pause = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms))
24
+
25
+ async function acquire(id: string, timeoutMs: number): Promise<(() => void) | null> {
26
+ mkdirSync(lockRoot(), { recursive: true })
27
+ const path = lockPath(id), deadline = Date.now() + timeoutMs
28
+ for (;;) {
29
+ try {
30
+ const fd = openSync(path, 'wx')
31
+ writeSync(fd, String(process.pid))
32
+ closeSync(fd)
33
+ return () => { try { unlinkSync(path) } catch { /* a liveness reclaim already removed it */ } }
34
+ } catch (e) {
35
+ if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
36
+ let owner = 0
37
+ try { owner = Number(readFileSync(path, 'utf8').trim()) || 0 } catch { /* race with creator/releaser */ }
38
+ if (owner && owner !== process.pid) {
39
+ try { process.kill(owner, 0) } catch { try { unlinkSync(path) } catch { /* race */ }; continue }
40
+ }
41
+ // A drain is never urgent enough to fight for: whoever holds the lock is delivering these same messages,
42
+ // and the retry sweep will come back. Declining is not a lost message.
43
+ if (Date.now() >= deadline) return null
44
+ await pause(25)
45
+ }
46
+ }
47
+ }
48
+
49
+ function read(id: string): PendingMessage[] {
50
+ try {
51
+ const raw = JSON.parse(readFileSync(queuePath(id), 'utf8')) as unknown
52
+ if (!Array.isArray(raw)) return []
53
+ return raw.filter((m): m is PendingMessage =>
54
+ !!m && typeof m === 'object'
55
+ && typeof (m as PendingMessage).mid === 'string'
56
+ && typeof (m as PendingMessage).text === 'string')
57
+ } catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
58
+ }
59
+
60
+ // Written whole and atomically; an empty queue is REMOVED rather than left as `[]`, so "is anything owed?" is
61
+ // one existsSync on the sweep's hot path.
62
+ function write(id: string, msgs: PendingMessage[]): void {
63
+ const path = queuePath(id)
64
+ if (!msgs.length) { try { unlinkSync(path) } catch { /* already gone */ } ; return }
65
+ mkdirSync(sessionStoreDir(id), { recursive: true })
66
+ const tmp = `${path}.${process.pid}.tmp`
67
+ writeFileSync(tmp, JSON.stringify(msgs, null, 2) + '\n')
68
+ renameSync(tmp, path)
69
+ }
70
+
71
+ // The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
72
+ // both, and the record is written first, so a crash between them leaves a message visible but undelivered —
73
+ // never delivered but unrecorded.
74
+ export function enqueue(id: string, msg: PendingMessage): void {
75
+ write(id, [...read(id), msg])
76
+ }
77
+
78
+ export const pendingMessages = (id: string): PendingMessage[] => read(id)
79
+
80
+ export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
81
+
82
+ // Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
83
+ // then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
84
+ // still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
85
+ // one. Returns how many were handed over and how many are still owed.
86
+ export async function drain(
87
+ id: string,
88
+ insert: (msg: PendingMessage) => Promise<boolean>,
89
+ timeoutMs = 5_000,
90
+ ): Promise<{ delivered: number; remaining: number }> {
91
+ const release = await acquire(id, timeoutMs)
92
+ if (!release) return { delivered: 0, remaining: read(id).length }
93
+ let delivered = 0
94
+ try {
95
+ for (;;) {
96
+ const queued = read(id)
97
+ if (!queued.length) return { delivered, remaining: 0 }
98
+ let ok = false
99
+ try { ok = await insert(queued[0]) } catch { ok = false }
100
+ if (!ok) return { delivered, remaining: queued.length }
101
+ // Re-read before removing: a send that landed while this pass ran appended to the tail, and rewriting a
102
+ // stale snapshot minus the head would silently drop it.
103
+ write(id, read(id).filter((m) => m.mid !== queued[0].mid))
104
+ delivered++
105
+ }
106
+ } finally { release() }
107
+ }
@@ -282,7 +282,7 @@ export interface Harness {
282
282
  // write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
283
283
  // visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
284
284
  // (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
285
- // `ok=false` leaves the durable timeline line for the turn-boundary reader.
285
+ // `ok=false` leaves the message OWED on the session's delivery queue, for a later pass to hand over.
286
286
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
287
287
  // Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
288
288
  // transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
@@ -47,7 +47,7 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
47
47
  watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
48
48
  'Streams lifecycle transitions and blocks until killed; `session wait` is the one-shot alternative.', ['selector']],
49
49
  wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
50
- `EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own inbox, and exits 0 on
50
+ `EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
51
51
  the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
52
52
  status into an actionable one (stdout = the observed path, e.g. working→review; read the LAST
53
53
  token as the status reached), or a message arriving for you (stdout = message). The arrival
@@ -18,7 +18,7 @@ import { getBoardJson } from './graphCache.js'
18
18
  import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
19
19
  import { gitA, gitTry, repoRoot } from './git.js'
20
20
  import { cockpitReview } from './cockpit.js'
21
- import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
21
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
22
22
  import { readTimeline } from './session-timeline.js'
23
23
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
24
24
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
@@ -700,6 +700,7 @@ injectWebSocket(server)
700
700
  superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
701
701
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
702
702
  superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
703
+ superviseDelivery() // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
703
704
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
704
705
 
705
706
  let graphWatchersClosed = false
@@ -4,10 +4,13 @@ import { sessionArtifactPath, sessionStoreDir } from './layout.js'
4
4
  import type { TimelineEvent } from './session-timeline.js'
5
5
 
6
6
  // @@@ session-cursors - a reader's durable place in a log. One `cursors.json` per session in its global store
7
- // dir: `inbox` is its place in its OWN timeline, `follows` one entry per followed session. A position is an
8
- // event INDEX into timeline.ndjson (lines already consumed), so it is also the index of the next unread event.
7
+ // dir: `follows` holds one entry per followed session, including the reader's own id when it watches its own
8
+ // log. A position is an event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
9
+ // the next unread event. A position is NOT a work list: what a session still owes its agent is a debt, and it
10
+ // lives in its own queue ([[delivery-queue]]). Binding both to one counter is what made a session's own status
11
+ // lines get consumed as though they were mail, and made "anything outstanding?" a scan of all history.
9
12
 
10
- export type Cursors = { version: 1; inbox: number; follows: Record<string, number> }
13
+ export type Cursors = { version: 1; follows: Record<string, number> }
11
14
 
12
15
  const cursorsPath = (id: string): string => sessionArtifactPath(id, 'cursors.json')
13
16
  const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
@@ -16,7 +19,7 @@ const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v)
16
19
  // message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
17
20
  // store dir is gone are dropped here — expiry is this read, and the next write persists it.
18
21
  export function readCursors(id: string): Cursors {
19
- let raw: { inbox?: unknown; follows?: unknown } | null = null
22
+ let raw: { follows?: unknown } | null = null
20
23
  try { raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8')) } catch { /* no cursors yet */ }
21
24
  const follows: Record<string, number> = {}
22
25
  const stored = raw?.follows
@@ -26,11 +29,11 @@ export function readCursors(id: string): Cursors {
26
29
  follows[target] = at(pos)
27
30
  }
28
31
  }
29
- return { version: 1, inbox: at(raw?.inbox), follows }
32
+ return { version: 1, follows }
30
33
  }
31
34
 
32
- // Written whole and atomically, one field per line — the same shape as the session record, so the mark-active
33
- // hook can read its inbox position with an exact whole-line match in pure shell.
35
+ // Written whole and atomically, one field per line — the same shape as the session record, so a position stays
36
+ // readable by an exact whole-line match where a value regex would not be.
34
37
  function writeCursors(id: string, cursors: Cursors): void {
35
38
  const dir = sessionStoreDir(id)
36
39
  mkdirSync(dir, { recursive: true })
@@ -39,22 +42,13 @@ function writeCursors(id: string, cursors: Cursors): void {
39
42
  renameSync(tmp, cursorsPath(id))
40
43
  }
41
44
 
42
- export const inboxCursor = (id: string): number => readCursors(id).inbox
43
-
44
- // A reader that has shown everything up to `to`. Monotonic: a stale read can leave the position too low
45
- // (a message shown twice), never too high (a message lost).
46
- export function advanceInbox(id: string, to: number): void {
47
- const cursors = readCursors(id)
48
- if (to <= cursors.inbox) return
49
- writeCursors(id, { ...cursors, inbox: to })
50
- }
51
-
52
45
  export const followCursor = (id: string, target: string): number | null => {
53
46
  const stored = readCursors(id).follows[target]
54
47
  return stored === undefined ? null : stored
55
48
  }
56
49
 
57
50
  // Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
51
+ // Monotonic: a stale read can leave a position too low (an event read twice), never too high (one lost).
58
52
  export function advanceFollow(id: string, target: string, to: number): void {
59
53
  const cursors = readCursors(id)
60
54
  const stored = cursors.follows[target]
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { sessionStoreDir } from './layout.js'
3
- import { advanceFollow, followCursor, inboxCursor, unreadSince } from './session-cursors.js'
3
+ import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
4
4
  import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
5
5
  import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
6
6
 
@@ -158,12 +158,12 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
158
158
  state.delete(id)
159
159
  emit(`${tag}[spex] closed · removed [id ${id}]`)
160
160
  }
161
- // THE INBOXthe 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.
161
+ // THE FOLLOWER'S OWN LOG watched exactly like any other target, on its own entry in `cursors.json`. It is
162
+ // a WATCH, not a delivery: a message reaches the agent as a prompt through the adapter ([[delivery-queue]]),
163
+ // so this position only decides what THIS process has already reported and can never make an agent miss
164
+ // mail. Never advanced here in take mode — the waiter stops on the event and the next wait resumes on it.
165
165
  if (self && existsSync(sessionStoreDir(self))) {
166
- const mine = unreadSince(timelineEvents(self), inboxCursor(self))
166
+ const mine = unreadSince(timelineEvents(self), followCursor(self, self) ?? 0)
167
167
  for (let k = 0; k < mine.events.length; k++) {
168
168
  const e = mine.events[k]
169
169
  if (e.kind !== 'sent') continue
@@ -12,6 +12,7 @@ import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentity
12
12
  import { materialize } from './materialize.js'
13
13
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
14
14
  import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
15
+ import { drain, enqueue, owesDelivery } from './delivery-queue.js'
15
16
  import { stripRefSigil } from './mentions.js'
16
17
  import { shQuote } from './sh.js'
17
18
  import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
@@ -1223,6 +1224,28 @@ export function superviseQueue(intervalMs = 3000): void {
1223
1224
  void tick()
1224
1225
  }
1225
1226
 
1227
+ let supervisingDelivery = false
1228
+ // @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
1229
+ // covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
1230
+ // pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
1231
+ // the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
1232
+ // it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
1233
+ // concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
1234
+ export function superviseDelivery(intervalMs = 2000): void {
1235
+ if (supervisingDelivery) return
1236
+ supervisingDelivery = true
1237
+ const tick = async () => {
1238
+ try {
1239
+ for (const id of listSessionIds()) {
1240
+ if (!owesDelivery(id)) continue
1241
+ try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
1242
+ }
1243
+ } catch { /* transient store read; next tick retries */ }
1244
+ setTimeout(tick, intervalMs).unref()
1245
+ }
1246
+ void tick()
1247
+ }
1248
+
1226
1249
  type TurnFailureObserverState = {
1227
1250
  fingerprint: string
1228
1251
  subscription: FailureSubscription | null
@@ -3092,12 +3115,11 @@ export function formatTable(sessions: Session[], color = true): string {
3092
3115
  return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
3093
3116
  }
3094
3117
 
3095
- // @@@ sendText - THE APPEND IS THE DELIVERY ([[dispatch]]). The message lands in the target's durable log
3096
- // under its record lock, and success is decided there; only then is the harness adapter poked with the same
3097
- // text, so a live agent sees it in its current turn instead of at its next turn boundary. The poke is
3098
- // best-effort losing it, having it refused, or replaying it costs nothing, because the line is already the
3099
- // message's copy and the turn-boundary reader picks up whatever the poke did not show. What stays LOUD is only
3100
- // what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3118
+ // @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
3119
+ // message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
3120
+ // sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
3121
+ // separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
3122
+ // genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
3101
3123
  // A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
3102
3124
  // that cannot reach an agent must at least leave a trace ([[session-timeline]]).
3103
3125
  // (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
@@ -3105,37 +3127,44 @@ export async function sendText(id: string, text: string, from?: string, opts: {
3105
3127
  if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
3106
3128
  const rec = readRecord(id)
3107
3129
  if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
3130
+ // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
3131
+ // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
3132
+ // a message that was already accepted.
3108
3133
  const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
3109
- let sent: { mid: string }
3110
3134
  try {
3111
- // The lock covers the append alone. Codex's native turn can synchronously run hooks that write this same
3112
- // record, so holding it across the adapter poke below would deadlock the app-server's confirmation.
3113
- sent = await withRecordLock(id, async () => appendSent(id, text, from ?? null, prompt.replyVia))
3135
+ await withRecordLock(id, async () => {
3136
+ const appended = appendSent(id, text, from ?? null, prompt.replyVia)
3137
+ enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
3138
+ })
3114
3139
  } catch (error) {
3115
3140
  return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
3116
3141
  }
3117
- const h = harnessById(rec.harness || defaultHarness.id)
3118
- // Awaited, not fire-and-forget: `spex session send` is a short-lived process that would exit before an
3119
- // unawaited poke ever reached the socket, costing every CLI send its same-turn arrival. Its result never
3120
- // advances the inbox: a write cannot prove the target parsed it, so only the target's reader consumes the
3121
- // durable line.
3122
- await pokeAdapter(h, rec, prompt.text, sent.mid)
3142
+ // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
3143
+ // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
3144
+ // the text in a live agent's current turn instead of up to one tick later.
3145
+ await drainSession(id)
3123
3146
  return { ok: true }
3124
3147
  }
3125
3148
 
3126
- // The courtesy kick. Carries `mid` as the adapter's native message marker, so an adapter that replays or
3127
- // duplicates it stays harmless. Never throws and never reports: a poke has no outcome the caller can act on.
3128
- async function pokeAdapter(h: Harness, rec: SessRec, text: string, mid: string): Promise<void> {
3129
- // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3130
- // prompt its channel confirms (claude's sessions panel), checkable only from the pane. It no longer refuses
3131
- // the send — the message is already delivered — it only skips a kick known to be swallowed.
3132
- if (h.deliveryBlockedBy) {
3133
- try {
3134
- if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return
3135
- } catch { /* no pane to consult let the poke itself decide */ }
3136
- }
3137
- try { await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid }, text) }
3138
- catch { /* the unread timeline line remains the delivery */ }
3149
+ // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
3150
+ // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
3151
+ // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
3152
+ export async function drainSession(id: string): Promise<void> {
3153
+ if (!owesDelivery(id)) return
3154
+ const rec = readRecord(id)
3155
+ if (!rec) return
3156
+ const h = harnessById(rec.harness || defaultHarness.id)
3157
+ await drain(id, async (msg) => {
3158
+ // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
3159
+ // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
3160
+ // rather than a skip the message stays owed and the sweep hands it over once the pane leaves that state.
3161
+ if (h.deliveryBlockedBy) {
3162
+ try {
3163
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
3164
+ } catch { /* no pane to consult — let the insert itself decide */ }
3165
+ }
3166
+ return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
3167
+ })
3139
3168
  }
3140
3169
 
3141
3170
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bash
2
- # @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It does two
3
- # jobs, both keyed off the session's global record dir: it keeps the declared FRESHNESS state honest, and it
4
- # delivers the session's unread MAIL.
2
+ # @@@ mark-active - the SINGLE turn-boundary hook, wired to BOTH UserPromptSubmit and PreToolUse. It has ONE
3
+ # job, keyed off the session's global record dir: keep the declared FRESHNESS state honest. It carries no
4
+ # conversation — a message reaches this agent as an ordinary prompt through the harness adapter
5
+ # ([[delivery-queue]]), which is the only way anything enters a turn. A hook that also injected mail handed
6
+ # every message over twice and made the agent's context depend on which of two paths won a race.
5
7
  # Freshness branches on ONE structured signal read straight from the hook payload (stdin JSON), so the state is
6
8
  # HARD — never text-sniffed from the TUI:
7
9
  # the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
@@ -36,44 +38,6 @@ rec="$sdir/session.json"
36
38
  # board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
37
39
  grep -q '^[[:space:]]*"governed"[[:space:]]*:[[:space:]]*true,\?$' "$rec" 2>/dev/null || exit 0
38
40
 
39
- # @@@ the mail read - this hook is the ONE reader of the session's inbox ([[session-timeline]]): a message is
40
- # delivered by being appended to timeline.ndjson, and the agent finds it here, by mechanism, at a turn
41
- # boundary — never by remembering to run a command. `cursors.json` names how far this session has been shown
42
- # ([[session-cursors]]); everything past it is unread, the `sent` lines among it are printed as context, and
43
- # the cursor advances past ALL of it (a session's own status lines are not mail, but they are consumed, so
44
- # they can never come back as one).
45
- # Pure bash builtins, no forks: the common case is "no new lines", and the fast path must not cost a spawn.
46
- # The read is generous where it cannot be exact — an unparseable cursor reads as 0, which re-shows a message
47
- # rather than skipping one.
48
- mail() {
49
- local tl="$sdir/timeline.ndjson" cur="$sdir/cursors.json" pos=0 i=0 line body unread=""
50
- [ -f "$tl" ] || return 0
51
- if [ -f "$cur" ]; then
52
- while IFS= read -r line; do
53
- case "$line" in
54
- *'"inbox":'*) line="${line#*\"inbox\":}"; line="${line%%,*}"; line="${line// /}"; line="${line//$'\t'/}"
55
- case "$line" in ''|*[!0-9]*) ;; *) pos=$line ;; esac; break ;;
56
- esac
57
- done < "$cur"
58
- fi
59
- while IFS= read -r line; do
60
- if [ "$i" -ge "$pos" ]; then
61
- case "$line" in
62
- *'"kind":"sent"'*) body=$(hp_field "$line" text)
63
- [ -n "$body" ] && unread="$unread$body"$'\n\n' ;;
64
- esac
65
- fi
66
- i=$((i + 1))
67
- done < "$tl"
68
- # No mail → no cursor write. Unread STATUS lines alone leave the cursor where it is: they print nothing, so
69
- # advancing past them would buy nothing and cost a spawn on almost every turn. The next scan re-reads them
70
- # and still prints nothing.
71
- [ -n "$unread" ] || return 0
72
- printf 'Messages addressed to you (delivered to your session log while you were working):\n\n%s' "$unread"
73
- ${SPEX:-spex} internal session-cursor inbox --session "$sid" --to "$i" >/dev/null 2>&1
74
- }
75
- mail
76
-
77
41
  # does FIELD's line hold exactly VALUE? The record is written one-field-per-line by the single writer
78
42
  # (sessions.ts writeRecord), so a whole-line match is exact — and, unlike a value regex, it cannot be fooled
79
43
  # by an escaped quote inside a neighbouring note.
@@ -17,6 +17,6 @@ The one activity that does NOT count as the session acting is an IN-PROCESS SUBA
17
17
 
18
18
  It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), but it never edits that file itself: it READS it in pure shell (whole-line matches, the hot path stays jq-free) and hands every write to `spex internal session-state`, the one structured writer the CLI declarations use — an asking note is arbitrary prose, and a shell that substitutes prose into existing JSON eventually writes a record nothing can parse.
19
19
 
20
- This hook is also the session's MAIL READER, and that is the same job seen from the other side: a message is delivered by being appended to the session's log (session-timeline), and a turn boundary is exactly when the agent can be shown one. It reads the lines past its own inbox cursor (session-cursors), prints the message bodies so the harness injects them as context, and advances the cursor through `spex internal session-cursor`. So an agent finds its unread mail by mechanism, never by remembering to run a command identical for a dashboard-launched and a self-launched agent. The scan is pure builtins and writes nothing when there is no mail, so the every-tool-call case still costs no spawn.
20
+ This hook carries no conversation. A message addressed to the session reaches its agent as an ordinary prompt through the harness adapter (delivery-queue), which is the only way anything enters a turn, so an inter-agent message is indistinguishable from a human one at the point of arrival. A hook that also injected mail delivered every message a second time and made the agent's context depend on which of two paths won a race; a freshness signal reports a fact about the session and hands nothing over.
21
21
 
22
22
  This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.