spexcode 0.5.0 → 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/README.md +1 -1
- package/package.json +2 -2
- package/spec-cli/src/claude-headless.ts +105 -16
- package/spec-cli/src/cli.ts +13 -2
- package/spec-cli/src/codex-headless.ts +13 -0
- package/spec-cli/src/guide.ts +8 -7
- package/spec-cli/src/harness.ts +53 -5
- 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 +29 -21
- package/spec-cli/src/sessions.ts +72 -32
- package/spec-cli/templates/spexcode.json +1 -0
- 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
|
@@ -20,6 +20,7 @@ type Subscription = {
|
|
|
20
20
|
bridge?: Bridge
|
|
21
21
|
lingerTimer?: ReturnType<typeof setTimeout>
|
|
22
22
|
restoreTimer?: ReturnType<typeof setTimeout>
|
|
23
|
+
startupError?: string
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
type Bridge = {
|
|
@@ -37,6 +38,7 @@ type Bridge = {
|
|
|
37
38
|
refreshRunning: boolean
|
|
38
39
|
refreshOffset?: number
|
|
39
40
|
deliveryTimer?: ReturnType<typeof setTimeout>
|
|
41
|
+
startupError?: string
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
const subscribers = new Map<string, Map<Viewer, Subscription>>()
|
|
@@ -103,6 +105,8 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
|
|
|
103
105
|
const ready = line.match(/^READY (\d+)$/)
|
|
104
106
|
if (ready) {
|
|
105
107
|
bridge.ptyPid = Number(ready[1])
|
|
108
|
+
const subscription = currentSubscription(bridge.id, bridge.viewer)
|
|
109
|
+
if (subscription) subscription.startupError = undefined
|
|
106
110
|
if (bridge.delivery === 'initial') {
|
|
107
111
|
armDeliveryBoundary(bridge)
|
|
108
112
|
queueRefresh(bridge)
|
|
@@ -117,11 +121,28 @@ function onHelperStderr(bridge: Bridge, chunk: Buffer): void {
|
|
|
117
121
|
queueRefresh(bridge)
|
|
118
122
|
}
|
|
119
123
|
} else if (line) {
|
|
124
|
+
const failed = line.match(/^ERROR (.+)$/)
|
|
125
|
+
if (failed) reportStartupError(bridge, failed[1])
|
|
120
126
|
console.error(`[terminal helper ${bridge.id}/${bridge.ptyPid ?? 'starting'}] ${line}`)
|
|
121
127
|
}
|
|
122
128
|
}
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
function reportStartupError(bridge: Bridge, detail: string): void {
|
|
132
|
+
const subscription = currentSubscription(bridge.id, bridge.viewer)
|
|
133
|
+
if (!subscription || subscription.bridge !== bridge) return
|
|
134
|
+
const message = boundedStartupError(detail)
|
|
135
|
+
bridge.startupError = message
|
|
136
|
+
if (!message || subscription.startupError === message) return
|
|
137
|
+
subscription.startupError = message
|
|
138
|
+
deliver(bridge, Buffer.from(`\r\n[SpexCode terminal unavailable] ${message}\r\n`, 'utf8'))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function boundedStartupError(detail: unknown): string {
|
|
142
|
+
return (detail instanceof Error ? detail.message : String(detail))
|
|
143
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500) || 'native PTY failed to start'
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, cols: number, rows: number): { bridge: Bridge | null; created: boolean } {
|
|
126
147
|
if (subscription.bridge) return { bridge: subscription.bridge, created: false }
|
|
127
148
|
let proc: ChildProcessWithoutNullStreams | undefined
|
|
@@ -130,8 +151,13 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
|
|
|
130
151
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
131
152
|
env: process.env,
|
|
132
153
|
})
|
|
133
|
-
} catch {
|
|
154
|
+
} catch (error) {
|
|
134
155
|
try { proc?.kill() } catch { /* spawn did not complete */ }
|
|
156
|
+
const detail = boundedStartupError(error)
|
|
157
|
+
if (subscription.startupError !== detail) {
|
|
158
|
+
subscription.startupError = detail
|
|
159
|
+
try { viewer.send(Buffer.from(`\r\n[SpexCode terminal unavailable] ${detail}\r\n`, 'utf8')) } catch { /* socket closed */ }
|
|
160
|
+
}
|
|
135
161
|
return { bridge: null, created: false }
|
|
136
162
|
}
|
|
137
163
|
const bridge: Bridge = {
|
|
@@ -142,18 +168,19 @@ function ensureBridge(id: string, viewer: Viewer, subscription: Subscription, co
|
|
|
142
168
|
proc.stdout.on('data', (data: Buffer) => onHelperOutput(bridge, data))
|
|
143
169
|
proc.stderr.on('data', (data: Buffer) => onHelperStderr(bridge, data))
|
|
144
170
|
let reaped = false
|
|
145
|
-
const gone = () => {
|
|
171
|
+
const gone = (detail?: string) => {
|
|
146
172
|
if (reaped) return
|
|
147
173
|
reaped = true
|
|
148
174
|
const current = currentSubscription(id, viewer)
|
|
149
175
|
if (current?.bridge !== bridge) return
|
|
176
|
+
if (!bridge.ptyPid && !bridge.startupError) reportStartupError(bridge, detail || 'helper exited before native PTY startup')
|
|
150
177
|
current.bridge = undefined
|
|
151
178
|
clearDelivery(bridge)
|
|
152
179
|
try { bridge.proc.kill() } catch { /* already gone */ }
|
|
153
180
|
scheduleRestore(id, viewer, current)
|
|
154
181
|
}
|
|
155
|
-
proc.on('exit', gone)
|
|
156
|
-
proc.on('error', gone)
|
|
182
|
+
proc.on('exit', (code, signal) => gone(`helper exited before native PTY startup (${signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`})`))
|
|
183
|
+
proc.on('error', (error) => gone(`helper process failed: ${error.message}`))
|
|
157
184
|
return { bridge, created: true }
|
|
158
185
|
}
|
|
159
186
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as pty from 'node-pty'
|
|
2
2
|
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { ensureExecutableIfPresent, nodePtySpawnHelperPath } from './pty-native-helper.mjs'
|
|
3
4
|
|
|
4
5
|
const [id, colsArg, rowsArg] = process.argv.slice(2)
|
|
5
6
|
const cols = Number(colsArg)
|
|
@@ -40,12 +41,21 @@ try {
|
|
|
40
41
|
}
|
|
41
42
|
} catch { /* attach below fails loudly if the tmux server/session is unavailable */ }
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
let terminal
|
|
45
|
+
try {
|
|
46
|
+
ensureExecutableIfPresent(nodePtySpawnHelperPath(pty.native))
|
|
47
|
+
terminal = pty.spawn('tmux', ['-u', '-L', socket, 'attach-session', '-t', id], {
|
|
48
|
+
name: 'xterm-256color',
|
|
49
|
+
cols,
|
|
50
|
+
rows,
|
|
51
|
+
env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
|
|
52
|
+
})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const detail = (error instanceof Error ? error.message : String(error))
|
|
55
|
+
.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, 500)
|
|
56
|
+
process.stderr.write(`ERROR ${detail || 'native PTY failed to start'}\n`)
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
49
59
|
|
|
50
60
|
terminal.onData((data) => process.stdout.write(Buffer.from(data, 'utf8')))
|
|
51
61
|
terminal.onExit(({ exitCode }) => process.exit(exitCode === 0 ? 0 : 1))
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { chmodSync, statSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url)
|
|
6
|
+
|
|
7
|
+
export function nodePtySpawnHelperPath(nativeModule) {
|
|
8
|
+
const nativeAddon = Object.values(require.cache).find((loaded) => loaded?.exports === nativeModule)?.filename
|
|
9
|
+
if (!nativeAddon) throw new Error('cannot locate node-pty loaded native addon')
|
|
10
|
+
return join(dirname(nativeAddon), 'spawn-helper')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ensureExecutableIfPresent(path) {
|
|
14
|
+
let mode
|
|
15
|
+
try {
|
|
16
|
+
mode = statSync(path).mode & 0o777
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error?.code === 'ENOENT') return
|
|
19
|
+
throw error
|
|
20
|
+
}
|
|
21
|
+
if ((mode & 0o111) !== 0o111) chmodSync(path, mode | 0o111)
|
|
22
|
+
}
|
|
@@ -8,23 +8,23 @@ 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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// session
|
|
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.
|
|
28
28
|
|
|
29
29
|
export type TimelineEvent =
|
|
30
30
|
| { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
|
|
@@ -39,6 +39,13 @@ function append(id: string, ev: TimelineEvent): void {
|
|
|
39
39
|
} catch { /* best-effort: a failed history append must never break the state machine or a delivery */ }
|
|
40
40
|
}
|
|
41
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
|
+
|
|
42
49
|
function readEvents(id: string): TimelineEvent[] {
|
|
43
50
|
try {
|
|
44
51
|
const p = timelinePath(id)
|
|
@@ -89,7 +96,7 @@ function scan(): void {
|
|
|
89
96
|
if (last && fpOf(last.status, last.proposal ?? null, last.note ?? null) === fp) { lastSeen.set(id, fp); continue }
|
|
90
97
|
}
|
|
91
98
|
lastSeen.set(id, fp)
|
|
92
|
-
|
|
99
|
+
recordStatus(id, status, proposal, note)
|
|
93
100
|
} catch { /* one bad record must not stall the sweep */ }
|
|
94
101
|
}
|
|
95
102
|
const live = new Set(ids)
|
|
@@ -134,7 +141,8 @@ export function lastHumanSendVia(id: string): 'note' | null {
|
|
|
134
141
|
|
|
135
142
|
// record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
|
|
136
143
|
// caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
|
|
137
|
-
// `replyVia`
|
|
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.
|
|
138
146
|
export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
|
|
139
147
|
try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
|
|
140
148
|
append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
|
|
@@ -142,9 +150,9 @@ export function recordSent(id: string, text: string, from: string | null, replyV
|
|
|
142
150
|
|
|
143
151
|
// the read surface behind GET /api/sessions/:id/timeline: the last `limit` events, oldest first, each
|
|
144
152
|
// status event carrying its composed display word. null = no such session (the route 404s).
|
|
145
|
-
// Adjacent status lines with identical (status, proposal, note) fold into their first:
|
|
146
|
-
// observing one store (a throwaway worktree/eval serve beside the live
|
|
147
|
-
//
|
|
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
|
|
148
156
|
// stays best-effort append-only and the read is where duplicates die, same stance as the board.
|
|
149
157
|
export function readTimeline(id: string, limit = 500): { events: TimelineEvent[] } | null {
|
|
150
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
|
|
@@ -892,17 +901,18 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
892
901
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
893
902
|
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
894
903
|
}
|
|
895
|
-
// @@@ withNoteReplyHint - the
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
904
|
+
// @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
|
|
905
|
+
// terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
|
|
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
|
|
909
|
+
// deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
|
|
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
|
|
@@ -996,6 +1006,30 @@ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpe
|
|
|
996
1006
|
const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
|
|
997
1007
|
return composeCommandPrompt(raw, [preset], specs)
|
|
998
1008
|
}
|
|
1009
|
+
|
|
1010
|
+
type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
|
|
1011
|
+
type SessionPromptOptions = {
|
|
1012
|
+
from?: string
|
|
1013
|
+
replyVia?: 'note'
|
|
1014
|
+
loadedSpecs?: CommandSpec[]
|
|
1015
|
+
suffix?: string
|
|
1016
|
+
}
|
|
1017
|
+
export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
|
|
1018
|
+
|
|
1019
|
+
// @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
|
|
1020
|
+
// exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
|
|
1021
|
+
// merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
|
|
1022
|
+
// request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
|
|
1023
|
+
// note/terminal inserts, so clients never own the policy or duplicate the phrase.
|
|
1024
|
+
export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
|
|
1025
|
+
const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
|
|
1026
|
+
const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
|
|
1027
|
+
const h = harnessById(target.harness || defaultHarness.id)
|
|
1028
|
+
const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
|
|
1029
|
+
const text = replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
1030
|
+
: !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
1031
|
+
return { text, ...(replyVia ? { replyVia } : {}) }
|
|
1032
|
+
}
|
|
999
1033
|
// @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
|
|
1000
1034
|
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
1001
1035
|
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
@@ -1057,7 +1091,10 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1057
1091
|
// retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
|
|
1058
1092
|
// only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
|
|
1059
1093
|
// attempts, then give up).
|
|
1060
|
-
|
|
1094
|
+
// A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
|
|
1095
|
+
// app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
|
|
1096
|
+
// retry loop is a runtime capability rather than a harness-id branch.
|
|
1097
|
+
const launchBody = harness.launchOneShot ? [born, ''] : [
|
|
1061
1098
|
`for __spex_try in 1 2 3; do`,
|
|
1062
1099
|
` __spex_t0=$SECONDS`,
|
|
1063
1100
|
` ${born}`,
|
|
@@ -1068,7 +1105,8 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1068
1105
|
`done`,
|
|
1069
1106
|
`exit $__spex_rc`,
|
|
1070
1107
|
``,
|
|
1071
|
-
]
|
|
1108
|
+
]
|
|
1109
|
+
writeFileSync(file, launchBody.join('\n'))
|
|
1072
1110
|
return file
|
|
1073
1111
|
}
|
|
1074
1112
|
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
@@ -1286,19 +1324,25 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1286
1324
|
const chosen = resolveLauncher(lname)
|
|
1287
1325
|
const h = harnessById(chosen.harness)
|
|
1288
1326
|
const pinned = h.baseCmd(chosen.cmd)
|
|
1289
|
-
// Resolve a command preset at the shared backend prompt boundary, before any worktree exists. The RAW prompt remains the
|
|
1290
|
-
// identity + originating-prompt source; only `launchPrompt` is expanded for the agent. This preserves the
|
|
1291
|
-
// no-target rule even when the plugin body itself contains `[[links]]`.
|
|
1292
1327
|
const rawPrompt = prompt
|
|
1293
1328
|
// node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
|
|
1294
1329
|
// plugin prose is payload only and can never invent scope.
|
|
1295
1330
|
const ref = nodeFromPrompt(rawPrompt)
|
|
1296
1331
|
const launchSpecs = ref ? await loadSpecs() : null
|
|
1297
|
-
let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
|
|
1298
1332
|
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1299
1333
|
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1300
1334
|
const branch = `node/${slug}`
|
|
1301
1335
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1336
|
+
// Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
|
|
1337
|
+
// preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
|
|
1338
|
+
const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
|
|
1339
|
+
const suffix = spec
|
|
1340
|
+
? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
|
|
1341
|
+
: undefined
|
|
1342
|
+
const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
|
|
1343
|
+
loadedSpecs: launchSpecs ?? undefined,
|
|
1344
|
+
suffix,
|
|
1345
|
+
})).text
|
|
1302
1346
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1303
1347
|
// the checkout delivers the tracked spec sources and the materialize below delivers the materialized
|
|
1304
1348
|
// artifacts; the ONE
|
|
@@ -1331,15 +1375,6 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1331
1375
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
1332
1376
|
// own memory load too.
|
|
1333
1377
|
bootstrapMaterialize(rec)
|
|
1334
|
-
if (ref) {
|
|
1335
|
-
// @@@ spec pointer - the prompt's first [[id]] ref named an EXISTING node.
|
|
1336
|
-
// Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
|
|
1337
|
-
// it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
|
|
1338
|
-
// is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
|
|
1339
|
-
// a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
|
|
1340
|
-
const spec = launchSpecs?.find((n) => n.id === ref)
|
|
1341
|
-
if (spec) launchPrompt = `${launchPrompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
|
|
1342
|
-
}
|
|
1343
1378
|
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
1344
1379
|
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
1345
1380
|
const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
|
|
@@ -1455,6 +1490,17 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
|
|
|
1455
1490
|
}
|
|
1456
1491
|
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
1457
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
|
+
}
|
|
1458
1504
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
1459
1505
|
const id = sessionId || ownSessionId()
|
|
1460
1506
|
if (!id || !harnessSessionId) return false
|
|
@@ -1996,19 +2042,13 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
1996
2042
|
if (blocked) return { ok: false, error: blocked }
|
|
1997
2043
|
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
1998
2044
|
}
|
|
1999
|
-
const prompt = await
|
|
2000
|
-
|
|
2001
|
-
// previous human send carried it is the note→terminal transition and gets the one-shot counter-insert
|
|
2002
|
-
// ([[session-timeline]]). Both appended here, beside the delivery, so every input surface shares the one
|
|
2003
|
-
// phrase pair and the timeline records the message WITHOUT it (the hint is transport, not conversation).
|
|
2004
|
-
const wrapped = opts.replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
2005
|
-
: !from && lastHumanSendVia(id) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
2006
|
-
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, wrapped)
|
|
2045
|
+
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
2046
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
|
|
2007
2047
|
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
2008
2048
|
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
2009
2049
|
if (r.ok && from) void recordComms(id, from)
|
|
2010
2050
|
// the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
|
|
2011
|
-
if (r.ok) recordSent(id, text, from ?? null,
|
|
2051
|
+
if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
|
|
2012
2052
|
return r
|
|
2013
2053
|
}
|
|
2014
2054
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"claude": { "harness": "claude", "cmd": "claude" },
|
|
11
11
|
"claude-headless": { "harness": "claude-headless", "cmd": "claude" },
|
|
12
12
|
"codex": { "harness": "codex", "cmd": "codex" },
|
|
13
|
+
"codex-headless": { "harness": "codex-headless", "cmd": "codex --yolo" },
|
|
13
14
|
"opencode": { "harness": "opencode", "cmd": "opencode" },
|
|
14
15
|
"opencode-headless": { "harness": "opencode-headless", "cmd": "opencode --auto" },
|
|
15
16
|
"pi": { "harness": "pi", "cmd": "pi" },
|