spexcode 0.5.0 → 0.5.1
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/codex-headless.ts +13 -0
- package/spec-cli/src/guide.ts +8 -7
- package/spec-cli/src/harness.ts +32 -4
- package/spec-cli/src/session-timeline.ts +6 -4
- package/spec-cli/src/sessions.ts +49 -30
- package/spec-cli/templates/spexcode.json +1 -0
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ Requires Node ≥ 22 and git. This part is plain tooling — no AI involved yet.
|
|
|
82
82
|
```sh
|
|
83
83
|
npm i -g spexcode # installs the `spex` command
|
|
84
84
|
cd your-repo
|
|
85
|
-
spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/, installs hooks, materializes the agent contracts
|
|
85
|
+
spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/, installs hooks, materializes the agent contracts
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
That's the whole adoption. The example lists all the built-in harnesses — remove the ones you don't
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@hono/node-server": "^1.13.7",
|
|
40
40
|
"@hono/node-ws": "^1.0.4",
|
|
41
41
|
"hono": "^4.6.14",
|
|
42
|
-
"node-pty": "
|
|
42
|
+
"node-pty": "1.2.0-beta.14",
|
|
43
43
|
"tsx": "^4.19.2"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { codexLaunchCommand } from './harness.js'
|
|
2
|
+
|
|
3
|
+
// Codex headless keeps the existing app-server bootstrap and backend-owned thread/start + first turn, but
|
|
4
|
+
// deliberately omits the final interactive `--remote … resume` attach. The shared app-server remains the
|
|
5
|
+
// durable home for the thread; follow-up delivery uses codexHarness' existing JSON-RPC transport.
|
|
6
|
+
export function codexHeadlessLaunchCommand(
|
|
7
|
+
id: string,
|
|
8
|
+
codexCmd = 'codex',
|
|
9
|
+
serverCmd?: string,
|
|
10
|
+
dir?: string,
|
|
11
|
+
): string {
|
|
12
|
+
return codexLaunchCommand(id, codexCmd, serverCmd, dir, false)
|
|
13
|
+
}
|
package/spec-cli/src/guide.ts
CHANGED
|
@@ -14,7 +14,7 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
|
|
|
14
14
|
startup commands.)
|
|
15
15
|
|
|
16
16
|
2. Adopt a repo
|
|
17
|
-
cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
17
|
+
cd <your-repo> && spex init --harness claude,codex,opencode,pi,claude-headless,opencode-headless,pi-headless,codex-headless # seeds .spec/ + git hooks (additive, never overwrites)
|
|
18
18
|
--harness is required and has no default — the explicit choice of which harness(es) materialize
|
|
19
19
|
delivers into. The example lists every built-in; drop the ones you don't use (any one id or
|
|
20
20
|
comma-separated subset is valid).
|
|
@@ -333,18 +333,19 @@ harness the adopter SELECTED (--harness), from the template pool
|
|
|
333
333
|
"claude" → { "harness": "claude", "cmd": "claude" }
|
|
334
334
|
"claude-headless" → { "harness": "claude-headless", "cmd": "claude" }
|
|
335
335
|
"codex" → { "harness": "codex", "cmd": "codex" }
|
|
336
|
+
"codex-headless" → { "harness": "codex-headless", "cmd": "codex --yolo" }
|
|
336
337
|
"opencode" → { "harness": "opencode", "cmd": "opencode" }
|
|
337
338
|
"opencode-headless" → { "harness": "opencode-headless", "cmd": "opencode --auto" }
|
|
338
339
|
"pi" → { "harness": "pi", "cmd": "pi" }
|
|
339
340
|
"pi-headless" → { "harness": "pi-headless", "cmd": "pi" }
|
|
340
|
-
The interactive profiles preserve each harness's normal permission model. \`opencode-headless\`
|
|
341
|
-
deliberate seed
|
|
342
|
-
plain. Other automatic-permission commands are NEVER clean-init
|
|
343
|
-
only when that access is intended. To run workers under an auth
|
|
341
|
+
The interactive profiles preserve each harness's normal permission model. \`opencode-headless\` and
|
|
342
|
+
\`codex-headless\` are deliberate seed exceptions: their terminal-free runs require \`opencode --auto\` and
|
|
343
|
+
\`codex --yolo\`; interactive profiles stay plain. Other automatic-permission commands are NEVER clean-init
|
|
344
|
+
defaults: define and select one explicitly only when that access is intended. To run workers under an auth
|
|
344
345
|
wrapper (e.g. reclaude), point a launcher's \`cmd\` at it in spexcode.local.json — there is no environment
|
|
345
346
|
override that rewrites a launcher's command. Add more profiles when a project needs named auth/config-dir or
|
|
346
347
|
permission variants. Shape:
|
|
347
|
-
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "opencode-headless" | "pi-headless",
|
|
348
|
+
"launchers": { "<name>": { "harness": "claude" | "codex" | "opencode" | "pi" | "claude-headless" | "codex-headless" | "opencode-headless" | "pi-headless",
|
|
348
349
|
"cmd": "<launch command>" } }
|
|
349
350
|
\`harness\` defaults to "claude"; \`cmd\` is required and embedded whole. A portable plain command may live
|
|
350
351
|
in committed spexcode.json (as the init seeds do). A host-specific command — an absolute wrapper path,
|
|
@@ -453,7 +454,7 @@ Example — tune opt-in health diagnosis without changing the lint gate:
|
|
|
453
454
|
preset the SELECTED init preset — which cumulative .plugins tier \`spex init\` seeds (default
|
|
454
455
|
'default'; seed-time only, read by init.ts).
|
|
455
456
|
harnesses which harness targets \`spex materialize\` delivers into — native ids
|
|
456
|
-
("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"opencode-headless"|"pi-headless") or a
|
|
457
|
+
("claude"|"codex"|"opencode"|"pi"|"claude-headless"|"codex-headless"|"opencode-headless"|"pi-headless") or a
|
|
457
458
|
{ "plugin": "<folder>" } bundle. REQUIRED — there is no default set: \`spex init --harness\`
|
|
458
459
|
stamps the explicit choice, and a missing field fails materialize loud. PERSISTENT and
|
|
459
460
|
git-transactional: the edit takes effect at the next git-native materialize anchor (the commit
|
package/spec-cli/src/harness.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlash
|
|
|
10
10
|
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js'
|
|
11
11
|
import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
|
|
12
12
|
import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js'
|
|
13
|
+
import { codexHeadlessLaunchCommand } from './codex-headless.js'
|
|
13
14
|
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
|
|
14
15
|
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
|
|
15
16
|
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
@@ -27,7 +28,7 @@ import { git } from './git.js'
|
|
|
27
28
|
// payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
|
|
28
29
|
// (materialize writes every harness's artifacts).
|
|
29
30
|
|
|
30
|
-
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'opencode-headless' | 'pi-headless'
|
|
31
|
+
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
31
32
|
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
32
33
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
33
34
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
@@ -47,6 +48,9 @@ export interface Harness {
|
|
|
47
48
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
48
49
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
49
50
|
readonly headless: boolean
|
|
51
|
+
// whether the launch command intentionally exits after its first turn instead of owning a resident process.
|
|
52
|
+
// One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
|
|
53
|
+
readonly launchOneShot?: boolean
|
|
50
54
|
// whether this harness persists a native event stream that the console may expose as an optional
|
|
51
55
|
// full-process drill-down ([[message-stream]]). This is adapter data, never a harness-id branch in UI.
|
|
52
56
|
readonly messageStream: boolean
|
|
@@ -446,7 +450,7 @@ export function codexSupportsBypassHookTrust(binary: string): boolean {
|
|
|
446
450
|
bypassProbe.set(binary, ok)
|
|
447
451
|
return ok
|
|
448
452
|
}
|
|
449
|
-
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot()): string {
|
|
453
|
+
export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
|
|
450
454
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
451
455
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
452
456
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -516,11 +520,17 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
516
520
|
// rollout has landed (resume-ready), so a fail-loud (empty output / non-zero) must ABORT — never `resume ""`.
|
|
517
521
|
`if [ "$1" = "--resume" ]; then`,
|
|
518
522
|
` tid=$2`,
|
|
523
|
+
...(attachTui ? [] : [
|
|
524
|
+
// A headless forced reopen has no TUI to attach and the shared app-server already owns the thread. Keep it
|
|
525
|
+
// a no-op instead of calling codex-launch without a prompt (which would mint an unrelated empty thread).
|
|
526
|
+
`elif [ "$#" -eq 0 ]; then`,
|
|
527
|
+
` exit 0`,
|
|
528
|
+
]),
|
|
519
529
|
`else`,
|
|
520
530
|
` tid=$(${SPEX} internal codex-launch "$sock" "$PWD" "$@") || exit 1`,
|
|
521
531
|
`fi`,
|
|
522
532
|
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
523
|
-
`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"
|
|
533
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} --remote unix://"$sock" resume "$tid"`] : []),
|
|
524
534
|
].join('\n')
|
|
525
535
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
526
536
|
}
|
|
@@ -1230,6 +1240,24 @@ export const codexHarness: Harness = {
|
|
|
1230
1240
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
1231
1241
|
}
|
|
1232
1242
|
|
|
1243
|
+
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
1244
|
+
// while launch only runs the backend-owned thread/start + first turn. There is no TUI to attach after that turn;
|
|
1245
|
+
// the shared project app-server keeps the thread addressable and idle sends use the inherited JSON-RPC channel.
|
|
1246
|
+
export const codexHeadlessHarness: Harness = {
|
|
1247
|
+
...codexHarness,
|
|
1248
|
+
id: 'codex-headless',
|
|
1249
|
+
headless: true,
|
|
1250
|
+
launchOneShot: true,
|
|
1251
|
+
messageStream: false,
|
|
1252
|
+
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
1253
|
+
// Record-backed liveness is the family contract for sleeping headless threads. A broken app-server or missing
|
|
1254
|
+
// thread is surfaced by the inherited delivery call rather than converted into a speculative offline state.
|
|
1255
|
+
liveness: () => 'online',
|
|
1256
|
+
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
1257
|
+
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
1258
|
+
resumeArg: () => '',
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1233
1261
|
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
1234
1262
|
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
1235
1263
|
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
@@ -1367,7 +1395,7 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
1367
1395
|
}
|
|
1368
1396
|
|
|
1369
1397
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
1370
|
-
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness]
|
|
1398
|
+
export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness, opencodeHarness, piHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness]
|
|
1371
1399
|
|
|
1372
1400
|
// the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
|
|
1373
1401
|
export const defaultHarness: Harness = claudeHarness
|
|
@@ -22,9 +22,10 @@ import type { Lifecycle, Proposal } from './sessions.js'
|
|
|
22
22
|
// is recorded — liveness (offline/starting/unknown) is a present-tense derivation ([[state]]), re-derived
|
|
23
23
|
// per probe and never history, so it stays off the durable log; a surface shows the CURRENT liveness from
|
|
24
24
|
// the board row. The timeline lives and dies with the session record (close sweeps the store dir), like
|
|
25
|
-
// comms.ndjson. `sent` events are appended by sendText on a CONFIRMED delivery (
|
|
26
|
-
//
|
|
27
|
-
//
|
|
25
|
+
// comms.ndjson. `sent` events are appended by sendText on a CONFIRMED post-launch delivery (dashboard/phone
|
|
26
|
+
// input, `spex session send`, merge and issue dispatch); the initial launch prompt passes through the same
|
|
27
|
+
// composition seam but has no adapter confirmation to record here. `from` is the sending session's id,
|
|
28
|
+
// null = a human surface.
|
|
28
29
|
|
|
29
30
|
export type TimelineEvent =
|
|
30
31
|
| { ts: string; kind: 'status'; status: Lifecycle; proposal: Proposal | null; note: string | null; display?: string }
|
|
@@ -134,7 +135,8 @@ export function lastHumanSendVia(id: string): 'note' | null {
|
|
|
134
135
|
|
|
135
136
|
// record a CONFIRMED prompt delivery (called by sendText after the harness accepted it). `text` is the
|
|
136
137
|
// caller's message BEFORE any mechanism insert (the note-reply hint is transport, not conversation);
|
|
137
|
-
// `replyVia`
|
|
138
|
+
// `replyVia` is the effective channel chosen by the shared prompt seam, whether explicit or derived from the
|
|
139
|
+
// target adapter, so the durable history records where the reply was actually readable.
|
|
138
140
|
export function recordSent(id: string, text: string, from: string | null, replyVia?: 'note'): void {
|
|
139
141
|
try { if (!readAliasedRawRecord(id)?.governed) return } catch { return }
|
|
140
142
|
append(id, { ts: new Date().toISOString(), kind: 'sent', text, from, ...(replyVia ? { replyVia } : {}) })
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -892,12 +892,12 @@ export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
|
892
892
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
893
893
|
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
894
894
|
}
|
|
895
|
-
// @@@ withNoteReplyHint - the
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
900
|
-
//
|
|
895
|
+
// @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
|
|
896
|
+
// 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
|
|
899
|
+
// deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
|
|
900
|
+
// default. The notice declares itself
|
|
901
901
|
// PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
|
|
902
902
|
// note-replied a few times keeps note-replying from context inertia long after the human is back at a
|
|
903
903
|
// terminal — the sticky-note failure this pair exists to prevent.
|
|
@@ -996,6 +996,30 @@ export async function resolveCommandPrompt(raw: string, loadedSpecs?: CommandSpe
|
|
|
996
996
|
const specs = loadedSpecs ?? (nodeFromPrompt(raw) ? await loadSpecs() : [])
|
|
997
997
|
return composeCommandPrompt(raw, [preset], specs)
|
|
998
998
|
}
|
|
999
|
+
|
|
1000
|
+
type SessionPromptTarget = Pick<SessRec, 'session' | 'harness'>
|
|
1001
|
+
type SessionPromptOptions = {
|
|
1002
|
+
from?: string
|
|
1003
|
+
replyVia?: 'note'
|
|
1004
|
+
loadedSpecs?: CommandSpec[]
|
|
1005
|
+
suffix?: string
|
|
1006
|
+
}
|
|
1007
|
+
export type ComposedSessionPrompt = { text: string; replyVia?: 'note' }
|
|
1008
|
+
|
|
1009
|
+
// @@@ composeSessionPrompt - the ONE prompt-delivery seam: raw caller text + target session become the
|
|
1010
|
+
// exact text handed to an adapter. Launch, ordinary input, CLI send, issue dispatch, watch greetings, and
|
|
1011
|
+
// merge all enter here (directly or through sendText). `replyVia` is target readability: an explicit note
|
|
1012
|
+
// request wins; otherwise a headless adapter defaults to note. This function alone decides and appends the
|
|
1013
|
+
// note/terminal inserts, so clients never own the policy or duplicate the phrase.
|
|
1014
|
+
export async function composeSessionPrompt(raw: string, target: SessionPromptTarget, opts: SessionPromptOptions = {}): Promise<ComposedSessionPrompt> {
|
|
1015
|
+
const resolved = await resolveCommandPrompt(raw, opts.loadedSpecs)
|
|
1016
|
+
const prompt = opts.suffix ? `${resolved}${opts.suffix}` : resolved
|
|
1017
|
+
const h = harnessById(target.harness || defaultHarness.id)
|
|
1018
|
+
const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
|
|
1019
|
+
const text = replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
1020
|
+
: !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
1021
|
+
return { text, ...(replyVia ? { replyVia } : {}) }
|
|
1022
|
+
}
|
|
999
1023
|
// @@@ identity-token strip - an `@session` actor mention ([[mentions]]) or a bare UUID-shaped token in the
|
|
1000
1024
|
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
1001
1025
|
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
@@ -1057,7 +1081,10 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1057
1081
|
// retry window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This
|
|
1058
1082
|
// only closes startup unready failures — it adds no fallback and never masks a genuinely dead agent (3
|
|
1059
1083
|
// attempts, then give up).
|
|
1060
|
-
|
|
1084
|
+
// A one-shot adapter (currently codex-headless) deliberately exits after its first turn while the shared
|
|
1085
|
+
// app-server stays alive. Retrying that successful fast exit would mint a duplicate thread/prompt, so the
|
|
1086
|
+
// retry loop is a runtime capability rather than a harness-id branch.
|
|
1087
|
+
const launchBody = harness.launchOneShot ? [born, ''] : [
|
|
1061
1088
|
`for __spex_try in 1 2 3; do`,
|
|
1062
1089
|
` __spex_t0=$SECONDS`,
|
|
1063
1090
|
` ${born}`,
|
|
@@ -1068,7 +1095,8 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1068
1095
|
`done`,
|
|
1069
1096
|
`exit $__spex_rc`,
|
|
1070
1097
|
``,
|
|
1071
|
-
]
|
|
1098
|
+
]
|
|
1099
|
+
writeFileSync(file, launchBody.join('\n'))
|
|
1072
1100
|
return file
|
|
1073
1101
|
}
|
|
1074
1102
|
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
@@ -1286,19 +1314,25 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1286
1314
|
const chosen = resolveLauncher(lname)
|
|
1287
1315
|
const h = harnessById(chosen.harness)
|
|
1288
1316
|
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
1317
|
const rawPrompt = prompt
|
|
1293
1318
|
// node identity + label: the RAW prompt's first `[[id]]` topic ref is the only binding channel; expanded
|
|
1294
1319
|
// plugin prose is payload only and can never invent scope.
|
|
1295
1320
|
const ref = nodeFromPrompt(rawPrompt)
|
|
1296
1321
|
const launchSpecs = ref ? await loadSpecs() : null
|
|
1297
|
-
let launchPrompt = await resolveCommandPrompt(rawPrompt, launchSpecs ?? undefined)
|
|
1298
1322
|
const title = ref ? null : titleFromPrompt(rawPrompt)
|
|
1299
1323
|
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
1300
1324
|
const branch = `node/${slug}`
|
|
1301
1325
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
1326
|
+
// Compose the FINAL launch text before making the worktree, preserving fail-before-side-effects if live
|
|
1327
|
+
// preset resolution breaks. The optional spec pointer is a seam input; the note insert remains last.
|
|
1328
|
+
const spec = ref ? launchSpecs?.find((n) => n.id === ref) : undefined
|
|
1329
|
+
const suffix = spec
|
|
1330
|
+
? `\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
|
|
1331
|
+
: undefined
|
|
1332
|
+
const launchPrompt = (await composeSessionPrompt(rawPrompt, { session: id, harness: h.id }, {
|
|
1333
|
+
loadedSpecs: launchSpecs ?? undefined,
|
|
1334
|
+
suffix,
|
|
1335
|
+
})).text
|
|
1302
1336
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
1303
1337
|
// the checkout delivers the tracked spec sources and the materialize below delivers the materialized
|
|
1304
1338
|
// artifacts; the ONE
|
|
@@ -1331,15 +1365,6 @@ export async function newSession(prompt: string, parent: string | null = null, l
|
|
|
1331
1365
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
1332
1366
|
// own memory load too.
|
|
1333
1367
|
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
1368
|
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
1344
1369
|
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
1345
1370
|
const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
|
|
@@ -1996,19 +2021,13 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
1996
2021
|
if (blocked) return { ok: false, error: blocked }
|
|
1997
2022
|
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
1998
2023
|
}
|
|
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)
|
|
2024
|
+
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
2025
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, prompt.text)
|
|
2007
2026
|
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
2008
2027
|
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
2009
2028
|
if (r.ok && from) void recordComms(id, from)
|
|
2010
2029
|
// the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
|
|
2011
|
-
if (r.ok) recordSent(id, text, from ?? null,
|
|
2030
|
+
if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
|
|
2012
2031
|
return r
|
|
2013
2032
|
}
|
|
2014
2033
|
|
|
@@ -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" },
|