spexcode 0.6.0 → 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 +68 -47
- 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/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 +11 -9
- package/spec-cli/src/help.ts +8 -13
- package/spec-cli/src/index.ts +8 -1
- 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/sessions.ts +166 -9
- 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/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/sessions.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGener
|
|
|
21
21
|
|
|
22
22
|
const pexec = promisify(execFile)
|
|
23
23
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
24
|
+
const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' }
|
|
24
25
|
const HARNESS = defaultHarness
|
|
25
26
|
const COLS = 120, ROWS = 32
|
|
26
27
|
const DEFAULT_MAX_ACTIVE = 8
|
|
@@ -72,7 +73,7 @@ const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', noth
|
|
|
72
73
|
|
|
73
74
|
export type Session = {
|
|
74
75
|
id: string; node: string | null; branch: string | null; path: string
|
|
75
|
-
label: string;
|
|
76
|
+
label: string; title: string // `label` remains the stable search handle; `title` is the one visible session name
|
|
76
77
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
77
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
|
|
78
79
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
@@ -111,18 +112,26 @@ function removeLaunchFile(id: string): void {
|
|
|
111
112
|
|
|
112
113
|
// One line, bounded — the launch prompt's shape when it enters a compact headline.
|
|
113
114
|
export const HEADLINE_PREVIEW_COLUMNS = 60
|
|
115
|
+
function isBareUrl(text: string): boolean {
|
|
116
|
+
return /^(?:https?|git|ssh):\/\/\S+$/i.test(text)
|
|
117
|
+
}
|
|
114
118
|
function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
|
|
115
|
-
const
|
|
119
|
+
const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
120
|
+
const first = lines.find((line) => !isBareUrl(line)) || lines[0] || ''
|
|
116
121
|
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
120
125
|
r.name || r.node || r.title || r.branch || r.id
|
|
121
|
-
export const
|
|
122
|
-
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
|
|
123
130
|
|
|
124
131
|
export const sessionLabel = (s: Session): string => s.label
|
|
125
|
-
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
|
|
126
135
|
|
|
127
136
|
// @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
|
|
128
137
|
// HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
|
|
@@ -449,7 +458,113 @@ function writeRecord(rec: SessRec): void {
|
|
|
449
458
|
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
450
459
|
|| previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
|
|
451
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
|
+
})
|
|
452
566
|
}
|
|
567
|
+
return cancelled
|
|
453
568
|
}
|
|
454
569
|
|
|
455
570
|
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
@@ -663,10 +778,26 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
663
778
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
664
779
|
}
|
|
665
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
|
+
|
|
666
797
|
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
667
798
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
668
799
|
return {
|
|
669
|
-
id, node: null, branch: null, path: '', label,
|
|
800
|
+
id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
|
|
670
801
|
parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
|
|
671
802
|
lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
|
|
672
803
|
note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
|
|
@@ -681,9 +812,9 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
681
812
|
const showActivity = lv === 'online'
|
|
682
813
|
const act = showActivity ? activity : null
|
|
683
814
|
const pp = prompt ? oneLinePreview(prompt) : null
|
|
684
|
-
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 }
|
|
685
816
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
686
|
-
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 }
|
|
687
818
|
}
|
|
688
819
|
|
|
689
820
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
@@ -1669,6 +1800,23 @@ type SessionCandidateReceiptRead =
|
|
|
1669
1800
|
const sessionCandidateReceiptDir = () => join(runtimeRoot(), '.session-create-candidates')
|
|
1670
1801
|
const sessionCandidateReceiptPath = (id: string) => join(sessionCandidateReceiptDir(), `${id}.json`)
|
|
1671
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
|
+
}
|
|
1672
1820
|
function readSessionCandidateReceipt(id: string): SessionCandidateReceiptRead {
|
|
1673
1821
|
const path = sessionCandidateReceiptPath(id)
|
|
1674
1822
|
if (!existsSync(path)) return { kind: 'absent' }
|
|
@@ -1857,7 +2005,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1857
2005
|
const resourceLock = sessionCandidateLockId(path, branch)
|
|
1858
2006
|
return await withRecordLock(resourceLock, async () => {
|
|
1859
2007
|
throwIfCreateAborted(signal, phase)
|
|
2008
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'candidate-state')
|
|
1860
2009
|
let before = await sessionCandidateState(root, path, branch, signal)
|
|
2010
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'candidate-state')
|
|
1861
2011
|
let storePresent = existsSync(sessionStoreDir(id))
|
|
1862
2012
|
const durable = readSessionCandidateReceipt(id)
|
|
1863
2013
|
if (durable.kind === 'invalid') throw new SessionCreateError('session_create_failed', phase, durable.error, 409)
|
|
@@ -1889,7 +2039,12 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1889
2039
|
let published = false
|
|
1890
2040
|
try {
|
|
1891
2041
|
gitMutationStarted = true
|
|
1892
|
-
|
|
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')
|
|
1893
2048
|
if (added.ok) Object.assign(owned, { path: true, worktree: true, branch: true })
|
|
1894
2049
|
if (!added.ok || !existsSync(path)) {
|
|
1895
2050
|
throw new SessionCreateError('session_create_failed', phase, `git worktree add failed: ${added.stderr.trim() || added.failure || 'worktree missing after success'}`, 500)
|
|
@@ -1897,7 +2052,9 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
1897
2052
|
candidateReceipt = { ...candidateReceipt, stage: 'git-created' }
|
|
1898
2053
|
writeSessionCandidateReceipt(id, candidateReceipt)
|
|
1899
2054
|
traceSessionCreate(id, requestDigest, phase, 'finish')
|
|
2055
|
+
traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state')
|
|
1900
2056
|
seedWorktreeHostState(root, path)
|
|
2057
|
+
traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state')
|
|
1901
2058
|
|
|
1902
2059
|
let rec: SessRec = {
|
|
1903
2060
|
session: id, governed: true, worktreePath: path, branch,
|
|
@@ -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()
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
2
3
|
# @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
|
|
3
4
|
# transitions that can move the materialize's inputs: .spec/.plugins content changes across branches, and a
|
|
4
5
|
# contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
|
|
@@ -10,6 +11,8 @@
|
|
|
10
11
|
# args: <prev-HEAD> <new-HEAD> <flag>; flag=1 is a branch checkout, flag=0 a file checkout (git checkout --
|
|
11
12
|
# <path> restores files and moves nothing the materialize depends on — skip those).
|
|
12
13
|
[ "${3:-0}" = "1" ] || exit 0
|
|
14
|
+
# Session creation owns the one post-seed materialize in its transaction.
|
|
15
|
+
[ "${SPEXCODE_DEFER_FOOTPRINT_REFRESH:-}" = "session-create" ] && exit 0
|
|
13
16
|
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
14
17
|
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
15
18
|
if command -v spex >/dev/null 2>&1; then
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
2
3
|
# @@@ issue nudge (post-merge) - the taste store ([[local-issues]]) fires AFTER the work lands, not while the
|
|
3
4
|
# agent is finishing it: the agent's own task comes first. Merge is dispatched to the session's OWN agent
|
|
4
5
|
# (see [[dispatch]]), so this hook runs in that live merge turn and its stdout reaches the agent as the merge
|
|
@@ -5,4 +5,4 @@ status: active
|
|
|
5
5
|
hue: 280
|
|
6
6
|
desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
|
|
7
7
|
---
|
|
8
|
-
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. This preset IS your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize) — the CLI's own `spex help session` is the reference for every verb's exact semantics. Drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, dispatch the merge of good ones back to their own session (`spex session merge <id>`; the doer syncs the base into its own worktree first, so what reaches `<root>` is a trivial merge) and confirm it landed, then close. `<root>` is the fleet's ONE landing door — it takes one landing at a time, so a worker whose merge finds it mid-merge waits rather than racing, and you never fix up another lane's half-merged index yourself. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:**
|
|
8
|
+
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. This preset IS your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize) — the CLI's own `spex help session` is the reference for every verb's exact semantics. Drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, dispatch the merge of good ones back to their own session (`spex session merge <id>`; the doer syncs the base into its own worktree first, so what reaches `<root>` is a trivial merge) and confirm it landed, then close. `<root>` is the fleet's ONE landing door — it takes one landing at a time, so a worker whose merge finds it mid-merge waits rather than racing, and you never fix up another lane's half-merged index yourself. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch stream`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:** `spex session new` registers its managed child watch automatically, and a managed watch's send delivery is a real wake-up, so park while those watches exist. For an existing governed worker run one-shot `spex session watch <child>`; use `watch list`/`watch cancel <child>` to manage the relation. With no governed parent address, background a `spex session wait <child>` instead. Only go `asking` when you genuinely need the human. This matters because the dashboard **folds each child under you and shows YOUR own status for the whole group** (session-nesting, no child-status aggregation), so a supervisor that stays parked-while-they-run is what makes that folded group status honest. Two footguns that bite a fresh supervisor. First: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. Second: `<id>` always names a WORKER YOU DISPATCHED, spelled out — never `.` and never your own id. `.` means the session running the command, so `close .` deletes your own worktree, branch and record mid-turn and takes your fleet's manager down with it; your own ending is a declaration (`done --propose close`), never a close you run on yourself. **DRAIN THE ISSUES** (issues / local-issues) as part of your loop: `spex issue ls` lists every open concern in one place — the taste concerns finished sessions recorded locally, AND the forge's issues, store-tagged. Cluster the same concern yourself (use judgment — duplicates are a recurrence SIGNAL, not noise; fold them into one) and weigh by recurrence AND novelty — **recurrence is salience, not importance, so never just fix the highest count**: a sharp single-voice concern can outrank a popular gripe. For the ones worth acting on, `spex session new "<task>"` a worker to land it (mention the concern's node as `[[<id>]]` if it has one), then `spex issue ls resolve <id> --as accepted|landed` (or `rejected`, with a reply saying why) so the store reflects the decision. Report progress as you go and when the goal is complete. Your goal follows:
|
|
@@ -14,7 +14,7 @@ The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected
|
|
|
14
14
|
|
|
15
15
|
The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop emits `{"decision":"block"}` and the dispatcher exits 2 so the harness actually interrupts the stop and shows the reason; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end.
|
|
16
16
|
|
|
17
|
-
The block text is where the declaration ritual is taught, so it is written to be read at two depths. The FULL teaching text prints once per session: it names the PATH-independent CLI once as a shared prefix, lists the five choices as a compact menu each with its application condition (park policed hardest — a false park is the most damaging mislabel)
|
|
17
|
+
The block text is where the declaration ritual is taught, so it is written to be read at two depths. The FULL teaching text prints once per session: it names the PATH-independent CLI once as a shared prefix, lists the five choices as a compact menu each with its application condition (park policed hardest — a false park is the most damaging mislabel). A real wake source is either a background job or a managed session-follow subscription whose normal send delivery will re-enter the parent; a caller without that address must still arm background `session wait`. It ends with the ordering discipline: declare LAST, then stop — a declaration followed by more tool calls honestly re-flips the record to active ([[mark-active]], by design), so making the declaration the turn's final call is what eliminates the park→block→re-park loop at its source. Every later undeclared stop in the same session gets a ONE-LINE version instead (a heavy session hits the gate 15-20 times a night; re-printing the full menu is token noise). The once-sentinel is a plain file beside the session record in the global store — the same per-session-sentinel mechanism as the CLI's note-truncation notice, never a second scheme. The terse line stays self-explanatory: it carries the command menu, the declare-LAST reminder, and the `spex help session` recovery entry, so an agent that never saw the full text (a compacted context) recovers every choice's condition from the entry rather than from memory — the whole full-to-terse information gap is closable from the line itself.
|
|
18
18
|
|
|
19
19
|
The clean-done eval nudge is advisory only and must never corrupt the Stop hook protocol. Claude-family hooks can receive it as `hookSpecificOutput.additionalContext`; Codex Stop allows are silent because Codex treats unsupported non-block stdout as invalid hook JSON. Blocking decisions stay shared across harnesses through `{"decision":"block"}` plus the dispatcher’s Codex stderr bridge.
|
|
20
20
|
|
|
@@ -129,7 +129,7 @@ fi
|
|
|
129
129
|
# of the full-to-terse information gap is recoverable from the entry, none of it from memory.
|
|
130
130
|
taught="$sdir/stop-gate-taught"
|
|
131
131
|
if [ -f "$taught" ]; then
|
|
132
|
-
printf '{"decision":"block","reason":"undeclared stop — declare the ONE true state as your LAST call: `%s session <done --propose merge (review; ONLY clickable merge)|nothing (done; no merge)|close (close-pending) / park (parked; real background wake-up) / ask (asking; human reply)>`. Conditions: `%s help session`."}\n' "$S" "$S"
|
|
132
|
+
printf '{"decision":"block","reason":"undeclared stop — declare the ONE true state as your LAST call: `%s session <done --propose merge (review; ONLY clickable merge)|nothing (done; no merge)|close (close-pending) / park (parked; managed watch delivery or real background wake-up) / ask (asking; human reply)>`. Conditions: `%s help session`."}\n' "$S" "$S"
|
|
133
133
|
exit 0
|
|
134
134
|
fi
|
|
135
135
|
touch "$taught" 2>/dev/null || true
|
|
@@ -143,5 +143,5 @@ touch "$taught" 2>/dev/null || true
|
|
|
143
143
|
# tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
|
|
144
144
|
# this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
|
|
145
145
|
# kills the park->block->re-park loop at its source lives here.
|
|
146
|
-
printf '{"decision":"block","reason":"Your session state is a CLAIM the graph, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for human review. It declares REVIEW and is the ONLY proposal that offers a clickable merge.\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look. It declares DONE, never a merge.\\n • done --propose close — you PROPOSE discarding this worktree; the human performs the close. It declares CLOSE-PENDING, not merge. This is how a session ends ITSELF — never run `session close` on your own id, which would delete your worktree mid-turn.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction. It declares ASKING and resumes only when they reply.\\n • park --note <what-you-await> — ONLY when a real
|
|
146
|
+
printf '{"decision":"block","reason":"Your session state is a CLAIM the graph, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for human review. It declares REVIEW and is the ONLY proposal that offers a clickable merge.\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look. It declares DONE, never a merge.\\n • done --propose close — you PROPOSE discarding this worktree; the human performs the close. It declares CLOSE-PENDING, not merge. This is how a session ends ITSELF — never run `session close` on your own id, which would delete your worktree mid-turn.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction. It declares ASKING and resumes only when they reply.\\n • park --note <what-you-await> — ONLY when a real wake-up will resume you: a managed spex session watch subscription whose send delivery reaches you, or a spex session wait you backgrounded/a running build/job. It declares PARKED and self-resumes. With neither, you are waiting on the human: use ask, never park as a default.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, establish managed watches or arm background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
|
|
147
147
|
exit 0
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Dashboard-
|
|
2
|
-
import{u as N,r as a,j as n,I as D,a as B,P as k,i as z,_ as T,D as M,b as G}from"./index-paP-z_Vd.js";import{a as J,l as q,s as F,p as V}from"./data-Bwd3kAVL.js";const h=async e=>(e.headers.get("content-type")||"").includes("json")?e.json().catch(()=>null):null,H=5e3,O=(e,r,t)=>({title:typeof(e==null?void 0:e.title)=="string"&&e.title?e.title:r,icon:typeof(e==null?void 0:e.icon)=="string"&&e.icon?e.icon:t});function x(e){if(!e||typeof e!="object")return null;const r=e.id??e.projectId;return r?{id:String(r),identity:O(e.identity||{title:e.name,icon:e.icon},e.name||String(r),"spexcode"),root:typeof e.root=="string"?e.root:"",online:typeof e.online=="boolean"?e.online:null,url:e.url||"",port:e.port??null,gated:!!(e.gated??e.locked??e.hasPassword),configRevision:typeof e.configRevision=="string"?e.configRevision:""}:null}const W=e=>{const r=Array.isArray(e)?e:Array.isArray(e==null?void 0:e.projects)?e.projects:null;return r?r.map(x).filter(Boolean):null},Y=10;function ce(e,r,t=Y){const s=Array.isArray(e)?e:[],o=Math.max(1,Math.ceil(s.length/t)),l=Math.min(Math.max(1,Number.isInteger(r)?r:1),o);return{items:s.slice((l-1)*t,l*t),page:l,pageCount:o}}function Z(e,r,t){var s;if(!e)return t;if(!r)return null;if(r.state==="ok"){const o=(s=r.projects)==null?void 0:s.find(l=>l.id===e);return(o==null?void 0:o.identity)||{title:e,icon:"spexcode"}}return{title:(t==null?void 0:t.title)||e,icon:(t==null?void 0:t.icon)||"spexcode"}}const K=e=>(e==null?void 0:e.state)==="ok"?e.gateway.identity:{title:"Projects",icon:"gateway"},Q=e=>(e==null?void 0:e.title)||"SpexCode",R=(e,r)=>(r==null?void 0:r.state)==="absent"&&e&&e.state!=="absent"?e:r;async function X(){let e;try{e=await fetch("/projects",{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{state:"absent"}}if(e.status===401)return{state:"denied",reason:"admin-login"};if(e.status===403)return{state:"denied",reason:"locked"};if(!e.ok)return{state:"absent"};const r=await h(e),t=W(r);if(!t)return{state:"absent"};const s=r!=null&&r.gateway&&typeof r.gateway=="object"?{identity:O(r.gateway,"Projects","gateway"),revision:typeof r.gateway.revision=="string"?r.gateway.revision:""}:{identity:{title:"Projects",icon:"gateway"},revision:""};return{state:"ok",adminGated:!!(r!=null&&r.adminGated),gateway:s,projects:t}}async function ie(e,{timeoutMs:r=2500}={}){try{const t=await fetch(`/p/${encodeURIComponent(e)}/health`,{cache:"no-store",signal:AbortSignal.timeout(r)});return!t.ok||t.redirected?"unreachable":(await t.text()).trim()==="ok"?"running":"unreachable"}catch{return"unreachable"}}async function C(e,r,t){let s;try{s=await fetch(e,{method:r,headers:{"Content-Type":"application/json",Accept:"application/json"},...r==="PUT"?{body:JSON.stringify({password:t})}:{}})}catch{return{ok:!1,error:"network"}}const o=await h(s)||{};return{ok:s.ok&&o.ok!==!1,status:s.status,...o.error?{error:o.error}:{}}}const le=(e,r)=>C(`/projects/${encodeURIComponent(e)}/password`,"PUT",r),ue=e=>C(`/projects/${encodeURIComponent(e)}/password`,"DELETE"),pe=e=>C("/projects/admin-password","PUT",e),de=()=>C("/projects/admin-password","DELETE");async function ee(e,r){const t=e==="admin"?"/login":`/p/${encodeURIComponent(e.projectId)}/login`;let s;try{s=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:r})})}catch{return{ok:!1,error:"network"}}return s.status===401?{ok:!1,error:"wrong-password"}:s.status===403?{ok:!1,error:"locked"}:s.ok||s.redirected?{ok:!0}:{ok:!1,error:`http-${s.status}`}}async function fe(e=""){let r;try{const s=e?`?path=${encodeURIComponent(e)}`:"";r=await fetch(`/projects/browse${s}`,{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.ok?typeof(t==null?void 0:t.path)!="string"||!Array.isArray(t==null?void 0:t.entries)?{ok:!1,error:"unexpected answer"}:{ok:!0,path:t.path,parent:typeof t.parent=="string"?t.parent:null,home:typeof t.home=="string"?t.home:t.path,gitRoot:typeof t.gitRoot=="string"?t.gitRoot:null,initialized:!!t.initialized,cataloged:!!t.cataloged,entries:t.entries.filter(s=>s&&typeof s.name=="string"&&typeof s.path=="string").map(s=>({name:s.name,path:s.path,git:!!s.git,initialized:!!s.initialized}))}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}async function he(e,r={}){let t;try{t=await fetch("/projects",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({root:e,...r})})}catch{return{ok:!1,error:"network"}}const s=await h(t);if(!t.ok)return{ok:!1,status:t.status,error:(s==null?void 0:s.error)||`http-${t.status}`,...s!=null&&s.init&&typeof s.init=="object"?{code:s.init.code??null,output:String(s.init.output??"")}:{}};const o=x(s);return o?{ok:!0,project:o,setup:s.setup??null}:{ok:!1,error:"unexpected answer"}}async function je(e){let r;try{r=await fetch(`/projects/${encodeURIComponent(e)}/config`,{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.ok?typeof(t==null?void 0:t.content)!="string"||typeof(t==null?void 0:t.revision)!="string"?{ok:!1,error:"unexpected answer"}:{ok:!0,content:t.content,revision:t.revision}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}async function me(e,r,t){let s;try{s=await fetch(`/projects/${encodeURIComponent(e)}/config`,{method:"PUT",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({content:r,revision:t})})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?typeof(o==null?void 0:o.content)!="string"||typeof(o==null?void 0:o.revision)!="string"?{ok:!1,error:"unexpected answer"}:{ok:!0,content:o.content,revision:o.revision}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}async function $(e,r,t){let s;try{s=await fetch(e,{method:"PUT",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({icon:r,revision:t})})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?{ok:!0,...o}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}const ke=(e,r)=>$("/projects/icon",e,r),ge=(e,r,t)=>$(`/projects/${encodeURIComponent(e)}/icon`,r,t);async function U(e,r,t={}){let s;try{s=await fetch(`/projects/${encodeURIComponent(e)}/${r}`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(t)})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?!o||typeof o!="object"?{ok:!1,error:"unexpected answer"}:{ok:o.ok===!0,code:o.code??null,output:String(o.output??"")}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}const we=(e,r)=>U(e,"init",{harness:r}),ye=e=>U(e,"doctor");async function Pe(e){let r;try{r=await fetch(`/projects/${encodeURIComponent(e)}/serve`,{method:"POST",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.status===409?{ok:!0,already:!0,project:x(t==null?void 0:t.project)}:r.ok?{ok:!0,project:x(t==null?void 0:t.project)}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}function I({scope:e,projectLabel:r,locked:t,onUnlocked:s}){const o=N(),[l,S]=a.useState(""),[j,A]=a.useState(!1),[g,P]=a.useState(null),m=e==="admin",p=async b=>{if(b.preventDefault(),!l||j)return;A(!0),P(null);const w=await ee(m?"admin":{projectId:e.projectId},l);A(!1),w.ok?(S(""),s()):P(w.error==="wrong-password"?o("credential.wrong"):o("credential.failed"))};return n.jsx("div",{className:"cred-wrap",children:n.jsxs("form",{className:"cred-card",onSubmit:p,children:[n.jsx("div",{className:"cred-brand",children:"$ spexcode"}),n.jsxs("div",{className:"cred-title",children:[n.jsx(D,{name:"lock",size:14,className:"cred-lock"}),t?o("credential.lockedTitle"):m?o("credential.adminTitle"):o("credential.projectTitle",{name:r||e&&e.projectId||""})]}),t?n.jsx("p",{className:"cred-sub",children:o("credential.lockedBody")}):n.jsxs(n.Fragment,{children:[n.jsx("p",{className:"cred-sub",children:o(m?"credential.adminBody":"credential.projectBody")}),g&&n.jsx("div",{className:"cred-err",children:g}),n.jsx("input",{className:"cred-input",type:"password",autoFocus:!0,required:!0,placeholder:"••••••••••","aria-label":o("credential.passwordLabel"),value:l,onChange:b=>S(b.target.value)}),n.jsx("button",{className:"cred-submit",type:"submit",disabled:j||!l,children:o(j?"credential.checking":"credential.unlock")})]})]})})}const te=a.lazy(()=>T(()=>import("./Dashboard-CvAjfRC2.js").then(e=>e.D),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9]))),re=a.lazy(()=>T(()=>import("./MobileApp-D9L1Va8Z.js"),__vite__mapDeps([10,1,2,3,4,5,6,11,12]))),se=a.lazy(()=>T(()=>import("./ProjectsPage-CjybFBmR.js"),__vite__mapDeps([13,1,2,7,5,6])));window.addEventListener("vite:preloadError",e=>{const r=String(e.payload);sessionStorage.getItem("spexcode.chunkReload")!==r&&(sessionStorage.setItem("spexcode.chunkReload",r),e.preventDefault(),location.reload())});function oe(){const e=N(),r=B(),[t,s]=a.useState(null),[o,l]=a.useState(!1),S=a.useRef(new Map),j=a.useCallback((u,c)=>{s(J(u,S.current,c))},[]),[A,g]=a.useState(!1),[P,m]=a.useState(null),[p,b]=a.useState(null);a.useEffect(()=>{let u=!0;const c=()=>X().then(y=>{u&&b(L=>R(L,y))}).catch(()=>{u&&b(y=>R(y,{state:"absent"}))});c();const d=setInterval(c,H);return()=>{u=!1,clearInterval(d)}},[]);const w=a.useRef(0),f=a.useCallback(()=>{const u=++w.current;return q().then(c=>{if(!(u!==w.current||!c)){if(c.authRequired){m(c.authRequired);return}m(null),g(!1),j(c.board,!0),c.seal()}}).catch(()=>{u===w.current&&g(!0)})},[j]),v=!k&&!t&&!!p&&p.state!=="absent",E=!k&&!t&&p===null;a.useEffect(()=>{if(v||E)return;f();const u=F({onBoard:(d,y)=>{w.current++,g(!1),j(d,!!(y!=null&&y.authoritative))},onLegacyChange:()=>{f()},onStatus:l}),c=setInterval(()=>{f()},15e3);return()=>{u(),clearInterval(c)}},[f,j,v,E]);const _=t?V(t):null,i=k?Z(k,p,_):v?K(p):_;return a.useEffect(()=>{i&&(document.title=Q(i))},[i==null?void 0:i.title]),a.useEffect(()=>{if(!i)return;const u=v?M:G,c=z(i.icon,u);let d=document.querySelector("link[rel~='icon']");d||(d=document.createElement("link"),d.rel="icon",document.head.appendChild(d)),d.getAttribute("href")!==c&&d.setAttribute("href",c)},[i==null?void 0:i.icon,v]),P&&k?n.jsx(I,{scope:{projectId:k},projectLabel:(i==null?void 0:i.title)||k,onUnlocked:()=>{m(null),f()}}):t?n.jsx(a.Suspense,{fallback:n.jsx("div",{className:"loading",children:e("hud.loading")}),children:r?n.jsx(re,{specs:t.nodes,sessions:t.sessions,issuesStamp:t.issuesStamp,reloadBoard:f}):n.jsx(te,{specs:t.nodes,sessions:t.sessions,issuesStamp:t.issuesStamp,reload:f,identity:i,catalog:p,boardLive:o})}):v?n.jsx(a.Suspense,{fallback:n.jsx("div",{className:"loading",children:e("hud.loading")}),children:n.jsx(se,{})}):P?n.jsx(I,{scope:"admin",locked:P==="locked",onUnlocked:()=>{m(null),f()}}):A&&(k||p&&p.state==="absent")?n.jsxs("div",{className:"loading load-error",children:[n.jsx("span",{children:e("hud.loadError")}),n.jsx("button",{className:"load-retry",onClick:()=>{g(!1),f()},children:e("hud.retry")})]}):n.jsx("div",{className:"loading",children:e("hud.loading")})}const be=Object.freeze(Object.defineProperty({__proto__:null,default:oe},Symbol.toStringTag,{value:"Module"}));export{be as A,H as C,ce as a,I as b,de as c,fe as d,je as e,ye as f,ke as g,he as h,we as i,me as j,ge as k,X as l,Pe as m,ue as n,le as o,ie as p,pe as s};
|
|
1
|
+
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Dashboard-Ba_jhxp1.js","assets/index-DrVao0Ep.js","assets/index-CsI8DElI.css","assets/SessionWindow-CixDi4PI.js","assets/Thread-C6Go8HRh.js","assets/PageScroll-hT7UTLvD.js","assets/data-B-RQmit6.js","assets/Modal-CQgYymmr.js","assets/bindings-BC9vqpYU.js","assets/Dashboard-C5ap-Sga.css","assets/MobileApp-CHgEHORJ.js","assets/launch-BBH02b1v.js","assets/launch-Cp579UoJ.css","assets/ProjectsPage-CtXxakF9.js"])))=>i.map(i=>d[i]);
|
|
2
|
+
import{u as N,r as a,j as n,I as D,a as B,P as k,i as z,_ as T,D as M,b as G}from"./index-DrVao0Ep.js";import{a as J,l as q,s as F,p as V}from"./data-B-RQmit6.js";const h=async e=>(e.headers.get("content-type")||"").includes("json")?e.json().catch(()=>null):null,H=5e3,O=(e,r,t)=>({title:typeof(e==null?void 0:e.title)=="string"&&e.title?e.title:r,icon:typeof(e==null?void 0:e.icon)=="string"&&e.icon?e.icon:t});function x(e){if(!e||typeof e!="object")return null;const r=e.id??e.projectId;return r?{id:String(r),identity:O(e.identity||{title:e.name,icon:e.icon},e.name||String(r),"spexcode"),root:typeof e.root=="string"?e.root:"",online:typeof e.online=="boolean"?e.online:null,url:e.url||"",port:e.port??null,gated:!!(e.gated??e.locked??e.hasPassword),configRevision:typeof e.configRevision=="string"?e.configRevision:""}:null}const W=e=>{const r=Array.isArray(e)?e:Array.isArray(e==null?void 0:e.projects)?e.projects:null;return r?r.map(x).filter(Boolean):null},Y=10;function ce(e,r,t=Y){const s=Array.isArray(e)?e:[],o=Math.max(1,Math.ceil(s.length/t)),l=Math.min(Math.max(1,Number.isInteger(r)?r:1),o);return{items:s.slice((l-1)*t,l*t),page:l,pageCount:o}}function Z(e,r,t){var s;if(!e)return t;if(!r)return null;if(r.state==="ok"){const o=(s=r.projects)==null?void 0:s.find(l=>l.id===e);return(o==null?void 0:o.identity)||{title:e,icon:"spexcode"}}return{title:(t==null?void 0:t.title)||e,icon:(t==null?void 0:t.icon)||"spexcode"}}const K=e=>(e==null?void 0:e.state)==="ok"?e.gateway.identity:{title:"Projects",icon:"gateway"},Q=e=>(e==null?void 0:e.title)||"SpexCode",R=(e,r)=>(r==null?void 0:r.state)==="absent"&&e&&e.state!=="absent"?e:r;async function X(){let e;try{e=await fetch("/projects",{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{state:"absent"}}if(e.status===401)return{state:"denied",reason:"admin-login"};if(e.status===403)return{state:"denied",reason:"locked"};if(!e.ok)return{state:"absent"};const r=await h(e),t=W(r);if(!t)return{state:"absent"};const s=r!=null&&r.gateway&&typeof r.gateway=="object"?{identity:O(r.gateway,"Projects","gateway"),revision:typeof r.gateway.revision=="string"?r.gateway.revision:""}:{identity:{title:"Projects",icon:"gateway"},revision:""};return{state:"ok",adminGated:!!(r!=null&&r.adminGated),gateway:s,projects:t}}async function ie(e,{timeoutMs:r=2500}={}){try{const t=await fetch(`/p/${encodeURIComponent(e)}/health`,{cache:"no-store",signal:AbortSignal.timeout(r)});return!t.ok||t.redirected?"unreachable":(await t.text()).trim()==="ok"?"running":"unreachable"}catch{return"unreachable"}}async function C(e,r,t){let s;try{s=await fetch(e,{method:r,headers:{"Content-Type":"application/json",Accept:"application/json"},...r==="PUT"?{body:JSON.stringify({password:t})}:{}})}catch{return{ok:!1,error:"network"}}const o=await h(s)||{};return{ok:s.ok&&o.ok!==!1,status:s.status,...o.error?{error:o.error}:{}}}const le=(e,r)=>C(`/projects/${encodeURIComponent(e)}/password`,"PUT",r),ue=e=>C(`/projects/${encodeURIComponent(e)}/password`,"DELETE"),pe=e=>C("/projects/admin-password","PUT",e),de=()=>C("/projects/admin-password","DELETE");async function ee(e,r){const t=e==="admin"?"/login":`/p/${encodeURIComponent(e.projectId)}/login`;let s;try{s=await fetch(t,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({password:r})})}catch{return{ok:!1,error:"network"}}return s.status===401?{ok:!1,error:"wrong-password"}:s.status===403?{ok:!1,error:"locked"}:s.ok||s.redirected?{ok:!0}:{ok:!1,error:`http-${s.status}`}}async function fe(e=""){let r;try{const s=e?`?path=${encodeURIComponent(e)}`:"";r=await fetch(`/projects/browse${s}`,{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.ok?typeof(t==null?void 0:t.path)!="string"||!Array.isArray(t==null?void 0:t.entries)?{ok:!1,error:"unexpected answer"}:{ok:!0,path:t.path,parent:typeof t.parent=="string"?t.parent:null,home:typeof t.home=="string"?t.home:t.path,gitRoot:typeof t.gitRoot=="string"?t.gitRoot:null,initialized:!!t.initialized,cataloged:!!t.cataloged,entries:t.entries.filter(s=>s&&typeof s.name=="string"&&typeof s.path=="string").map(s=>({name:s.name,path:s.path,git:!!s.git,initialized:!!s.initialized}))}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}async function he(e,r={}){let t;try{t=await fetch("/projects",{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({root:e,...r})})}catch{return{ok:!1,error:"network"}}const s=await h(t);if(!t.ok)return{ok:!1,status:t.status,error:(s==null?void 0:s.error)||`http-${t.status}`,...s!=null&&s.init&&typeof s.init=="object"?{code:s.init.code??null,output:String(s.init.output??"")}:{}};const o=x(s);return o?{ok:!0,project:o,setup:s.setup??null}:{ok:!1,error:"unexpected answer"}}async function je(e){let r;try{r=await fetch(`/projects/${encodeURIComponent(e)}/config`,{cache:"no-store",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.ok?typeof(t==null?void 0:t.content)!="string"||typeof(t==null?void 0:t.revision)!="string"?{ok:!1,error:"unexpected answer"}:{ok:!0,content:t.content,revision:t.revision}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}async function me(e,r,t){let s;try{s=await fetch(`/projects/${encodeURIComponent(e)}/config`,{method:"PUT",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({content:r,revision:t})})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?typeof(o==null?void 0:o.content)!="string"||typeof(o==null?void 0:o.revision)!="string"?{ok:!1,error:"unexpected answer"}:{ok:!0,content:o.content,revision:o.revision}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}async function $(e,r,t){let s;try{s=await fetch(e,{method:"PUT",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify({icon:r,revision:t})})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?{ok:!0,...o}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}const ke=(e,r)=>$("/projects/icon",e,r),ge=(e,r,t)=>$(`/projects/${encodeURIComponent(e)}/icon`,r,t);async function U(e,r,t={}){let s;try{s=await fetch(`/projects/${encodeURIComponent(e)}/${r}`,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"},body:JSON.stringify(t)})}catch{return{ok:!1,error:"network"}}const o=await h(s);return s.ok?!o||typeof o!="object"?{ok:!1,error:"unexpected answer"}:{ok:o.ok===!0,code:o.code??null,output:String(o.output??"")}:{ok:!1,status:s.status,error:(o==null?void 0:o.error)||`http-${s.status}`}}const we=(e,r)=>U(e,"init",{harness:r}),ye=e=>U(e,"doctor");async function Pe(e){let r;try{r=await fetch(`/projects/${encodeURIComponent(e)}/serve`,{method:"POST",headers:{Accept:"application/json"}})}catch{return{ok:!1,error:"network"}}const t=await h(r);return r.status===409?{ok:!0,already:!0,project:x(t==null?void 0:t.project)}:r.ok?{ok:!0,project:x(t==null?void 0:t.project)}:{ok:!1,status:r.status,error:(t==null?void 0:t.error)||`http-${r.status}`}}function I({scope:e,projectLabel:r,locked:t,onUnlocked:s}){const o=N(),[l,S]=a.useState(""),[j,A]=a.useState(!1),[g,P]=a.useState(null),m=e==="admin",p=async b=>{if(b.preventDefault(),!l||j)return;A(!0),P(null);const w=await ee(m?"admin":{projectId:e.projectId},l);A(!1),w.ok?(S(""),s()):P(w.error==="wrong-password"?o("credential.wrong"):o("credential.failed"))};return n.jsx("div",{className:"cred-wrap",children:n.jsxs("form",{className:"cred-card",onSubmit:p,children:[n.jsx("div",{className:"cred-brand",children:"$ spexcode"}),n.jsxs("div",{className:"cred-title",children:[n.jsx(D,{name:"lock",size:14,className:"cred-lock"}),t?o("credential.lockedTitle"):m?o("credential.adminTitle"):o("credential.projectTitle",{name:r||e&&e.projectId||""})]}),t?n.jsx("p",{className:"cred-sub",children:o("credential.lockedBody")}):n.jsxs(n.Fragment,{children:[n.jsx("p",{className:"cred-sub",children:o(m?"credential.adminBody":"credential.projectBody")}),g&&n.jsx("div",{className:"cred-err",children:g}),n.jsx("input",{className:"cred-input",type:"password",autoFocus:!0,required:!0,placeholder:"••••••••••","aria-label":o("credential.passwordLabel"),value:l,onChange:b=>S(b.target.value)}),n.jsx("button",{className:"cred-submit",type:"submit",disabled:j||!l,children:o(j?"credential.checking":"credential.unlock")})]})]})})}const te=a.lazy(()=>T(()=>import("./Dashboard-Ba_jhxp1.js").then(e=>e.D),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9]))),re=a.lazy(()=>T(()=>import("./MobileApp-CHgEHORJ.js"),__vite__mapDeps([10,1,2,3,4,5,6,11,12]))),se=a.lazy(()=>T(()=>import("./ProjectsPage-CtXxakF9.js"),__vite__mapDeps([13,1,2,7,5,6])));window.addEventListener("vite:preloadError",e=>{const r=String(e.payload);sessionStorage.getItem("spexcode.chunkReload")!==r&&(sessionStorage.setItem("spexcode.chunkReload",r),e.preventDefault(),location.reload())});function oe(){const e=N(),r=B(),[t,s]=a.useState(null),[o,l]=a.useState(!1),S=a.useRef(new Map),j=a.useCallback((u,c)=>{s(J(u,S.current,c))},[]),[A,g]=a.useState(!1),[P,m]=a.useState(null),[p,b]=a.useState(null);a.useEffect(()=>{let u=!0;const c=()=>X().then(y=>{u&&b(L=>R(L,y))}).catch(()=>{u&&b(y=>R(y,{state:"absent"}))});c();const d=setInterval(c,H);return()=>{u=!1,clearInterval(d)}},[]);const w=a.useRef(0),f=a.useCallback(()=>{const u=++w.current;return q().then(c=>{if(!(u!==w.current||!c)){if(c.authRequired){m(c.authRequired);return}m(null),g(!1),j(c.board,!0),c.seal()}}).catch(()=>{u===w.current&&g(!0)})},[j]),v=!k&&!t&&!!p&&p.state!=="absent",E=!k&&!t&&p===null;a.useEffect(()=>{if(v||E)return;f();const u=F({onBoard:(d,y)=>{w.current++,g(!1),j(d,!!(y!=null&&y.authoritative))},onLegacyChange:()=>{f()},onStatus:l}),c=setInterval(()=>{f()},15e3);return()=>{u(),clearInterval(c)}},[f,j,v,E]);const _=t?V(t):null,i=k?Z(k,p,_):v?K(p):_;return a.useEffect(()=>{i&&(document.title=Q(i))},[i==null?void 0:i.title]),a.useEffect(()=>{if(!i)return;const u=v?M:G,c=z(i.icon,u);let d=document.querySelector("link[rel~='icon']");d||(d=document.createElement("link"),d.rel="icon",document.head.appendChild(d)),d.getAttribute("href")!==c&&d.setAttribute("href",c)},[i==null?void 0:i.icon,v]),P&&k?n.jsx(I,{scope:{projectId:k},projectLabel:(i==null?void 0:i.title)||k,onUnlocked:()=>{m(null),f()}}):t?n.jsx(a.Suspense,{fallback:n.jsx("div",{className:"loading",children:e("hud.loading")}),children:r?n.jsx(re,{specs:t.nodes,sessions:t.sessions,issuesStamp:t.issuesStamp,reloadBoard:f}):n.jsx(te,{specs:t.nodes,sessions:t.sessions,issuesStamp:t.issuesStamp,reload:f,identity:i,catalog:p,boardLive:o})}):v?n.jsx(a.Suspense,{fallback:n.jsx("div",{className:"loading",children:e("hud.loading")}),children:n.jsx(se,{})}):P?n.jsx(I,{scope:"admin",locked:P==="locked",onUnlocked:()=>{m(null),f()}}):A&&(k||p&&p.state==="absent")?n.jsxs("div",{className:"loading load-error",children:[n.jsx("span",{children:e("hud.loadError")}),n.jsx("button",{className:"load-retry",onClick:()=>{g(!1),f()},children:e("hud.retry")})]}):n.jsx("div",{className:"loading",children:e("hud.loading")})}const be=Object.freeze(Object.defineProperty({__proto__:null,default:oe},Symbol.toStringTag,{value:"Module"}));export{be as A,H as C,ce as a,I as b,de as c,fe as d,je as e,ye as f,ke as g,he as h,we as i,me as j,ge as k,X as l,Pe as m,ue as n,le as o,ie as p,pe as s};
|