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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/spec-cli/src/cli.ts +68 -47
  3. package/spec-cli/src/client.ts +1 -1
  4. package/spec-cli/src/codex-runtime-generations.ts +97 -7
  5. package/spec-cli/src/commit-surgery.ts +2 -1
  6. package/spec-cli/src/contract-filter.ts +58 -42
  7. package/spec-cli/src/doctor.ts +2 -1
  8. package/spec-cli/src/file-write.ts +22 -0
  9. package/spec-cli/src/git.ts +30 -8
  10. package/spec-cli/src/graphStream.ts +79 -11
  11. package/spec-cli/src/harness.ts +11 -9
  12. package/spec-cli/src/help.ts +8 -13
  13. package/spec-cli/src/index.ts +8 -1
  14. package/spec-cli/src/init.ts +8 -17
  15. package/spec-cli/src/layout.ts +35 -7
  16. package/spec-cli/src/materialize.ts +150 -107
  17. package/spec-cli/src/plugin-harness.ts +19 -8
  18. package/spec-cli/src/process-identity.ts +13 -0
  19. package/spec-cli/src/reviews.ts +55 -11
  20. package/spec-cli/src/sessions.ts +166 -9
  21. package/spec-cli/src/uninstall.ts +2 -1
  22. package/spec-cli/templates/hooks/post-checkout +3 -0
  23. package/spec-cli/templates/hooks/post-merge +1 -0
  24. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  25. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  26. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  27. package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
  28. package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
  29. package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
  30. package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
  32. package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
  33. package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
  34. package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
  35. package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
  36. package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
  37. package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
  38. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
  39. package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
  40. package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
  41. package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
  42. package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
  43. package/spec-dashboard/dist/index.html +2 -2
  44. package/spec-dashboard/src/reviewFilters.js +5 -0
  45. package/spec-dashboard/src/session.js +5 -1
  46. package/spec-eval/src/cli.ts +7 -8
  47. package/spec-eval/src/evaltab.ts +25 -5
  48. package/spec-eval/src/freshness.ts +52 -8
  49. package/spec-eval/src/scenariofresh.ts +58 -8
  50. package/spec-eval/src/scenarios.ts +60 -12
  51. package/spec-eval/src/sessioneval.ts +122 -30
  52. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
  53. package/spec-eval/src/matrix.ts +0 -693
@@ -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; headline: string // the DERIVED display strings ([[session-label]]) the only names surfaces read
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 first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
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 deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
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 sessionHeadline = (s: Session): string => s.headline
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, headline: label, raw: { name: null, title: null },
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), headline: deriveHeadline(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 }
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
- const added = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'worktree', 'add', '-b', branch, path, mainBranch()]))
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('git', ['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
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:** after dispatching, background a `spex session wait <child>` per worker (per the manager loop) so that when you stop with children still working you land in `parked` self-resuming on the next child transition NOT `asking`; 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:
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), and 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.
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 BACKGROUND TASK will wake you (a spex session wait you backgrounded, a running build/job). It declares PARKED and self-resumes. If nothing is actually running, 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, arm your 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"
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-CvAjfRC2.js","assets/index-paP-z_Vd.js","assets/index-DAbQBBK_.css","assets/SessionWindow-iOk0yHoU.js","assets/Thread-D_kcDnfd.js","assets/PageScroll-qW6uOJL8.js","assets/data-Bwd3kAVL.js","assets/Modal-Drscez-d.js","assets/bindings-BC9vqpYU.js","assets/Dashboard-C5ap-Sga.css","assets/MobileApp-D9L1Va8Z.js","assets/launch-B-bYdWmh.js","assets/launch-Cp579UoJ.css","assets/ProjectsPage-CjybFBmR.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-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};