spexcode 0.5.9 → 0.6.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/package.json +1 -1
- package/spec-cli/src/cli.ts +69 -70
- package/spec-cli/src/client.ts +1 -1
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +12 -10
- package/spec-cli/src/help.ts +9 -14
- package/spec-cli/src/index.ts +10 -2
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/session-cursors.ts +11 -17
- package/spec-cli/src/session-follow.ts +6 -6
- package/spec-cli/src/sessions.ts +224 -38
- package/spec-cli/src/uninstall.ts +2 -1
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +5 -41
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/reviews.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { listSessions } from './sessions.js'
|
|
3
3
|
import { getBoard, getBoardForForgeRevision } from './graphCache.js'
|
|
4
|
-
import { buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
4
|
+
import { type SessionEvalOrderRow, buildSessionEvals, type SessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
5
5
|
import { evalTimeline } from '../../spec-eval/src/evaltab.js'
|
|
6
6
|
import { issuesEnabled as issuesEnabledForReview } from './localIssues.js'
|
|
7
7
|
import { issueStores as issueStoresForReview } from './issues.js'
|
|
@@ -217,15 +217,26 @@ export function scopedEvalReviewItems(model: SessionEvals): ReviewItem[] {
|
|
|
217
217
|
|
|
218
218
|
const evalItemKey = (item: any): string => `${String(item?.node ?? '')}\0${String(item?.scenario ?? '')}`
|
|
219
219
|
|
|
220
|
-
function evalNeighbor(item: any): EvalNeighbor {
|
|
220
|
+
function evalNeighbor(item: any, stateOf: (row: { node: string; scenario: string }) => string): EvalNeighbor {
|
|
221
221
|
return {
|
|
222
222
|
node: String(item.node),
|
|
223
223
|
scenario: String(item.scenario),
|
|
224
|
-
state:
|
|
224
|
+
state: stateOf(item),
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
// `sequence` is the whole measured population in list order; `stateOf` answers only for rows whose freshness
|
|
229
|
+
// was actually computed. On a focused build those are two different sets — the sequence spans the scope, the
|
|
230
|
+
// states cover the selected row and its window — which is exactly why the state is looked up rather than
|
|
231
|
+
// carried: a row this response does not render contributes its POSITION and nothing else.
|
|
232
|
+
export function boundedEvalNeighbors(
|
|
233
|
+
sequence: { node: string; scenario: string }[],
|
|
234
|
+
node: string,
|
|
235
|
+
scenario: string,
|
|
236
|
+
stateOf: (row: { node: string; scenario: string }) => string,
|
|
237
|
+
want = 5,
|
|
238
|
+
) {
|
|
239
|
+
const items = sequence
|
|
229
240
|
const key = `${node}\0${scenario}`
|
|
230
241
|
const index = items.findIndex((item) => evalItemKey(item) === key)
|
|
231
242
|
if (index < 0) return { prev: [], next: [], total: items.length, index: null, order: 'default' as const }
|
|
@@ -235,8 +246,8 @@ export function boundedEvalNeighbors(items: ReviewItem[], node: string, scenario
|
|
|
235
246
|
const nextN = Math.min(after, Math.max(Math.ceil(take / 2), take - before))
|
|
236
247
|
const prevN = Math.min(before, take - nextN)
|
|
237
248
|
return {
|
|
238
|
-
prev: items.slice(index - prevN, index).reverse().map(evalNeighbor),
|
|
239
|
-
next: items.slice(index + 1, index + 1 + nextN).map(evalNeighbor),
|
|
249
|
+
prev: items.slice(index - prevN, index).reverse().map((item) => evalNeighbor(item, stateOf)),
|
|
250
|
+
next: items.slice(index + 1, index + 1 + nextN).map((item) => evalNeighbor(item, stateOf)),
|
|
240
251
|
total: items.length,
|
|
241
252
|
index,
|
|
242
253
|
order: 'default' as const,
|
|
@@ -248,12 +259,22 @@ export function projectEvalDetail(
|
|
|
248
259
|
historySource: ReviewItem[],
|
|
249
260
|
node: string,
|
|
250
261
|
scenario: string,
|
|
251
|
-
metadata: {
|
|
262
|
+
metadata: {
|
|
263
|
+
scope?: string | null
|
|
264
|
+
summary?: SessionEvals['summary']
|
|
265
|
+
evalRevision?: SessionEvals['evalRevision']
|
|
266
|
+
// the scope's whole measured sequence, when `items` deliberately holds only the rendered window
|
|
267
|
+
sequence?: { node: string; scenario: string }[]
|
|
268
|
+
} = {},
|
|
252
269
|
): EvalDetailReview {
|
|
253
270
|
const results = items.filter((item: any) => item.filterKind === EVAL_FILTER_KIND.RESULT)
|
|
254
271
|
const selected = results.find((item) => evalItemKey(item) === `${node}\0${scenario}`) ?? null
|
|
255
272
|
const history = historySource.filter((reading: any) => String(reading.scenario) === scenario)
|
|
256
|
-
const
|
|
273
|
+
const stateByKey = new Map(results.map((item: any) => [evalItemKey(item), String(item.state ?? evalReviewState(item))]))
|
|
274
|
+
const sequence = metadata.sequence
|
|
275
|
+
?? results.map((item: any) => ({ node: String(item.node), scenario: String(item.scenario) }))
|
|
276
|
+
const neighbors = boundedEvalNeighbors(sequence, node, scenario,
|
|
277
|
+
(row) => stateByKey.get(evalItemKey(row)) ?? 'empty')
|
|
257
278
|
const scope = metadata.scope ?? null
|
|
258
279
|
return {
|
|
259
280
|
scope,
|
|
@@ -266,15 +287,38 @@ export function projectEvalDetail(
|
|
|
266
287
|
}
|
|
267
288
|
}
|
|
268
289
|
|
|
290
|
+
// the measured population in list order, from the freshness-free rows: a filed reading leads (newest first),
|
|
291
|
+
// and the tie-breaks are the identity ones — the SAME comparison `byNewest` applies, over the only fields it
|
|
292
|
+
// actually reads. Blind rows never enter, exactly as the detail's own `results` filter excludes them.
|
|
293
|
+
export function measuredSequence(order: SessionEvalOrderRow[]): { node: string; scenario: string }[] {
|
|
294
|
+
return order.filter((row) => row.ts)
|
|
295
|
+
.sort((a, b) => String(b.ts ?? '').localeCompare(String(a.ts ?? ''))
|
|
296
|
+
|| a.node.localeCompare(b.node) || a.scenario.localeCompare(b.scenario))
|
|
297
|
+
.map((row) => ({ node: row.node, scenario: row.scenario }))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// the nodes whose verdicts the response will publish: the selected row's, plus a window wide enough to
|
|
301
|
+
// contain any neighbour boundedEvalNeighbors can choose (it takes at most five, split around the index).
|
|
302
|
+
export function focusNodes(order: SessionEvalOrderRow[], node: string, scenario: string): string[] {
|
|
303
|
+
const sequence = measuredSequence(order)
|
|
304
|
+
const index = sequence.findIndex((row) => row.node === node && row.scenario === scenario)
|
|
305
|
+
if (index < 0) return [node]
|
|
306
|
+
return [...new Set([node, ...sequence.slice(Math.max(0, index - 6), index + 7).map((row) => row.node)])]
|
|
307
|
+
}
|
|
308
|
+
|
|
269
309
|
export async function evalDetailReview(node: string, scenario: string, scope?: string | null): Promise<EvalDetailReview | null> {
|
|
270
310
|
if (scope) {
|
|
271
|
-
|
|
311
|
+
// A detail renders ONE row plus at most five neighbours, but owes the whole population's index/total.
|
|
312
|
+
// So name the window from the freshness-free sequence and let only those nodes pay the freshness pass;
|
|
313
|
+
// a build that finds a full cached model ignores the pick and answers from it instead.
|
|
314
|
+
const model = await buildSessionEvals(scope, (order) => focusNodes(order, node, scenario))
|
|
272
315
|
if (!model) return null
|
|
273
316
|
const sourceNode = model.nodes.find((candidate) => candidate.id === node)
|
|
274
317
|
return projectEvalDetail(scopedEvalReviewItems(model), sourceNode?.evals ?? [], node, scenario, {
|
|
275
318
|
scope,
|
|
276
319
|
summary: model.summary,
|
|
277
320
|
evalRevision: model.evalRevision,
|
|
321
|
+
...(model.order ? { sequence: measuredSequence(model.order) } : {}),
|
|
278
322
|
})
|
|
279
323
|
}
|
|
280
324
|
await getBoard()
|
|
@@ -335,13 +379,13 @@ export async function evalsReview(query: string | undefined, requestedPage: unkn
|
|
|
335
379
|
const filtered = evalFilterModel(items, tokenFilterState(text, 'eval'), { sessions, defaultKind: 'all', defaultSection: '' })
|
|
336
380
|
return {
|
|
337
381
|
scope,
|
|
338
|
-
gates: model.gates,
|
|
382
|
+
gates: model.gates ?? [],
|
|
339
383
|
unknown: model.nodes.reduce((count, node) => count + (node.unknownCoverage?.length ?? 0), 0),
|
|
340
384
|
summary: model.summary,
|
|
341
385
|
evalRevision: model.evalRevision,
|
|
342
386
|
impact: model.impact,
|
|
343
387
|
...paginateReview(items, filtered.shown, filtered, requestedPage, {
|
|
344
|
-
domain: 'evals', scope, items, gates: model.gates, summary: model.summary,
|
|
388
|
+
domain: 'evals', scope, items, gates: model.gates ?? [], summary: model.summary,
|
|
345
389
|
evalRevision: model.evalRevision, impact: model.impact, sessions: sessions.map((session) => session.id),
|
|
346
390
|
}),
|
|
347
391
|
}
|
|
@@ -4,10 +4,13 @@ import { sessionArtifactPath, sessionStoreDir } from './layout.js'
|
|
|
4
4
|
import type { TimelineEvent } from './session-timeline.js'
|
|
5
5
|
|
|
6
6
|
// @@@ session-cursors - a reader's durable place in a log. One `cursors.json` per session in its global store
|
|
7
|
-
// dir: `
|
|
8
|
-
// event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
|
|
7
|
+
// dir: `follows` holds one entry per followed session, including the reader's own id when it watches its own
|
|
8
|
+
// log. A position is an event INDEX into timeline.ndjson (lines already consumed), so it is also the index of
|
|
9
|
+
// the next unread event. A position is NOT a work list: what a session still owes its agent is a debt, and it
|
|
10
|
+
// lives in its own queue ([[delivery-queue]]). Binding both to one counter is what made a session's own status
|
|
11
|
+
// lines get consumed as though they were mail, and made "anything outstanding?" a scan of all history.
|
|
9
12
|
|
|
10
|
-
export type Cursors = { version: 1;
|
|
13
|
+
export type Cursors = { version: 1; follows: Record<string, number> }
|
|
11
14
|
|
|
12
15
|
const cursorsPath = (id: string): string => sessionArtifactPath(id, 'cursors.json')
|
|
13
16
|
const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0)
|
|
@@ -16,7 +19,7 @@ const at = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v)
|
|
|
16
19
|
// message is the honest recovery for a lost position and skipping one is not. Followed entries whose target
|
|
17
20
|
// store dir is gone are dropped here — expiry is this read, and the next write persists it.
|
|
18
21
|
export function readCursors(id: string): Cursors {
|
|
19
|
-
let raw: {
|
|
22
|
+
let raw: { follows?: unknown } | null = null
|
|
20
23
|
try { raw = JSON.parse(readFileSync(cursorsPath(id), 'utf8')) } catch { /* no cursors yet */ }
|
|
21
24
|
const follows: Record<string, number> = {}
|
|
22
25
|
const stored = raw?.follows
|
|
@@ -26,11 +29,11 @@ export function readCursors(id: string): Cursors {
|
|
|
26
29
|
follows[target] = at(pos)
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
|
-
return { version: 1,
|
|
32
|
+
return { version: 1, follows }
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
// Written whole and atomically, one field per line — the same shape as the session record, so
|
|
33
|
-
//
|
|
35
|
+
// Written whole and atomically, one field per line — the same shape as the session record, so a position stays
|
|
36
|
+
// readable by an exact whole-line match where a value regex would not be.
|
|
34
37
|
function writeCursors(id: string, cursors: Cursors): void {
|
|
35
38
|
const dir = sessionStoreDir(id)
|
|
36
39
|
mkdirSync(dir, { recursive: true })
|
|
@@ -39,22 +42,13 @@ function writeCursors(id: string, cursors: Cursors): void {
|
|
|
39
42
|
renameSync(tmp, cursorsPath(id))
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
export const inboxCursor = (id: string): number => readCursors(id).inbox
|
|
43
|
-
|
|
44
|
-
// A reader that has shown everything up to `to`. Monotonic: a stale read can leave the position too low
|
|
45
|
-
// (a message shown twice), never too high (a message lost).
|
|
46
|
-
export function advanceInbox(id: string, to: number): void {
|
|
47
|
-
const cursors = readCursors(id)
|
|
48
|
-
if (to <= cursors.inbox) return
|
|
49
|
-
writeCursors(id, { ...cursors, inbox: to })
|
|
50
|
-
}
|
|
51
|
-
|
|
52
45
|
export const followCursor = (id: string, target: string): number | null => {
|
|
53
46
|
const stored = readCursors(id).follows[target]
|
|
54
47
|
return stored === undefined ? null : stored
|
|
55
48
|
}
|
|
56
49
|
|
|
57
50
|
// Start or advance a follow. Following IS this entry existing, so the first call registers the relationship.
|
|
51
|
+
// Monotonic: a stale read can leave a position too low (an event read twice), never too high (one lost).
|
|
58
52
|
export function advanceFollow(id: string, target: string, to: number): void {
|
|
59
53
|
const cursors = readCursors(id)
|
|
60
54
|
const stored = cursors.follows[target]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { sessionStoreDir } from './layout.js'
|
|
3
|
-
import { advanceFollow, followCursor,
|
|
3
|
+
import { advanceFollow, followCursor, unreadSince } from './session-cursors.js'
|
|
4
4
|
import { timelineDisplay, timelineEvents, timelineStamp } from './session-timeline.js'
|
|
5
5
|
import { sessionLabel, type DisplayStatus, type Session } from './sessions.js'
|
|
6
6
|
|
|
@@ -158,12 +158,12 @@ export async function followSessions(emit: (line: string) => void, opts: FollowO
|
|
|
158
158
|
state.delete(id)
|
|
159
159
|
emit(`${tag}[spex] closed · removed [id ${id}]`)
|
|
160
160
|
}
|
|
161
|
-
// THE
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
161
|
+
// THE FOLLOWER'S OWN LOG — watched exactly like any other target, on its own entry in `cursors.json`. It is
|
|
162
|
+
// a WATCH, not a delivery: a message reaches the agent as a prompt through the adapter ([[delivery-queue]]),
|
|
163
|
+
// so this position only decides what THIS process has already reported and can never make an agent miss
|
|
164
|
+
// mail. Never advanced here in take mode — the waiter stops on the event and the next wait resumes on it.
|
|
165
165
|
if (self && existsSync(sessionStoreDir(self))) {
|
|
166
|
-
const mine = unreadSince(timelineEvents(self),
|
|
166
|
+
const mine = unreadSince(timelineEvents(self), followCursor(self, self) ?? 0)
|
|
167
167
|
for (let k = 0; k < mine.events.length; k++) {
|
|
168
168
|
const e = mine.events[k]
|
|
169
169
|
if (e.kind !== 'sent') continue
|
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentity
|
|
|
12
12
|
import { materialize } from './materialize.js'
|
|
13
13
|
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
14
14
|
import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
15
|
+
import { drain, enqueue, owesDelivery } from './delivery-queue.js'
|
|
15
16
|
import { stripRefSigil } from './mentions.js'
|
|
16
17
|
import { shQuote } from './sh.js'
|
|
17
18
|
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
@@ -20,6 +21,7 @@ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGener
|
|
|
20
21
|
|
|
21
22
|
const pexec = promisify(execFile)
|
|
22
23
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
24
|
+
const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
|
|
23
25
|
const HARNESS = defaultHarness
|
|
24
26
|
const COLS = 120, ROWS = 32
|
|
25
27
|
const DEFAULT_MAX_ACTIVE = 8
|
|
@@ -71,7 +73,7 @@ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', noth
|
|
|
71
73
|
|
|
72
74
|
export type Session = {
|
|
73
75
|
id: string; node: string | null; branch: string | null; path: string
|
|
74
|
-
label: string;
|
|
76
|
+
label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
|
|
75
77
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
76
78
|
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
77
79
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
@@ -110,18 +112,26 @@ function removeLaunchFile(id: string): void {
|
|
|
110
112
|
|
|
111
113
|
// One line, bounded — the launch prompt's shape when it enters a compact headline.
|
|
112
114
|
export const HEADLINE_PREVIEW_COLUMNS = 60
|
|
115
|
+
function isBareUrl(text: string): boolean {
|
|
116
|
+
return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
|
|
117
|
+
}
|
|
113
118
|
function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
|
|
114
|
-
const
|
|
119
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
120
|
+
const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
|
|
115
121
|
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
116
122
|
}
|
|
117
123
|
|
|
118
124
|
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
119
125
|
r.name || r.node || r.title || r.branch || r.id
|
|
120
|
-
export const
|
|
121
|
-
r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
|
|
126
|
+
export const deriveTitle = (r: { name?: string | null; activity?: string | null; note?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
127
|
+
r.name || r.activity || (r.note ? oneLinePreview(r.note) : '') || (r.promptPreview ? oneLinePreview(r.promptPreview) : '') || r.node || r.title || r.branch || r.id
|
|
128
|
+
// Compatibility for package consumers that still import the old name.
|
|
129
|
+
export const deriveHeadline = deriveTitle
|
|
122
130
|
|
|
123
131
|
export const sessionLabel = (s: Session): string => s.label
|
|
124
|
-
export const
|
|
132
|
+
export const sessionTitle = (s: Session): string => s.title
|
|
133
|
+
// Compatibility for older callers; all visible surfaces now resolve through `title`.
|
|
134
|
+
export const sessionHeadline = sessionTitle
|
|
125
135
|
|
|
126
136
|
// @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
|
|
127
137
|
// HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
|
|
@@ -448,7 +458,113 @@ function writeRecord(rec: SessRec): void {
|
|
|
448
458
|
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
449
459
|
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
450
460
|
recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note)
|
|
461
|
+
scheduleWatchNotifications(rec)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
type WatchEntry = { watcher: string; createdAt: string }
|
|
466
|
+
export type SessionWatch = { target: string; createdAt: string }
|
|
467
|
+
const watchPath = (target: string) => sessionArtifactPath(target, 'watchers.json')
|
|
468
|
+
|
|
469
|
+
function readWatchEntries(target: string): WatchEntry[] {
|
|
470
|
+
try {
|
|
471
|
+
const raw = JSON.parse(readFileSync(watchPath(target), 'utf8')) as unknown
|
|
472
|
+
if (!Array.isArray(raw)) return []
|
|
473
|
+
const seen = new Set<string>()
|
|
474
|
+
return raw.flatMap((entry): WatchEntry[] => {
|
|
475
|
+
if (!entry || typeof entry !== 'object') return []
|
|
476
|
+
const watcher = (entry as WatchEntry).watcher
|
|
477
|
+
const createdAt = (entry as WatchEntry).createdAt
|
|
478
|
+
if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher)) return []
|
|
479
|
+
seen.add(watcher)
|
|
480
|
+
return [{ watcher, createdAt }]
|
|
481
|
+
})
|
|
482
|
+
} catch { return [] }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function writeWatchEntries(target: string, entries: WatchEntry[]): void {
|
|
486
|
+
const path = watchPath(target)
|
|
487
|
+
if (!entries.length) { try { unlinkSync(path) } catch { /* already absent */ }; return }
|
|
488
|
+
const dir = sessionStoreDir(target)
|
|
489
|
+
mkdirSync(dir, { recursive: true })
|
|
490
|
+
const tmp = join(dir, `.watchers.json.${process.pid}.tmp`)
|
|
491
|
+
writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n')
|
|
492
|
+
renameSync(tmp, path)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function managedWatchRecord(id: string): SessRec {
|
|
496
|
+
const rec = readRecord(id)
|
|
497
|
+
if (!rec?.governed) throw new ResourceConflict(`session ${id} is not a governed session and cannot participate in a durable watch`)
|
|
498
|
+
return rec
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function watchMessage(target: SessRec): string {
|
|
502
|
+
const status = target.status === 'awaiting'
|
|
503
|
+
? PROPOSAL_STATUS[target.proposal ?? 'nothing']
|
|
504
|
+
: target.status === 'active' ? 'working' : target.status
|
|
505
|
+
const note = target.note ? ` — ${target.note}` : ''
|
|
506
|
+
return `[spex watch] ${target.session} is ${status}${note}`
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function scheduleWatchNotifications(target: SessRec): void {
|
|
510
|
+
const watchers = readWatchEntries(target.session).map((entry) => entry.watcher)
|
|
511
|
+
if (!watchers.length) return
|
|
512
|
+
queueMicrotask(() => {
|
|
513
|
+
for (const watcher of watchers) {
|
|
514
|
+
void sendText(watcher, watchMessage(target), target.session).then((result) => {
|
|
515
|
+
if (!result.ok) console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`)
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
})
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export async function subscribeSessionWatch(watcher: string, targets: string[]): Promise<{ watched: string[] }> {
|
|
522
|
+
managedWatchRecord(watcher)
|
|
523
|
+
const watched: string[] = []
|
|
524
|
+
for (const target of [...new Set(targets)]) {
|
|
525
|
+
if (target === watcher) throw new ResourceConflict('a session cannot watch itself')
|
|
526
|
+
const targetRecord = managedWatchRecord(target)
|
|
527
|
+
withRecordLockSync(target, () => {
|
|
528
|
+
const entries = readWatchEntries(target)
|
|
529
|
+
if (!entries.some((entry) => entry.watcher === watcher)) {
|
|
530
|
+
writeWatchEntries(target, [...entries, { watcher, createdAt: new Date().toISOString() }])
|
|
531
|
+
}
|
|
532
|
+
})
|
|
533
|
+
const delivered = await sendText(watcher, watchMessage(targetRecord), target)
|
|
534
|
+
if (!delivered.ok) throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`)
|
|
535
|
+
watched.push(target)
|
|
536
|
+
}
|
|
537
|
+
return { watched }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function listSessionWatches(watcher: string): SessionWatch[] {
|
|
541
|
+
managedWatchRecord(watcher)
|
|
542
|
+
const watches: SessionWatch[] = []
|
|
543
|
+
for (const target of listSessionIds()) {
|
|
544
|
+
const entries = readWatchEntries(target)
|
|
545
|
+
const active = entries.filter((entry) => {
|
|
546
|
+
try { return !!readRecord(entry.watcher)?.governed } catch { return false }
|
|
547
|
+
})
|
|
548
|
+
if (active.length !== entries.length) writeWatchEntries(target, active)
|
|
549
|
+
for (const entry of active) if (entry.watcher === watcher) watches.push({ target, createdAt: entry.createdAt })
|
|
550
|
+
}
|
|
551
|
+
return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target))
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function cancelSessionWatch(watcher: string, targets: string[]): number {
|
|
555
|
+
managedWatchRecord(watcher)
|
|
556
|
+
let cancelled = 0
|
|
557
|
+
for (const target of [...new Set(targets)]) {
|
|
558
|
+
withRecordLockSync(target, () => {
|
|
559
|
+
const entries = readWatchEntries(target)
|
|
560
|
+
const kept = entries.filter((entry) => entry.watcher !== watcher)
|
|
561
|
+
if (kept.length !== entries.length) {
|
|
562
|
+
writeWatchEntries(target, kept)
|
|
563
|
+
cancelled++
|
|
564
|
+
}
|
|
565
|
+
})
|
|
451
566
|
}
|
|
567
|
+
return cancelled
|
|
452
568
|
}
|
|
453
569
|
|
|
454
570
|
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
@@ -662,10 +778,26 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
662
778
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
663
779
|
}
|
|
664
780
|
|
|
781
|
+
// @@@ identity WITHOUT the gates - reviewPayload answers two different questions at once: who is this
|
|
782
|
+
// session (a store read, free) and how does its branch stand against main (ahead count, dirty scan, a
|
|
783
|
+
// merge-tree conflict probe — 646 ms and 8 git children on a far-diverged branch). A consumer that renders
|
|
784
|
+
// no gates strip should not buy the second one. The record already holds the identity half.
|
|
785
|
+
export type ReviewIdentity = { id: string; node: string | null; branch: string | null; label: string }
|
|
786
|
+
export function reviewIdentity(id: string): ReviewIdentity | null {
|
|
787
|
+
const rec = readRecord(id)
|
|
788
|
+
if (!rec) return null
|
|
789
|
+
return {
|
|
790
|
+
id,
|
|
791
|
+
node: rec.node,
|
|
792
|
+
branch: rec.branch,
|
|
793
|
+
label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
665
797
|
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
666
798
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
667
799
|
return {
|
|
668
|
-
id, node: null, branch: null, path: '', label,
|
|
800
|
+
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
669
801
|
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
670
802
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
671
803
|
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
@@ -680,9 +812,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
680
812
|
const showActivity = lv === 'online'
|
|
681
813
|
const act = showActivity ? activity : null
|
|
682
814
|
const pp = prompt ? oneLinePreview(prompt) : null
|
|
683
|
-
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
815
|
+
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp }
|
|
684
816
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
685
|
-
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts),
|
|
817
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
686
818
|
}
|
|
687
819
|
|
|
688
820
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
@@ -1223,6 +1355,28 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
1223
1355
|
void tick()
|
|
1224
1356
|
}
|
|
1225
1357
|
|
|
1358
|
+
let supervisingDelivery = false
|
|
1359
|
+
// @@@ superviseDelivery - the RETRY half of [[delivery-queue]]. `sendText` hands over in its own process, which
|
|
1360
|
+
// covers the live case; this covers everything that could not be handed over then — a harness mid-restart, a
|
|
1361
|
+
// pane in the one state that swallows prompts, a session that was offline when the message arrived. Owned by
|
|
1362
|
+
// the serve that serves this project root, so a message owed to a worker is delivered when the worker can take
|
|
1363
|
+
// it rather than when it happens to run a tool. A tick with nothing owed is one existsSync per session, and
|
|
1364
|
+
// concurrent serves are harmless: the queue's lock, not the process, is what makes a handover exactly-once.
|
|
1365
|
+
export function superviseDelivery(intervalMs = 2000): void {
|
|
1366
|
+
if (supervisingDelivery) return
|
|
1367
|
+
supervisingDelivery = true
|
|
1368
|
+
const tick = async () => {
|
|
1369
|
+
try {
|
|
1370
|
+
for (const id of listSessionIds()) {
|
|
1371
|
+
if (!owesDelivery(id)) continue
|
|
1372
|
+
try { await drainSession(id) } catch { /* an adapter that refused stays owed; next tick retries */ }
|
|
1373
|
+
}
|
|
1374
|
+
} catch { /* transient store read; next tick retries */ }
|
|
1375
|
+
setTimeout(tick, intervalMs).unref()
|
|
1376
|
+
}
|
|
1377
|
+
void tick()
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1226
1380
|
type TurnFailureObserverState = {
|
|
1227
1381
|
fingerprint: string
|
|
1228
1382
|
subscription: FailureSubscription | null
|
|
@@ -1646,6 +1800,23 @@ type SessionCandidateReceiptRead =
|
|
|
1646
1800
|
const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
|
|
1647
1801
|
const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
|
|
1648
1802
|
const sessionCandidateLockId = (path: string, branch: string) => `create-resource-${digest(`${path}\0${branch}`)}`
|
|
1803
|
+
// The graph watcher uses this private fence to avoid rebuilding the full board while Git is still
|
|
1804
|
+
// registering a session candidate. The receipt is written before `git worktree add` and retired only
|
|
1805
|
+
// after publication or bounded cleanup, so the path names exactly the transaction-owned worktree.
|
|
1806
|
+
export function pendingSessionCreateWorktreePaths(): Set<string> {
|
|
1807
|
+
const paths = new Set<string>()
|
|
1808
|
+
let entries: import('node:fs').Dirent[]
|
|
1809
|
+
try { entries = readdirSync(sessionCandidateReceiptDir(), { withFileTypes: true }) }
|
|
1810
|
+
catch { return paths }
|
|
1811
|
+
for (const entry of entries) {
|
|
1812
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue
|
|
1813
|
+
try {
|
|
1814
|
+
const value = JSON.parse(readFileSync(join(sessionCandidateReceiptDir(), entry.name), 'utf8')) as Partial<SessionCandidateReceipt>
|
|
1815
|
+
if (typeof value.path === 'string' && value.path && typeof value.stage === 'string') paths.add(resolve(value.path))
|
|
1816
|
+
} catch { /* an in-flight atomic replace is not a candidate path */ }
|
|
1817
|
+
}
|
|
1818
|
+
return paths
|
|
1819
|
+
}
|
|
1649
1820
|
function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
|
|
1650
1821
|
const path = sessionCandidateReceiptPath(id)
|
|
1651
1822
|
if (!existsSync(path)) return { kind: 'absent' }
|
|
@@ -1834,7 +2005,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1834
2005
|
const resourceLock = sessionCandidateLockId(path, branch)
|
|
1835
2006
|
return await withRecordLock(resourceLock, async () => {
|
|
1836
2007
|
throwIfCreateAborted(signal, phase)
|
|
2008
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
|
|
1837
2009
|
let before = await sessionCandidateState(root, path, branch, signal)
|
|
2010
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
|
|
1838
2011
|
let storePresent = existsSync(sessionStoreDir(id))
|
|
1839
2012
|
const durable = readSessionCandidateReceipt(id)
|
|
1840
2013
|
if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
|
|
@@ -1866,7 +2039,12 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1866
2039
|
let published = false
|
|
1867
2040
|
try {
|
|
1868
2041
|
gitMutationStarted = true
|
|
1869
|
-
|
|
2042
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'worktree-add')
|
|
2043
|
+
const added = await withGitAbortSignal(signal, () => gitTry(
|
|
2044
|
+
['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()],
|
|
2045
|
+
{ extraEnv: DEFER_FOOTPRINT_REFRESH },
|
|
2046
|
+
))
|
|
2047
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'worktree-add')
|
|
1870
2048
|
if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
|
|
1871
2049
|
if (!added.ok || !existsSync(path)) {
|
|
1872
2050
|
throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
|
|
@@ -1874,7 +2052,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1874
2052
|
candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
|
|
1875
2053
|
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
1876
2054
|
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2055
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
|
|
1877
2056
|
seedWorktreeHostState(root, path)
|
|
2057
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
|
|
1878
2058
|
|
|
1879
2059
|
let rec: SessRec = {
|
|
1880
2060
|
session: id, governed: true, worktreePath: path, branch,
|
|
@@ -3092,12 +3272,11 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
3092
3272
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3093
3273
|
}
|
|
3094
3274
|
|
|
3095
|
-
// @@@ sendText - THE APPEND
|
|
3096
|
-
//
|
|
3097
|
-
//
|
|
3098
|
-
//
|
|
3099
|
-
//
|
|
3100
|
-
// what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3275
|
+
// @@@ sendText - THE APPEND ACCEPTS, THE QUEUE OWES ([[dispatch]]). One hold of the record lock records the
|
|
3276
|
+
// message in the durable log AND enqueues it ([[delivery-queue]]); success is decided by that write, so a
|
|
3277
|
+
// sender learns whether the message was accepted and never whether a socket was reachable. The handover is a
|
|
3278
|
+
// separate act: drain the queue into the harness adapter as an ordinary prompt. What stays LOUD is only what
|
|
3279
|
+
// genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3101
3280
|
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3102
3281
|
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3103
3282
|
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
@@ -3105,37 +3284,44 @@ export async function sendText(id: string, text: string, from?: string, opts: {
|
|
|
3105
3284
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3106
3285
|
const rec = readRecord(id)
|
|
3107
3286
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3287
|
+
// Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
|
|
3288
|
+
// the queue keeps the transport form. Composing again at handover would let a later send change the hints on
|
|
3289
|
+
// a message that was already accepted.
|
|
3108
3290
|
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3109
|
-
let sent: { mid: string }
|
|
3110
3291
|
try {
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3292
|
+
await withRecordLock(id, async () => {
|
|
3293
|
+
const appended = appendSent(id, text, from ?? null, prompt.replyVia)
|
|
3294
|
+
enqueue(id, { mid: appended.mid, text: prompt.text, from: from ?? null })
|
|
3295
|
+
})
|
|
3114
3296
|
} catch (error) {
|
|
3115
3297
|
return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
|
|
3116
3298
|
}
|
|
3117
|
-
|
|
3118
|
-
//
|
|
3119
|
-
//
|
|
3120
|
-
|
|
3121
|
-
// durable line.
|
|
3122
|
-
await pokeAdapter(h, rec, prompt.text, sent.mid)
|
|
3299
|
+
// Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
|
|
3300
|
+
// costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
|
|
3301
|
+
// the text in a live agent's current turn instead of up to one tick later.
|
|
3302
|
+
await drainSession(id)
|
|
3123
3303
|
return { ok: true }
|
|
3124
3304
|
}
|
|
3125
3305
|
|
|
3126
|
-
//
|
|
3127
|
-
//
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
if (
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3306
|
+
// @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
|
|
3307
|
+
// at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
|
|
3308
|
+
// The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
|
|
3309
|
+
export async function drainSession(id: string): Promise<void> {
|
|
3310
|
+
if (!owesDelivery(id)) return
|
|
3311
|
+
const rec = readRecord(id)
|
|
3312
|
+
if (!rec) return
|
|
3313
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3314
|
+
await drain(id, async (msg) => {
|
|
3315
|
+
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3316
|
+
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
|
|
3317
|
+
// rather than a skip — the message stays owed and the sweep hands it over once the pane leaves that state.
|
|
3318
|
+
if (h.deliveryBlockedBy) {
|
|
3319
|
+
try {
|
|
3320
|
+
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return false
|
|
3321
|
+
} catch { /* no pane to consult — let the insert itself decide */ }
|
|
3322
|
+
}
|
|
3323
|
+
return (await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text)).ok
|
|
3324
|
+
})
|
|
3139
3325
|
}
|
|
3140
3326
|
|
|
3141
3327
|
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
@@ -7,6 +7,7 @@ import { runtimeRoot, readConfig, mainCheckout } from './layout.js'
|
|
|
7
7
|
import { resolveHarnessTargets } from './harness-select.js'
|
|
8
8
|
import { loadSkillConfig, loadAgentConfig } from './specs.js'
|
|
9
9
|
import { dematerialize } from './materialize.js'
|
|
10
|
+
import { gitBinary } from './git.js'
|
|
10
11
|
|
|
11
12
|
// the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
|
|
12
13
|
const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex', '.zcode'] as const
|
|
@@ -71,7 +72,7 @@ function pluginLedgerHosts(store: string): string[] {
|
|
|
71
72
|
// resolve the repo's shared git hooks dir (the common dir's hooks/), or null when <dir> isn't a git repo.
|
|
72
73
|
function hooksDir(proj: string): string | null {
|
|
73
74
|
try {
|
|
74
|
-
const common = execFileSync(
|
|
75
|
+
const common = execFileSync(gitBinary(process.env), ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
75
76
|
encoding: 'utf8',
|
|
76
77
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
77
78
|
}).trim()
|