spexcode 0.5.6 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/anchors.ts +48 -38
- package/spec-cli/src/cli.ts +18 -1
- package/spec-cli/src/client.ts +21 -0
- package/spec-cli/src/git.ts +380 -198
- package/spec-cli/src/harness.ts +116 -24
- package/spec-cli/src/help.ts +3 -1
- package/spec-cli/src/index.ts +41 -7
- package/spec-cli/src/layout.ts +2 -0
- package/spec-cli/src/lint.ts +38 -28
- package/spec-cli/src/mentions.ts +10 -5
- package/spec-cli/src/process-identity.ts +20 -0
- package/spec-cli/src/session-maintenance.ts +2 -1
- package/spec-cli/src/sessions.ts +898 -140
- package/spec-cli/src/specs.ts +3 -3
- package/spec-dashboard/dist/assets/{App-B72LuS5I.js → App-u2P7KdSg.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-C5X4Va3V.js → Dashboard-B8wp5_61.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-BTvJIW8Q.js → EvalsPage-Bq1Tkb8y.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-Bn94h_HQ.js → IssuesPage-BlkPSkmv.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-ClbtwZ1e.js → MobileApp-B1GxRZXK.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-6l_QtCKF.js → Modal-bAkq9IIT.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-B2kxcqJJ.js → PageScroll-px_rUZVJ.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-C8IPsMKV.js → ProjectsPage-8uGqYM12.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-CswwbewF.js +39 -0
- package/spec-dashboard/dist/assets/{SessionWindow-Dag_GiJB.js → SessionWindow-IspcLjFA.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-J3aibcXo.js → Settings-bpAbfnmS.js} +1 -1
- package/spec-dashboard/dist/assets/{Thread-Dg35J-Pu.js → Thread-BpL3N3kw.js} +11 -11
- package/spec-dashboard/dist/assets/{TimelineChat-f0UF9fXq.js → TimelineChat-Ckmb1Ez2.js} +1 -1
- package/spec-dashboard/dist/assets/{data-SNi0AmVT.js → data-CQFbQEMH.js} +1 -1
- package/spec-dashboard/dist/assets/index-CixSnz1H.css +1 -0
- package/spec-dashboard/dist/assets/{index-BUKLPN_4.js → index-Di1ch5dd.js} +5 -5
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/dist/assets/SessionInterface-B5jf7dW7.js +0 -39
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +0 -1
package/spec-cli/src/harness.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from '.
|
|
|
16
16
|
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
|
|
17
17
|
import { git } from './git.js'
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
|
-
import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
19
|
+
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
|
|
21
21
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
22
22
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -86,6 +86,13 @@ export type HarnessColdPreflight =
|
|
|
86
86
|
| { ok: true; alreadyCold?: boolean; receipt?: unknown }
|
|
87
87
|
| { ok: false; reason: string }
|
|
88
88
|
|
|
89
|
+
// The corrupt-record quarantine path has no typed session record to pass into cold storage. The adapter therefore
|
|
90
|
+
// owns this separate proof: it can archive one exact native orphan, return only public audit facts, and retain an
|
|
91
|
+
// in-memory compensation closure for the caller's atomic record move. Product code never sees a native receipt.
|
|
92
|
+
export type HarnessOrphanThreadQuarantine =
|
|
93
|
+
| { ok: true; audit: { adapter: string; threadId: string; action: 'archived' | 'already-unloaded' }; compensate(): Promise<{ ok: true } | { ok: false; reason: string }> }
|
|
94
|
+
| { ok: false; reason: string }
|
|
95
|
+
|
|
89
96
|
export type AdapterLoadedReferenceState = {
|
|
90
97
|
healthy: boolean
|
|
91
98
|
loaded: boolean
|
|
@@ -298,6 +305,9 @@ export interface Harness {
|
|
|
298
305
|
// reference or return a loud reason; adapters without such a resident reference return {ok:true}.
|
|
299
306
|
coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
300
307
|
restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
308
|
+
// Recovery for an unreadable governed record. This accepts no record-shaped ownership claim: the adapter must
|
|
309
|
+
// prove the native target has zero other governed owners, is idle and descendant-free, then archive only it.
|
|
310
|
+
quarantineOrphanThread?(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine>
|
|
301
311
|
// Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
|
|
302
312
|
// references and protect a sibling-owned control plane without learning harness command names.
|
|
303
313
|
sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
|
|
@@ -328,19 +338,26 @@ export interface Harness {
|
|
|
328
338
|
resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
|
|
329
339
|
}
|
|
330
340
|
|
|
331
|
-
//
|
|
341
|
+
// A prompt-dispatch outcome. `accepted` means the native control plane acknowledged the turn; `rejected` means
|
|
342
|
+
// it definitely did not; `commit-unknown` means the request crossed a transport boundary but its native result
|
|
343
|
+
// was lost, so replaying it could duplicate a turn. The latter is deliberately distinct from a normal rejection.
|
|
344
|
+
export type DispatchOutcome = 'accepted' | 'rejected' | 'commit-unknown'
|
|
345
|
+
// ok=true means delivery is confirmed at the layer that harness proves it: claude at
|
|
332
346
|
// the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
|
|
333
347
|
// still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
|
|
334
348
|
// accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
335
349
|
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
336
350
|
// re-exports it for its existing importers.
|
|
337
|
-
export type DispatchResult = { ok: boolean; error?: string }
|
|
351
|
+
export type DispatchResult = { ok: boolean; outcome?: DispatchOutcome; error?: string }
|
|
338
352
|
export type HarnessDeliveryRecord = {
|
|
339
353
|
session: string
|
|
340
354
|
worktreePath?: string
|
|
341
355
|
harnessSessionId?: string | null
|
|
342
356
|
runtimeDir?: string
|
|
343
357
|
launchCmd?: string | null
|
|
358
|
+
// Opaque caller-owned marker. Codex maps it to its native `clientUserMessageId`, while other adapters may
|
|
359
|
+
// ignore it; product routing never needs to know which harness recognizes the marker.
|
|
360
|
+
deliveryId?: string
|
|
344
361
|
}
|
|
345
362
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
346
363
|
// skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
|
|
@@ -440,6 +457,7 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
|
440
457
|
}
|
|
441
458
|
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
442
459
|
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
|
|
460
|
+
const codexAppServerLegacyScope = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
|
|
443
461
|
type CodexRuntimeGenerationProof = Readonly<{
|
|
444
462
|
identity: VerifiedDetachedRuntime
|
|
445
463
|
socket: Readonly<{ path: string; dev: number; ino: number }>
|
|
@@ -464,6 +482,18 @@ function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
|
|
|
464
482
|
return proof ? codexRuntimeGenerationToken(proof) : null
|
|
465
483
|
}
|
|
466
484
|
|
|
485
|
+
function codexMutationGeneration(dir = runtimeRoot()): string | null {
|
|
486
|
+
const current = codexRuntimeGeneration(dir)
|
|
487
|
+
if (current) return current
|
|
488
|
+
let pid: number
|
|
489
|
+
try {
|
|
490
|
+
pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
|
|
491
|
+
if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket()) return null
|
|
492
|
+
} catch { return null }
|
|
493
|
+
if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir))) return null
|
|
494
|
+
return codexRuntimeGeneration(dir)
|
|
495
|
+
}
|
|
496
|
+
|
|
467
497
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
468
498
|
// the launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
|
|
469
499
|
// exec's the visible TUI. The launcher, never a raw `tsx cli.ts` pair: it owns tsx resolution and the
|
|
@@ -595,10 +625,11 @@ export function codexHandshakeMessages(threadId: string): JsonRpc[] {
|
|
|
595
625
|
// only sent with a turnId read live from the thread, never from SpexCode's session status. When the thread is
|
|
596
626
|
// idle (no active turn id), START a fresh turn (turn/start). `id` is parameterized so a steer that loses the
|
|
597
627
|
// expectedTurnId race (turn ended in the read→steer window) can retry as a turn/start with id 5.
|
|
598
|
-
export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4): JsonRpc {
|
|
628
|
+
export function codexInjectMessage(threadId: string, text: string, cwd: string | undefined, activeTurnId: string | null, id = 4, clientUserMessageId?: string): JsonRpc {
|
|
629
|
+
const marker = clientUserMessageId ? { clientUserMessageId } : {}
|
|
599
630
|
if (activeTurnId)
|
|
600
|
-
return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId } }
|
|
601
|
-
return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}) } }
|
|
631
|
+
return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } }
|
|
632
|
+
return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } }
|
|
602
633
|
}
|
|
603
634
|
|
|
604
635
|
// the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
|
|
@@ -1138,7 +1169,7 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1138
1169
|
}
|
|
1139
1170
|
|
|
1140
1171
|
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
|
|
1141
|
-
const generationBefore =
|
|
1172
|
+
const generationBefore = codexMutationGeneration(dir)
|
|
1142
1173
|
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1143
1174
|
const sock = codexAppServerSock(dir)
|
|
1144
1175
|
const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
|
|
@@ -1195,7 +1226,7 @@ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
|
|
|
1195
1226
|
}
|
|
1196
1227
|
|
|
1197
1228
|
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
|
|
1198
|
-
const generation = expectedGeneration ??
|
|
1229
|
+
const generation = expectedGeneration ?? codexMutationGeneration(dir)
|
|
1199
1230
|
if (!generation || codexRuntimeGeneration(dir) !== generation)
|
|
1200
1231
|
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
1201
1232
|
const sock = codexAppServerSock(dir)
|
|
@@ -1295,6 +1326,55 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1295
1326
|
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
|
|
1296
1327
|
}
|
|
1297
1328
|
|
|
1329
|
+
async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSessionId: string }): Promise<HarnessOrphanThreadQuarantine> {
|
|
1330
|
+
const dir = runtimeRoot()
|
|
1331
|
+
const generation = codexMutationGeneration(dir)
|
|
1332
|
+
if (!generation) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
1333
|
+
const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
|
|
1334
|
+
if (owners === null) return { ok: false, reason: 'governed Codex thread-owner census is unreadable' }
|
|
1335
|
+
if (owners.length) return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` }
|
|
1336
|
+
const before = await codexColdPreflight(threadId, dir, generation)
|
|
1337
|
+
if (!before.ok) return before
|
|
1338
|
+
const plan = before.receipt
|
|
1339
|
+
if (plan.descendantIds.length || plan.guard.descendantIds.length)
|
|
1340
|
+
return { ok: false, reason: `Codex native thread ${threadId} has descendants (${[...new Set([...plan.descendantIds, ...plan.guard.descendantIds])].join(', ')})` }
|
|
1341
|
+
if (plan.guard.targetTurnPresence === 'active' || plan.guard.targetTurnPresence === 'unknown')
|
|
1342
|
+
return { ok: false, reason: `Codex native thread ${threadId} is ${plan.guard.targetTurnPresence === 'active' ? 'active' : 'unknown'}` }
|
|
1343
|
+
if (plan.subtreeIds.length !== 1 || plan.subtreeIds[0] !== threadId)
|
|
1344
|
+
return { ok: false, reason: `Codex native thread ${threadId} has an ambiguous ownership closure` }
|
|
1345
|
+
const unchangedOwners = () => governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId)
|
|
1346
|
+
const rollback = () => codexRestoreColdPlan(plan, dir)
|
|
1347
|
+
if (plan.activeIds.length === 0) {
|
|
1348
|
+
if (plan.archivedIds.length !== 1 || plan.archivedIds[0] !== threadId || plan.guard.referenceIds.includes(threadId))
|
|
1349
|
+
return { ok: false, reason: `Codex native thread ${threadId} is not uniquely archived and unloaded` }
|
|
1350
|
+
const afterOwners = unchangedOwners()
|
|
1351
|
+
if (afterOwners === null || afterOwners.length) return { ok: false, reason: 'governed Codex thread-owner census changed during quarantine verification' }
|
|
1352
|
+
return { ok: true, audit: { adapter: 'codex', threadId, action: 'already-unloaded' }, compensate: async () => ({ ok: true }) }
|
|
1353
|
+
}
|
|
1354
|
+
if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
|
|
1355
|
+
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
|
|
1356
|
+
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
|
|
1357
|
+
const archived = await codexThreadMutation(codexAppServerSock(dir), 'thread/archive', threadId, { dir, generation })
|
|
1358
|
+
if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}` }
|
|
1359
|
+
const after = await codexColdPreflight(threadId, dir, generation)
|
|
1360
|
+
const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
|
|
1361
|
+
if (!after.ok) {
|
|
1362
|
+
const restored = await rollback()
|
|
1363
|
+
return failed(restored.ok ? after.reason : `${after.reason}; ${restored.reason}`)
|
|
1364
|
+
}
|
|
1365
|
+
const afterOwners = unchangedOwners()
|
|
1366
|
+
const afterPlan = after.receipt
|
|
1367
|
+
const valid = afterPlan.descendantIds.length === 0 && afterPlan.subtreeIds.length === 1 && afterPlan.subtreeIds[0] === threadId &&
|
|
1368
|
+
afterPlan.activeIds.length === 0 && afterPlan.archivedIds.length === 1 && afterPlan.archivedIds[0] === threadId &&
|
|
1369
|
+
!afterPlan.guard.referenceIds.includes(threadId) && siblingIds.every((id) => afterPlan.guard.referenceIds.includes(id)) &&
|
|
1370
|
+
afterOwners !== null && afterOwners.length === 0
|
|
1371
|
+
if (!valid) {
|
|
1372
|
+
const restored = await rollback()
|
|
1373
|
+
return failed(restored.ok ? `Codex orphan thread ${threadId} changed during archive verification` : `Codex orphan thread ${threadId} changed during archive verification; ${restored.reason}`)
|
|
1374
|
+
}
|
|
1375
|
+
return { ok: true, audit: { adapter: 'codex', threadId, action: 'archived' }, compensate: rollback }
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1298
1378
|
async function codexMutationGuard(
|
|
1299
1379
|
threadId: string,
|
|
1300
1380
|
dir = runtimeRoot(),
|
|
@@ -1598,13 +1678,19 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
|
|
|
1598
1678
|
})
|
|
1599
1679
|
}
|
|
1600
1680
|
|
|
1601
|
-
|
|
1681
|
+
const codexTurnConfirmMs = () => {
|
|
1682
|
+
const configured = Number(process.env.SPEXCODE_CODEX_TURN_CONFIRM_MS)
|
|
1683
|
+
return Number.isFinite(configured) && configured >= 100 ? configured : 15_000
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
|
|
1602
1687
|
return new Promise((resolve) => {
|
|
1603
1688
|
const conn: Socket = createConnection(sock)
|
|
1604
1689
|
const hs = codexHandshakeMessages(threadId) // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
|
|
1605
1690
|
let buf = Buffer.alloc(0), upgraded = false, settled = false
|
|
1606
1691
|
let fragOp = 0, fragBuf = Buffer.alloc(0)
|
|
1607
1692
|
let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
|
|
1693
|
+
let injected = false
|
|
1608
1694
|
const done = (r: DispatchResult) => {
|
|
1609
1695
|
if (settled) return
|
|
1610
1696
|
settled = true
|
|
@@ -1612,9 +1698,10 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1612
1698
|
try { conn.destroy() } catch { /* */ }
|
|
1613
1699
|
resolve(r)
|
|
1614
1700
|
}
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
conn.on('
|
|
1701
|
+
const unresolved = (error: string) => done({ ok: false, outcome: injected ? 'commit-unknown' : 'rejected', error })
|
|
1702
|
+
const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs())
|
|
1703
|
+
conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`))
|
|
1704
|
+
conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'))
|
|
1618
1705
|
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
1619
1706
|
conn.on('connect', () => {
|
|
1620
1707
|
const key = randomBytes(16).toString('base64')
|
|
@@ -1625,12 +1712,14 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1625
1712
|
try { m = JSON.parse(json) } catch { return }
|
|
1626
1713
|
if (m.error) {
|
|
1627
1714
|
if (m.id === 4 && steering) // active turn ended in the read→steer window → just start a fresh turn
|
|
1628
|
-
return send(codexInjectMessage(threadId, text, cwd, null, 5))
|
|
1715
|
+
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId))
|
|
1629
1716
|
if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
|
|
1630
|
-
return send(codexInjectMessage(threadId, text, cwd, null, 5))
|
|
1631
|
-
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
|
|
1717
|
+
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId)) // before its first user message") → just turn/start
|
|
1718
|
+
return done({ ok: false, outcome: 'rejected', error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
|
|
1632
1719
|
}
|
|
1633
|
-
|
|
1720
|
+
// JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
|
|
1721
|
+
// under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
|
|
1722
|
+
if (m.id === 1 && m.result) { send(hs[1]); return send(hs[2]) } // initialize ack → initialized → ask which threads are loaded
|
|
1634
1723
|
if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
|
|
1635
1724
|
const loaded = (m.result as { data?: unknown })?.data
|
|
1636
1725
|
if (Array.isArray(loaded) && !loaded.includes(threadId))
|
|
@@ -1640,9 +1729,10 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1640
1729
|
if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
|
|
1641
1730
|
const turnId = activeTurnIdFromThread(m.result)
|
|
1642
1731
|
steering = !!turnId
|
|
1643
|
-
|
|
1732
|
+
injected = true
|
|
1733
|
+
return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)) // id 4: turn/steer the live turn, or turn/start
|
|
1644
1734
|
}
|
|
1645
|
-
if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
|
|
1735
|
+
if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true, outcome: 'accepted' }) // steer/start accepted → the model has the message
|
|
1646
1736
|
}
|
|
1647
1737
|
const drainFrames = () => {
|
|
1648
1738
|
for (;;) {
|
|
@@ -1656,7 +1746,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1656
1746
|
let payload = buf.slice(dataStart, dataStart + len)
|
|
1657
1747
|
if (masked) { const mk = buf.slice(off, off + 4); const u = Buffer.alloc(len); for (let i = 0; i < len; i++) u[i] = payload[i] ^ mk[i % 4]; payload = u }
|
|
1658
1748
|
buf = buf.slice(dataStart + len)
|
|
1659
|
-
if (op === 0x8) return
|
|
1749
|
+
if (op === 0x8) return unresolved('codex app-server sent a WebSocket close before turn/start was confirmed')
|
|
1660
1750
|
if (op === 0x9) { conn.write(encodeWsFrame(0xa, payload)); continue } // ping → pong
|
|
1661
1751
|
if (op === 0xa) continue // pong
|
|
1662
1752
|
if (op === 0x0) fragBuf = Buffer.concat([fragBuf, payload]) // continuation
|
|
@@ -1673,7 +1763,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1673
1763
|
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `codex app-server refused the WebSocket upgrade: ${head.split('\r\n')[0]}` })
|
|
1674
1764
|
upgraded = true
|
|
1675
1765
|
buf = buf.slice(i + 4)
|
|
1676
|
-
send(hs[0])
|
|
1766
|
+
send(hs[0]) // wait for initialize before its required initialized notification
|
|
1677
1767
|
}
|
|
1678
1768
|
drainFrames()
|
|
1679
1769
|
})
|
|
@@ -1683,8 +1773,8 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1683
1773
|
// fire a turn on an owned thread over the per-project socket — the same steer-vs-start delivery the live UI
|
|
1684
1774
|
// uses. The launcher calls this to materialize a freshly-started thread's rollout (the first turn = the launch
|
|
1685
1775
|
// prompt), and delivery reuses it for follow-ups. Exported so the CLI's `codex-launch` can fire the first turn.
|
|
1686
|
-
export function codexTurn(sock: string, threadId: string, text: string, cwd?: string): Promise<DispatchResult> {
|
|
1687
|
-
return sendCodexAppServerTurn(sock, threadId, text, cwd)
|
|
1776
|
+
export function codexTurn(sock: string, threadId: string, text: string, cwd?: string, clientUserMessageId?: string): Promise<DispatchResult> {
|
|
1777
|
+
return sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
|
|
1688
1778
|
}
|
|
1689
1779
|
|
|
1690
1780
|
// @@@ codex rollout on disk - the visible TUI resumes a thread via `codex --remote resume <tid>`, which reads
|
|
@@ -1740,7 +1830,7 @@ async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string
|
|
|
1740
1830
|
if (!r.ok) return { ok: false, error: `${r.error} — prompt NOT delivered` }
|
|
1741
1831
|
threadId = r.threadId
|
|
1742
1832
|
}
|
|
1743
|
-
return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath)
|
|
1833
|
+
return sendCodexAppServerTurn(sock, threadId, text, rec.worktreePath, rec.deliveryId)
|
|
1744
1834
|
}
|
|
1745
1835
|
|
|
1746
1836
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
@@ -2280,6 +2370,7 @@ export const codexHarness: Harness = {
|
|
|
2280
2370
|
if (verified.ok) return verified
|
|
2281
2371
|
return compensate(verified.reason)
|
|
2282
2372
|
},
|
|
2373
|
+
quarantineOrphanThread: codexQuarantineOrphanThread,
|
|
2283
2374
|
restoreRuntime: async (rec, suppliedReceipt) => {
|
|
2284
2375
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2285
2376
|
if (suppliedReceipt !== undefined) {
|
|
@@ -2363,7 +2454,7 @@ const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof
|
|
|
2363
2454
|
left.target.referenceState === right.target.referenceState &&
|
|
2364
2455
|
left.target.protectsControlPlane === right.target.protectsControlPlane
|
|
2365
2456
|
|
|
2366
|
-
const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
|
|
2457
|
+
const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string, excludingSessionId?: string): string[] | null => {
|
|
2367
2458
|
const root = join(runtimeDir, 'sessions')
|
|
2368
2459
|
let entries
|
|
2369
2460
|
try { entries = readdirSync(root, { withFileTypes: true }) }
|
|
@@ -2371,6 +2462,7 @@ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string,
|
|
|
2371
2462
|
const owners: string[] = []
|
|
2372
2463
|
for (const entry of entries) {
|
|
2373
2464
|
if (!entry.isDirectory()) continue
|
|
2465
|
+
if (entry.name === excludingSessionId) continue
|
|
2374
2466
|
let parsed: unknown
|
|
2375
2467
|
try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
|
|
2376
2468
|
catch (error) {
|
package/spec-cli/src/help.ts
CHANGED
|
@@ -70,6 +70,8 @@ provably cannot land.`, ['selector', 'project-bound']],
|
|
|
70
70
|
archive: ['spex session archive <SEL>', 'Cold-archive it: exact leaf/runtime stopped, worktree and conversation kept.', ['selector']],
|
|
71
71
|
unarchive: ['spex session unarchive <SEL>', 'Deprecated compatibility spelling: same behavior as resume, relaunching the same conversation.', ['selector']],
|
|
72
72
|
close: ['spex session close <SEL>', 'Retire the session and its worktree.', ['selector', 'project-bound']],
|
|
73
|
+
quarantine: ['spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <id> --worktree <absent-path> --branch <absent-branch> [--restore]',
|
|
74
|
+
'Move only an unreadable record after the backend proves every named residue absent. Quarantine and --restore both require the original exact id because corrupt rows are outside selectors.', ['project-bound']],
|
|
73
75
|
maintain: [[
|
|
74
76
|
'spex session maintain --allow-stop <SEL> [--allow-resume <SEL>[:force]] … -- <command> [args…]',
|
|
75
77
|
'spex session maintain --status',
|
|
@@ -86,7 +88,7 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
|
|
|
86
88
|
|
|
87
89
|
const SESSION_HELP_GROUPS = [
|
|
88
90
|
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
|
|
89
|
-
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'maintain'] },
|
|
91
|
+
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine', 'maintain'] },
|
|
90
92
|
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
|
|
91
93
|
{ title: 'Human escape hatch', verbs: ['attach'] },
|
|
92
94
|
] as const
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { serve } from '@hono/node-server'
|
|
2
|
-
import type { Server as HttpServer } from 'node:http'
|
|
2
|
+
import type { Server as HttpServer, ServerResponse as HttpServerResponse } from 'node:http'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
3
4
|
import { installConnectionReaper } from './reaper.js'
|
|
4
5
|
import { Hono } from 'hono'
|
|
5
6
|
import { cors } from 'hono/cors'
|
|
@@ -15,7 +16,7 @@ import { resolveLayout, mainBranch } from './layout.js'
|
|
|
15
16
|
import { getBoardJson } from './graphCache.js'
|
|
16
17
|
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
18
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
19
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
19
20
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
21
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
21
22
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
@@ -461,9 +462,28 @@ app.post('/api/sessions/edges/unwatch', async (c) => {
|
|
|
461
462
|
return c.json({ ok }, ok ? 200 : 404)
|
|
462
463
|
})
|
|
463
464
|
app.post('/api/sessions', async (c) => {
|
|
464
|
-
const
|
|
465
|
-
const
|
|
466
|
-
|
|
465
|
+
const requestKey = c.req.header('idempotency-key') || randomUUID()
|
|
466
|
+
const controller = new AbortController()
|
|
467
|
+
const rawSignal = c.req.raw.signal
|
|
468
|
+
const outgoing = (c.env as { outgoing?: HttpServerResponse }).outgoing
|
|
469
|
+
const cancel = () => {
|
|
470
|
+
if (!outgoing?.writableEnded) controller.abort(new Error('session-create caller disconnected'))
|
|
471
|
+
}
|
|
472
|
+
const cancelFromRequest = () => controller.abort(rawSignal.reason)
|
|
473
|
+
rawSignal.addEventListener('abort', cancelFromRequest, { once: true })
|
|
474
|
+
outgoing?.once('close', cancel)
|
|
475
|
+
try {
|
|
476
|
+
const body = await c.req.json().catch(() => null)
|
|
477
|
+
const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal })
|
|
478
|
+
if (result.status === 201) {
|
|
479
|
+
c.header('Idempotency-Key', requestKey)
|
|
480
|
+
return c.json(result.session, 201)
|
|
481
|
+
}
|
|
482
|
+
return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status as any)
|
|
483
|
+
} finally {
|
|
484
|
+
rawSignal.removeEventListener('abort', cancelFromRequest)
|
|
485
|
+
outgoing?.off('close', cancel)
|
|
486
|
+
}
|
|
467
487
|
})
|
|
468
488
|
// one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
|
|
469
489
|
// dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
|
|
@@ -616,8 +636,10 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
616
636
|
// `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
|
|
617
637
|
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
|
|
618
638
|
// terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
|
|
619
|
-
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
|
|
620
|
-
body?.replyVia === 'note' ? { replyVia: 'note' } : {})
|
|
639
|
+
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined, {
|
|
640
|
+
...(body?.replyVia === 'note' ? { replyVia: 'note' as const } : {}),
|
|
641
|
+
...(typeof body?.deliveryId === 'string' && body.deliveryId ? { deliveryId: body.deliveryId } : {}),
|
|
642
|
+
})
|
|
621
643
|
return c.json(r, r.ok ? 200 : 502)
|
|
622
644
|
}
|
|
623
645
|
if (body?.kind === 'keys') {
|
|
@@ -642,8 +664,20 @@ app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
|
642
664
|
app.post('/api/sessions/:id/close', async (c) => {
|
|
643
665
|
const sessionId = c.req.param('id')
|
|
644
666
|
const ok = await closeSession(sessionId)
|
|
667
|
+
// The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
|
|
668
|
+
// invalidation must happen before the success response rather than leaving the confirming board to patrol.
|
|
669
|
+
if (ok) notifyBoardChanged('sessions')
|
|
645
670
|
return c.json(ok ? { ok: true } : { ok: false, error: `no close transition was committed for session ${sessionId}` }, ok ? 200 : 404)
|
|
646
671
|
})
|
|
672
|
+
app.post('/api/sessions/:id/quarantine', async (c) => {
|
|
673
|
+
const body = await c.req.json().catch(() => null)
|
|
674
|
+
const result = await quarantineCorruptRecord(c.req.param('id'), body)
|
|
675
|
+
return c.json({ ok: true, ...result })
|
|
676
|
+
})
|
|
677
|
+
app.post('/api/sessions/:id/quarantine/restore', async (c) => {
|
|
678
|
+
const result = await restoreQuarantinedRecord(c.req.param('id'))
|
|
679
|
+
return c.json({ ok: true, ...result })
|
|
680
|
+
})
|
|
647
681
|
// archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
|
|
648
682
|
// `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
|
|
649
683
|
app.post('/api/sessions/:id/archive', async (c) => {
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -184,6 +184,8 @@ export type RawRecord = {
|
|
|
184
184
|
adapter_recovery?: string // explicit lifecycle recovery required after a partial adapter mutation; absent on old records
|
|
185
185
|
launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
|
|
186
186
|
launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
|
|
187
|
+
create_request_id?: string // SHA-256 digest of the create Idempotency-Key; the raw key is never persisted
|
|
188
|
+
create_payload_hash?: string // normalized create payload bound to create_request_id
|
|
187
189
|
launch_readiness_pending?: '' | RawLaunchReadinessPending
|
|
188
190
|
}
|
|
189
191
|
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText } from './git.js'
|
|
3
|
+
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText, type DriftPathEvent } from './git.js'
|
|
4
4
|
import { loadSpecs, parseFrontmatter } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
|
-
import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents,
|
|
6
|
+
import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitQueries } from './anchors.js'
|
|
7
7
|
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
8
8
|
|
|
9
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
@@ -321,25 +321,28 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
321
321
|
// silent for either relation: a dead or ambiguous selector, a selector on a directory, and an
|
|
322
322
|
// unparseable working-tree file ERROR. An extension with no designated extractor, or a designated
|
|
323
323
|
// extractor that cannot run here, also ERRORS but skips those anchors so the remaining checks continue.
|
|
324
|
+
type AnchorLintQuery = { id: string; version: number; relation: 'code' | 'related'; path: string; symbols: string[]; win: DriftPathEvent[] }
|
|
325
|
+
type AnchorLintStep = { finding: Finding } | { query: AnchorLintQuery }
|
|
324
326
|
const readyWarned = new Set<string>()
|
|
327
|
+
const anchorSteps: AnchorLintStep[] = []
|
|
325
328
|
for (const s of specs) {
|
|
326
329
|
for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
|
|
327
330
|
for (const { path, selectors } of entries) {
|
|
328
331
|
if (pending && !changed.some((file) => relationClaimsPath(path, file))) continue
|
|
329
332
|
const x = extractorFor(regs, extOf(path))
|
|
330
333
|
if (!x) {
|
|
331
|
-
|
|
334
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` } })
|
|
332
335
|
continue
|
|
333
336
|
}
|
|
334
337
|
const ready = x.ready()
|
|
335
338
|
if (ready !== true) {
|
|
336
339
|
// once per (extractor, reason), even across several anchored nodes — one repair, one message.
|
|
337
|
-
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready);
|
|
340
|
+
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); anchorSteps.push({ finding: { level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` } }) }
|
|
338
341
|
continue
|
|
339
342
|
}
|
|
340
343
|
if (!existsAtTip(path)) continue // the missing FILE already errored above
|
|
341
344
|
if (isDirectoryAtTip(path)) {
|
|
342
|
-
|
|
345
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` } })
|
|
343
346
|
continue
|
|
344
347
|
}
|
|
345
348
|
let units
|
|
@@ -348,7 +351,7 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
348
351
|
if (source === null) throw new Error(`candidate tree has no file '${path}'`)
|
|
349
352
|
units = x.extract(source, path)
|
|
350
353
|
} catch (e: any) {
|
|
351
|
-
|
|
354
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` } })
|
|
352
355
|
continue
|
|
353
356
|
}
|
|
354
357
|
// each selector resolves (or errors) on its own; only the live ones feed the window engine.
|
|
@@ -356,43 +359,50 @@ export async function specLint(root = repoRoot(), regs = extractors(root), optio
|
|
|
356
359
|
for (const sym of selectors) {
|
|
357
360
|
const res = resolveAnchor(units, sym)
|
|
358
361
|
if ('dead' in res) {
|
|
359
|
-
|
|
362
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `dead anchor: ${path}#${sym} ('${s.id}') names no unit on the current tree — the unit was deleted or renamed; update the spec's ${relation}: entry to follow it` } })
|
|
360
363
|
continue
|
|
361
364
|
}
|
|
362
365
|
if ('ambiguous' in res) {
|
|
363
|
-
|
|
366
|
+
anchorSteps.push({ finding: { level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `ambiguous anchor: ${path}#${sym} ('${s.id}') names ${res.ambiguous} same-named units in one file — an anchor must be unique; rename one unit` } })
|
|
364
367
|
continue
|
|
365
368
|
}
|
|
366
369
|
if (res.ok.typeOnly)
|
|
367
|
-
|
|
370
|
+
anchorSteps.push({ finding: { level: 'warn', rule: 'anchor', spec: s.id, file: path, msg: `${path}#${sym} anchors a ${res.ok.kind} — anchoring a type is usually wrong (types reshape with every refactor); anchor the behaviour-bearing unit instead` } })
|
|
368
371
|
live.push(sym)
|
|
369
372
|
}
|
|
370
373
|
if (!live.length) continue
|
|
371
374
|
const since = rowsFor(hidx, s.path)[0]?.hash || ''
|
|
372
375
|
const win = windowEvents(didx, since, path, s.id)
|
|
373
376
|
if (!win.length) continue
|
|
374
|
-
|
|
375
|
-
if (!hits.length) continue
|
|
376
|
-
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
377
|
-
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
378
|
-
const unparseable = hits.filter((h) => h.unparseable)
|
|
379
|
-
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
380
|
-
if (relation === 'code') {
|
|
381
|
-
const current = pending && hits.some((hit) => hit.commit === tip)
|
|
382
|
-
const older = pending && hits.some((hit) => hit.commit !== tip)
|
|
383
|
-
const remedy = !pending
|
|
384
|
-
? `update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds`
|
|
385
|
-
: current && older
|
|
386
|
-
? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${s.id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
|
|
387
|
-
: current
|
|
388
|
-
? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${s.id}" …'; a later empty ack cannot pre-author this candidate`
|
|
389
|
-
: `update the spec in this commit, or clear this older debt with 'spex spec ack ${s.id} --reason "…"' before retrying the candidate`
|
|
390
|
-
out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
|
|
391
|
-
} else
|
|
392
|
-
out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
377
|
+
anchorSteps.push({ query: { id: s.id, version: s.version, relation, path, symbols: live, win } })
|
|
393
378
|
}
|
|
394
379
|
}
|
|
395
380
|
}
|
|
381
|
+
const anchorHits = await anchorHitQueries(root, anchorSteps.flatMap((step) => 'query' in step ? [{ win: step.query.win, symbols: step.query.symbols }] : []), regs)
|
|
382
|
+
let hitIndex = 0
|
|
383
|
+
for (const step of anchorSteps) {
|
|
384
|
+
if ('finding' in step) { out.push(step.finding); continue }
|
|
385
|
+
const { id, version, relation, path } = step.query
|
|
386
|
+
const hits = anchorHits[hitIndex++]
|
|
387
|
+
if (!hits.length) continue
|
|
388
|
+
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
389
|
+
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
390
|
+
const unparseable = hits.filter((h) => h.unparseable)
|
|
391
|
+
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
392
|
+
if (relation === 'code') {
|
|
393
|
+
const current = pending && hits.some((hit) => hit.commit === tip)
|
|
394
|
+
const older = pending && hits.some((hit) => hit.commit !== tip)
|
|
395
|
+
const remedy = !pending
|
|
396
|
+
? `update the spec, or 'spex spec ack ${id} --reason "…"' if the contract still holds`
|
|
397
|
+
: current && older
|
|
398
|
+
? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
|
|
399
|
+
: current
|
|
400
|
+
? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${id}" …'; a later empty ack cannot pre-author this candidate`
|
|
401
|
+
: `update the spec in this commit, or clear this older debt with 'spex spec ack ${id} --reason "…"' before retrying the candidate`
|
|
402
|
+
out.push({ level: 'error', rule: 'anchor-drift', spec: id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${id}' v${version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
|
|
403
|
+
} else
|
|
404
|
+
out.push({ level: 'warn', rule: 'related-drift', spec: id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${id}') was changed by ${hits.length} commit(s) since v${version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
405
|
+
}
|
|
396
406
|
|
|
397
407
|
// drift: a governed file has commits NOT yet reflected in its spec. Judged by true git ancestry —
|
|
398
408
|
// loadSpecs computes `driftFiles` via driftFor() over the one cached driftIndex walk (git.ts): a
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// node) and `@session` (an ACTOR — a live session, or `@new` for a fresh worker). The same parser resolves
|
|
3
3
|
// them in ANY input box; the resolve+dispatch live HERE (CLI-first) so the issues page, the composer, and an agent's
|
|
4
4
|
// own prompt share one implementation. An `@` "just auto-sends a prompt": resolve it against the live board
|
|
5
|
-
// sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s
|
|
5
|
+
// sessions and dispatch via [[dispatch]]'s sendText / [[launch]]'s bounded create owner — storage and delivery stay
|
|
6
6
|
// separate, and sessions.ts is imported LAZILY so a mention-free post pays nothing.
|
|
7
7
|
|
|
8
8
|
// ── parse (pure) ──────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -87,7 +87,7 @@ function mentionPrompt(threadId: string, node: string | null, author: string, te
|
|
|
87
87
|
// A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
|
|
88
88
|
// landed, so the prompt leads with the status and a verify-on-main-first instruction.
|
|
89
89
|
export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
|
|
90
|
-
// Keep inherited scope inside the text the worker receives:
|
|
90
|
+
// Keep inherited scope inside the text the worker receives: the create transaction derives its node only from the
|
|
91
91
|
// raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
|
|
92
92
|
const on = node ? ` on node [[${node}]]` : ''
|
|
93
93
|
const settled = status && status !== 'open'
|
|
@@ -109,7 +109,7 @@ export async function dispatchMentions(
|
|
|
109
109
|
): Promise<DispatchOutcome[]> {
|
|
110
110
|
const { actors } = parseMentions(text)
|
|
111
111
|
if (!actors.length) return []
|
|
112
|
-
const { sendText, listSessions,
|
|
112
|
+
const { sendText, listSessions, sessionCreateRequest } = await import('./sessions.js')
|
|
113
113
|
const sessions = await listSessions()
|
|
114
114
|
const resolved = resolveActors(actors, sessions as unknown as ActorSession[])
|
|
115
115
|
const out: DispatchOutcome[] = []
|
|
@@ -120,8 +120,13 @@ export async function dispatchMentions(
|
|
|
120
120
|
// deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
|
|
121
121
|
const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
|
|
122
122
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const created = await sessionCreateRequest({
|
|
124
|
+
prompt: newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status),
|
|
125
|
+
parent: spawnParent(ctx.author, sessions),
|
|
126
|
+
launcher: r.launcher,
|
|
127
|
+
})
|
|
128
|
+
if (created.status !== 201) throw new Error(`${created.code || 'session_create_failed'}: ${created.error}`)
|
|
129
|
+
out.push({ token: r.token, result: 'spawned', detail: created.session.id, ...(settled ? { note: `thread ${settled}` } : {}) })
|
|
125
130
|
} catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
|
|
126
131
|
continue
|
|
127
132
|
}
|
|
@@ -142,6 +142,26 @@ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter:
|
|
|
142
142
|
return live
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
|
|
146
|
+
// call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
|
|
147
|
+
export function migrateLegacyDetachedRuntimeReceipt(
|
|
148
|
+
pid: number,
|
|
149
|
+
legacyScopeFile: string,
|
|
150
|
+
receiptFile: string,
|
|
151
|
+
adapter: ProcessAdapter = hostProcessAdapter,
|
|
152
|
+
): boolean {
|
|
153
|
+
if (adapter.platform !== 'linux' || verifyDetachedRuntime(pid, receiptFile, adapter).ok) return false
|
|
154
|
+
let fields: string[]
|
|
155
|
+
try { fields = readFileSync(legacyScopeFile, 'utf8').trim().split(/\s+/) }
|
|
156
|
+
catch { return false }
|
|
157
|
+
const observed = observeDetachedRuntime(pid, adapter)
|
|
158
|
+
if (!observed.ok || observed.identity.linuxSessionId === undefined) return false
|
|
159
|
+
const expected = ['detached-v3', String(pid), observed.identity.startToken, String(observed.identity.processGroupId), String(observed.identity.linuxSessionId)]
|
|
160
|
+
if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) return false
|
|
161
|
+
try { writeDetachedRuntimeReceipt(pid, receiptFile, adapter); return true }
|
|
162
|
+
catch { return false }
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
export function writeDetachedRuntimeReceipt(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): VerifiedDetachedRuntime {
|
|
146
166
|
const observed = observeDetachedRuntime(pid, adapter)
|
|
147
167
|
if (!observed.ok) throw new Error(`cannot prove detached shared runtime: ${observed.reason}`)
|