@spexcode/spec-cli 0.6.7 → 0.7.0-next.0
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/bin/spex.mjs +175 -21
- package/dist/cli.js +121 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/file-write.js +14 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +29 -18
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +24 -6
- package/dist/harness.js +510 -72
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +98 -1
- package/dist/index.js +294 -36
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.d.ts +2 -1
- package/dist/machine-peer.js +46 -15
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/opencode.js +10 -2
- package/dist/pi-harness.js +18 -5
- package/dist/pi-headless.d.ts +2 -0
- package/dist/pi-headless.js +27 -0
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-transcript.d.ts +57 -0
- package/dist/session-transcript.js +137 -0
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +112 -15
- package/dist/sessions.js +1553 -764
- package/dist/shim-runtime.js +14 -0
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +8 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/dist/execution-trace.d.ts +0 -26
- package/dist/execution-trace.js +0 -475
- package/dist/session-execution.d.ts +0 -10
- package/dist/session-execution.js +0 -70
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/bin/spex.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// @@@ spex launcher ([[release-launcher]]) - package installs execute the package's compiled CLI directly;
|
|
3
3
|
// tsx remains a development tool and never enters an adopter's runtime closure.
|
|
4
4
|
import { spawn, spawnSync } from 'node:child_process'
|
|
5
|
-
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
6
7
|
import { fileURLToPath } from 'node:url'
|
|
7
8
|
import { dirname, join } from 'node:path'
|
|
8
9
|
|
|
@@ -16,7 +17,19 @@ const workspace = join(pkg, '..')
|
|
|
16
17
|
// carry unresolved conflict markers, so hooks keep the retryable exit-75 contract during a merge.
|
|
17
18
|
const sourceRoot = join(pkg, 'src')
|
|
18
19
|
if (existsSync(sourceRoot)) {
|
|
19
|
-
const
|
|
20
|
+
const buildTargets = [
|
|
21
|
+
['packages/session-protocol', 'dist/index.js'],
|
|
22
|
+
['packages/session-topology', 'dist/index.js'],
|
|
23
|
+
['packages/session-runtime', 'dist/index.js'],
|
|
24
|
+
['packages/session-events', 'dist/index.js'],
|
|
25
|
+
['packages/session-application', 'dist/index.js'],
|
|
26
|
+
['packages/session-selflaunch', 'dist/index.js'],
|
|
27
|
+
['packages/spec-core', 'dist/index.js'],
|
|
28
|
+
['spec-eval', 'dist/index.js'],
|
|
29
|
+
['spec-forge', 'dist/index.js'],
|
|
30
|
+
['spec-cli', 'dist/cli.js'],
|
|
31
|
+
]
|
|
32
|
+
const srcRoots = buildTargets.map(([packagePath]) => join(pkg, '..', packagePath, 'src'))
|
|
20
33
|
const conflicted = srcRoots.flatMap((root) => {
|
|
21
34
|
if (!existsSync(root)) return []
|
|
22
35
|
return readdirSync(root, { recursive: true })
|
|
@@ -34,33 +47,174 @@ if (existsSync(sourceRoot)) {
|
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
// @@@ source-workspace build ([[release-launcher]]) - the git hooks invoke this launcher straight from a
|
|
37
|
-
// checkout, where dist is intentionally untracked.
|
|
38
|
-
//
|
|
50
|
+
// checkout, where dist is intentionally untracked. Import main's fresh closure and rebuild only changed
|
|
51
|
+
// packages plus dependents before linting candidate source; an untrusted baseline falls back to the complete
|
|
52
|
+
// ordered driver. Every package still publishes its dist atomically. An installed package has no src tree
|
|
53
|
+
// and stays a pure Node -> dist execution path.
|
|
39
54
|
const runtimeEntries = [
|
|
40
55
|
cli,
|
|
41
56
|
join(workspace, 'packages', 'spec-core', 'dist', 'index.js'),
|
|
42
|
-
join(workspace, 'packages', 'session-core', 'dist', 'index.js'),
|
|
43
57
|
join(workspace, 'spec-eval', 'dist', 'index.js'),
|
|
44
58
|
join(workspace, 'spec-forge', 'dist', 'index.js'),
|
|
45
59
|
]
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
60
|
+
// Tests and declaration files are typecheck inputs, not runtime build inputs. Counting them here makes a
|
|
61
|
+
// harmless test edit look like a stale CLI and rebuilds every workspace on the next hook invocation.
|
|
62
|
+
const isRuntimeSource = (path) => {
|
|
63
|
+
return /\.(ts|tsx|js|mjs)$/.test(path)
|
|
64
|
+
&& !/\.d\.ts$/.test(path)
|
|
65
|
+
&& !/(^|[./])[^/]+\.test\.(ts|tsx|js|mjs)$/.test(path)
|
|
66
|
+
}
|
|
67
|
+
const newestRuntimeSource = (root) => {
|
|
68
|
+
const roots = buildTargets.map(([packagePath]) => join(root, packagePath, 'src'))
|
|
69
|
+
return roots.reduce((newest, root) => {
|
|
70
|
+
if (!existsSync(root)) return newest
|
|
71
|
+
for (const entry of readdirSync(root, { recursive: true })) {
|
|
72
|
+
const path = join(root, String(entry))
|
|
73
|
+
try {
|
|
74
|
+
if (isRuntimeSource(path)) newest = Math.max(newest, statSync(path).mtimeMs)
|
|
75
|
+
} catch { /* a concurrent source edit can only make the next invocation rebuild again */ }
|
|
76
|
+
}
|
|
77
|
+
return newest
|
|
78
|
+
}, 0)
|
|
79
|
+
}
|
|
80
|
+
const newestPackageSource = (root, packagePath) => {
|
|
81
|
+
const source = join(root, packagePath, 'src')
|
|
82
|
+
if (!existsSync(source)) return 0
|
|
83
|
+
let newest = 0
|
|
84
|
+
const visit = (dir) => {
|
|
85
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
86
|
+
const path = join(dir, entry.name)
|
|
87
|
+
if (entry.isDirectory()) visit(path)
|
|
88
|
+
else if (isRuntimeSource(path)) {
|
|
89
|
+
try { newest = Math.max(newest, statSync(path).mtimeMs) } catch { /* retry on the next launch */ }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
53
92
|
}
|
|
93
|
+
visit(source)
|
|
54
94
|
return newest
|
|
55
|
-
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
95
|
+
}
|
|
96
|
+
const entriesAreFresh = (root) => {
|
|
97
|
+
const newestSource = newestRuntimeSource(root)
|
|
98
|
+
return buildTargets.every(([packagePath, entry]) => {
|
|
99
|
+
try { return statSync(join(root, packagePath, entry)).mtimeMs >= newestSource } catch { return false }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
const packageFingerprint = (root, packagePath) => {
|
|
103
|
+
const hash = createHash('sha256')
|
|
104
|
+
const packageRoot = join(root, packagePath)
|
|
105
|
+
const files = ['package.json', 'tsconfig.build.json'].map((name) => join(packageRoot, name)).filter(existsSync)
|
|
106
|
+
const visit = (dir, relative = '') => {
|
|
107
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
108
|
+
const path = join(dir, entry.name)
|
|
109
|
+
const child = relative ? `${relative}/${entry.name}` : entry.name
|
|
110
|
+
if (entry.isDirectory()) visit(path, child)
|
|
111
|
+
else if (isRuntimeSource(path)) files.push(path)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const source = join(packageRoot, 'src')
|
|
115
|
+
if (existsSync(source)) visit(source)
|
|
116
|
+
files.sort()
|
|
117
|
+
for (const path of files) hash.update(path.slice(packageRoot.length)).update('\0').update(readFileSync(path)).update('\0')
|
|
118
|
+
return hash.digest('hex')
|
|
119
|
+
}
|
|
120
|
+
const packageDependents = {
|
|
121
|
+
'packages/session-protocol': ['packages/session-topology', 'packages/session-runtime', 'packages/session-events', 'packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
|
|
122
|
+
'packages/session-topology': ['packages/session-application', 'spec-cli'],
|
|
123
|
+
'packages/session-runtime': ['packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
|
|
124
|
+
'packages/session-events': ['packages/session-application', 'spec-cli'],
|
|
125
|
+
'packages/session-application': ['spec-cli'],
|
|
126
|
+
'packages/session-selflaunch': ['spec-cli'],
|
|
127
|
+
'packages/spec-core': ['spec-eval', 'spec-forge', 'spec-cli'],
|
|
128
|
+
'spec-eval': ['spec-cli'],
|
|
129
|
+
'spec-forge': ['spec-cli'],
|
|
130
|
+
'spec-cli': [],
|
|
131
|
+
}
|
|
132
|
+
const changedBuildPackages = (root) => {
|
|
133
|
+
const changed = new Set(buildTargets
|
|
134
|
+
.map(([packagePath]) => packagePath)
|
|
135
|
+
.filter((packagePath) => packageFingerprint(workspace, packagePath) !== packageFingerprint(root, packagePath)))
|
|
136
|
+
for (const [packagePath, entry] of buildTargets) {
|
|
137
|
+
try {
|
|
138
|
+
if (statSync(join(root, packagePath, entry)).mtimeMs < newestPackageSource(workspace, packagePath)) changed.add(packagePath)
|
|
139
|
+
} catch { changed.add(packagePath) }
|
|
140
|
+
}
|
|
141
|
+
for (const packagePath of [...changed]) for (const dependent of packageDependents[packagePath]) changed.add(dependent)
|
|
142
|
+
return buildTargets.map(([packagePath]) => packagePath).filter((packagePath) => changed.has(packagePath))
|
|
143
|
+
}
|
|
144
|
+
const mainRoot = (() => {
|
|
145
|
+
const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: workspace, encoding: 'utf8' })
|
|
146
|
+
if (result.status !== 0) return null
|
|
147
|
+
const commonDir = result.stdout.trim()
|
|
148
|
+
return commonDir ? dirname(commonDir) : null
|
|
149
|
+
})()
|
|
150
|
+
const reusableMainBuild = () => mainRoot && mainRoot !== workspace && entriesAreFresh(mainRoot)
|
|
151
|
+
const importMainBuild = () => {
|
|
152
|
+
for (const [packagePath] of buildTargets) {
|
|
153
|
+
const source = join(mainRoot, packagePath, 'dist')
|
|
154
|
+
const target = join(workspace, packagePath, 'dist')
|
|
155
|
+
const temporary = `${target}.from-main-${process.pid}`
|
|
156
|
+
const previous = `${target}.from-main-previous-${process.pid}`
|
|
157
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
158
|
+
rmSync(previous, { recursive: true, force: true })
|
|
159
|
+
cpSync(source, temporary, { recursive: true })
|
|
160
|
+
if (existsSync(target)) renameSync(target, previous)
|
|
161
|
+
renameSync(temporary, target)
|
|
162
|
+
rmSync(previous, { recursive: true, force: true })
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const sourceIsStale = () => {
|
|
166
|
+
const newestSource = newestRuntimeSource(workspace)
|
|
167
|
+
return runtimeEntries.some((entry) => {
|
|
168
|
+
try { return statSync(entry).mtimeMs < newestSource } catch { return true }
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
if (sourceIsStale()) {
|
|
172
|
+
// A hook, poller, and human shell can all invoke spex at once. The lock is deliberately at the
|
|
173
|
+
// launcher boundary: only one process pays the full workspace build, and waiters re-check freshness
|
|
174
|
+
// after the owner exits. A dead owner is recoverable; a live build gets two minutes before it is stale.
|
|
175
|
+
const buildLock = join(workspace, '.spex-build.lock')
|
|
176
|
+
const wait = new Int32Array(new SharedArrayBuffer(4))
|
|
177
|
+
const deadline = Date.now() + 120_000
|
|
178
|
+
let owner = false
|
|
179
|
+
while (!owner) {
|
|
180
|
+
try {
|
|
181
|
+
mkdirSync(buildLock)
|
|
182
|
+
owner = true
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error?.code !== 'EEXIST') throw error
|
|
185
|
+
try {
|
|
186
|
+
if (Date.now() - statSync(buildLock).mtimeMs > 120_000) rmSync(buildLock, { recursive: true, force: true })
|
|
187
|
+
} catch { /* the owner may have completed between the stat and the next check */ }
|
|
188
|
+
if (Date.now() >= deadline) {
|
|
189
|
+
console.error('spex: source workspace build lock did not clear; remove .spex-build.lock after confirming no build is running.')
|
|
190
|
+
process.exit(75)
|
|
191
|
+
}
|
|
192
|
+
Atomics.wait(wait, 0, 0, 100)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
if (sourceIsStale()) {
|
|
197
|
+
if (reusableMainBuild()) {
|
|
198
|
+
const changed = changedBuildPackages(mainRoot)
|
|
199
|
+
console.error(`spex: importing fresh runtime closure from main; rebuilding ${changed.length} package(s)`)
|
|
200
|
+
importMainBuild()
|
|
201
|
+
if (changed.length) {
|
|
202
|
+
const build = spawnSync(process.execPath, [join(workspace, 'scripts', 'build-workspaces.mjs'), ...changed], { cwd: workspace, stdio: 'inherit' })
|
|
203
|
+
if (build.error || build.status !== 0 || !existsSync(cli)) {
|
|
204
|
+
console.error('spex: source workspace selective build failed; fix it, then retry.')
|
|
205
|
+
process.exit(build.status ?? 1)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const build = spawnSync('npm', ['run', 'build'], { cwd: workspace, stdio: 'inherit' })
|
|
210
|
+
if (build.error || build.status !== 0 || !existsSync(cli)) {
|
|
211
|
+
console.error('spex: source workspace build failed; fix it, then retry.')
|
|
212
|
+
process.exit(build.status ?? 1)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
} finally {
|
|
217
|
+
rmSync(buildLock, { recursive: true, force: true })
|
|
64
218
|
}
|
|
65
219
|
}
|
|
66
220
|
}
|
package/dist/cli.js
CHANGED
|
@@ -67,7 +67,7 @@ async function assertDaemonDependencies(command) {
|
|
|
67
67
|
// (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
|
|
68
68
|
// paths route through the same printer.
|
|
69
69
|
function fatal(e) {
|
|
70
|
-
if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError'].includes(e.name))
|
|
70
|
+
if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError', 'SessionFileError'].includes(e.name))
|
|
71
71
|
console.error(`spex: ${e.message}`);
|
|
72
72
|
else
|
|
73
73
|
console.error(e);
|
|
@@ -96,7 +96,7 @@ function flushExit(code = 0) {
|
|
|
96
96
|
}
|
|
97
97
|
const has = (name) => process.argv.includes(`--${name}`);
|
|
98
98
|
// bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
|
|
99
|
-
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--content-dir', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--ssh', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base', '--path', '--owner', '--details', '--variant', '--cli', '--count', '--ids']);
|
|
99
|
+
const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--content-dir', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--ssh', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base', '--candidate', '--path', '--owner', '--details', '--variant', '--cli', '--count', '--ids']);
|
|
100
100
|
const EXPLICIT_BACKEND_ROUTE_FLAGS = ['api', 'port', 'password', 'insecure'];
|
|
101
101
|
const EXPLICIT_BACKEND_VALUE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
|
|
102
102
|
.filter((name) => VALUE_FLAGS.has(`--${name}`))
|
|
@@ -213,6 +213,8 @@ function parseSessionSendArgs(args) {
|
|
|
213
213
|
}
|
|
214
214
|
if (positionals.length !== 2)
|
|
215
215
|
sessionSendUsage('plain send requires exactly one selector and one message');
|
|
216
|
+
if (!positionals[1].trim())
|
|
217
|
+
sessionSendUsage('message must not be empty or whitespace');
|
|
216
218
|
return { selector: positionals[0], kind: 'text', text: positionals[1], ...(values.has('--ssh') ? { sshAddress: values.get('--ssh') } : {}) };
|
|
217
219
|
}
|
|
218
220
|
const FULL_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
@@ -339,6 +341,7 @@ async function followKit(selectors, verb) {
|
|
|
339
341
|
const { localCachedSessions } = await import('./client.js');
|
|
340
342
|
const { fromRaw, ownSessionId, selectSessions, toSession } = await import('./sessions.js');
|
|
341
343
|
const { listSessionIds, readPublicRecordEntry } = await import('@spexcode/spec-core');
|
|
344
|
+
const { configuredSessionApplicationIfCutover, sessionApplicationCutoverState } = await import('./session-application.js');
|
|
342
345
|
const real = selectors.filter((sel) => sel && sel !== '@all');
|
|
343
346
|
let picked = [];
|
|
344
347
|
if (real.length) {
|
|
@@ -353,9 +356,38 @@ async function followKit(selectors, verb) {
|
|
|
353
356
|
// a fresh sample of the mutable record — that resample is what made the old poll lose moves.
|
|
354
357
|
const row = (id, status, note) => {
|
|
355
358
|
const entry = readPublicRecordEntry(id);
|
|
356
|
-
if (entry.kind
|
|
359
|
+
if (entry.kind === 'ok')
|
|
360
|
+
return { ...toSession(fromRaw(entry.raw), status, 'unknown'), note };
|
|
361
|
+
const cutover = sessionApplicationCutoverState();
|
|
362
|
+
const state = cutover === 'fresh' ? null : configuredSessionApplicationIfCutover()?.readState(id);
|
|
363
|
+
if (!state)
|
|
357
364
|
return null;
|
|
358
|
-
return {
|
|
365
|
+
return {
|
|
366
|
+
id,
|
|
367
|
+
node: null,
|
|
368
|
+
branch: null,
|
|
369
|
+
path: '',
|
|
370
|
+
label: id,
|
|
371
|
+
title: id,
|
|
372
|
+
raw: { name: null, title: null },
|
|
373
|
+
parent: state.parentSessionId,
|
|
374
|
+
harness: 'unknown',
|
|
375
|
+
capabilities: { headless: false },
|
|
376
|
+
launcher: null,
|
|
377
|
+
lifecycle: state.status,
|
|
378
|
+
proposal: state.proposal,
|
|
379
|
+
merges: 0,
|
|
380
|
+
status,
|
|
381
|
+
liveness: 'unknown',
|
|
382
|
+
note,
|
|
383
|
+
archived: state.status === 'archived',
|
|
384
|
+
closedAt: null,
|
|
385
|
+
prompt: null,
|
|
386
|
+
promptPreview: null,
|
|
387
|
+
created: state.updatedAtMs,
|
|
388
|
+
activity: null,
|
|
389
|
+
sortKey: null,
|
|
390
|
+
};
|
|
359
391
|
};
|
|
360
392
|
const watcher = ownSessionId();
|
|
361
393
|
return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) };
|
|
@@ -1062,9 +1094,9 @@ else if (cmd === 'session') {
|
|
|
1062
1094
|
// the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
|
|
1063
1095
|
// incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
|
|
1064
1096
|
const { ownSessionId, resolveSession, selectChildren, selectSessions, formatTable } = await import('./sessions.js');
|
|
1065
|
-
const { clientListSessions, clientListSessionsThroughPeer
|
|
1066
|
-
// The backend's default projection excludes
|
|
1067
|
-
//
|
|
1097
|
+
const { clientListSessions, clientListSessionsThroughPeer } = await import('./client.js');
|
|
1098
|
+
// The backend's default projection excludes closed records. --all and an explicit selector request the
|
|
1099
|
+
// retained record projection so an operator can inspect or resume one deliberately.
|
|
1068
1100
|
const selectors = positionals(4);
|
|
1069
1101
|
rejectUnknownBackendFlags('spex session ls', 4, ['status', 'all', 'json', 'ssh', 'children'], ['children']);
|
|
1070
1102
|
const peerAnchor = parseSessionPeerAnchor('ls', selectors);
|
|
@@ -1117,19 +1149,10 @@ else if (cmd === 'session') {
|
|
|
1117
1149
|
const selected = selectSessions(scoped, peerAnchor ? [] : selectors);
|
|
1118
1150
|
const picked = flag('status') ? selected.filter((session) => flag('status').split(',').includes(session.status)) : selected;
|
|
1119
1151
|
if (!peerAnchor && children === undefined && selectors.length === 1 && !selected.length && idLikeSelector(selectors[0])) {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const result = { id: closure.id, status: 'closed', closedAt: closure.closedAt };
|
|
1123
|
-
console.log(has('json') ? JSON.stringify([result], null, 2) : `${closure.id.slice(0, 8)}: closed at ${closure.closedAt}`);
|
|
1124
|
-
}
|
|
1125
|
-
else {
|
|
1126
|
-
console.error(`spex session ls: ${selectors[0]} was not found in this project's live, archive, or terminal-close history`);
|
|
1127
|
-
process.exit(2);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
else {
|
|
1131
|
-
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked, true, scope));
|
|
1152
|
+
console.error(`spex session ls: ${selectors[0]} was not found in this project's session records`);
|
|
1153
|
+
process.exit(2);
|
|
1132
1154
|
}
|
|
1155
|
+
console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked, true, scope));
|
|
1133
1156
|
}
|
|
1134
1157
|
else if (sub === 'resources') {
|
|
1135
1158
|
rejectUnknownBackendFlags('spex session resources', 4, ['json']);
|
|
@@ -1155,8 +1178,8 @@ else if (cmd === 'session') {
|
|
|
1155
1178
|
}
|
|
1156
1179
|
const files = await import('./session-files.js');
|
|
1157
1180
|
if (verb === 'ls') {
|
|
1158
|
-
for (const file of files.
|
|
1159
|
-
console.log(file);
|
|
1181
|
+
for (const file of files.inspectSessionFiles(id))
|
|
1182
|
+
console.log(file.valid ? file.path : `INVALID ${file.path} — ${file.reason}`);
|
|
1160
1183
|
}
|
|
1161
1184
|
else if (verb === 'add') {
|
|
1162
1185
|
const result = files.addSessionFile(id, path, withSessionRecordLockSync);
|
|
@@ -1389,14 +1412,6 @@ else if (cmd === 'session') {
|
|
|
1389
1412
|
console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`);
|
|
1390
1413
|
process.exit(r.ok ? 0 : 1);
|
|
1391
1414
|
}
|
|
1392
|
-
else if (sub === 'archive' || sub === 'unarchive') {
|
|
1393
|
-
// ARCHIVING/legacy unarchive ([[archive]]) — archive exact-stops before filing; unarchive signposts to
|
|
1394
|
-
// resume and recreates the same conversation.
|
|
1395
|
-
const on = sub === 'archive';
|
|
1396
|
-
const full = await resolveSelectorOrExit(id);
|
|
1397
|
-
const ok = await c.clientArchive(full, on);
|
|
1398
|
-
console.log(!ok ? `no such session ${full}` : `${on ? 'archived' : 'resumed'} ${full}`);
|
|
1399
|
-
}
|
|
1400
1415
|
else if (sub === 'close') {
|
|
1401
1416
|
const full = targetArgs?.sshAddress
|
|
1402
1417
|
? (FULL_SESSION_ID.test(id ?? '') ? id : sessionTargetUsage('close', '--ssh requires a full session id, not a selector'))
|
|
@@ -1688,47 +1703,59 @@ else if (cmd === 'internal') {
|
|
|
1688
1703
|
// materializing the rollout — then stage the id + exact-payload proof for the session lifecycle owner and
|
|
1689
1704
|
// print the thread id. The launch script then `resume`s it in the visible TUI.
|
|
1690
1705
|
const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust, codexLauncherThreadPolicy } = await import('./harness.js');
|
|
1691
|
-
const { stageHarnessLaunchProof } = await import('./sessions.js');
|
|
1706
|
+
const { existingHarnessLaunchTarget, stageHarnessLaunchProof } = await import('./sessions.js');
|
|
1692
1707
|
const sock = process.argv[4], cwd = process.argv[5];
|
|
1693
1708
|
const prompt = process.argv.slice(6).join(' ');
|
|
1694
1709
|
if (!sock || !cwd) {
|
|
1695
1710
|
console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]');
|
|
1696
1711
|
process.exit(2);
|
|
1697
1712
|
}
|
|
1698
|
-
|
|
1699
|
-
//
|
|
1700
|
-
//
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
const ownId = process.env.SPEXCODE_SESSION_ID?.trim();
|
|
1707
|
-
const r = await codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined, codexLauncherThreadPolicy(launcherCmd));
|
|
1708
|
-
if (!r.ok) {
|
|
1709
|
-
console.error(r.error);
|
|
1710
|
-
process.exit(1);
|
|
1713
|
+
const sid = process.env.SPEXCODE_SESSION_ID;
|
|
1714
|
+
// The visible TUI can fail after codex-launch has already staged the native id and first-turn proof
|
|
1715
|
+
// (for example, a remote-workspace validation error). The bounded shell retry re-enters this command;
|
|
1716
|
+
// reuse the proven target instead of minting a second thread or asking stageHarnessLaunchProof to consume
|
|
1717
|
+
// a payload that the lifecycle owner may already have committed.
|
|
1718
|
+
const existingTarget = sid ? existingHarnessLaunchTarget(sid) : null;
|
|
1719
|
+
if (existingTarget) {
|
|
1720
|
+
console.log(existingTarget);
|
|
1711
1721
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1722
|
+
else {
|
|
1723
|
+
// On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
|
|
1724
|
+
// the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
|
|
1725
|
+
// the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
|
|
1726
|
+
const launcherCmd = process.env.SPEXCODE_CODEX_CMD || 'codex';
|
|
1727
|
+
const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(launcherCmd));
|
|
1728
|
+
// The governed record id rides into the thread's own shell environment (shell_environment_policy.set), so
|
|
1729
|
+
// every command this thread spawns knows which session it is — the codex equivalent of the launch-injected
|
|
1730
|
+
// id claude gets. codex-launch is exactly where both ids are known ([[harness-adapter]]).
|
|
1731
|
+
const ownId = process.env.SPEXCODE_SESSION_ID?.trim();
|
|
1732
|
+
const { withCodexGenerationMutex } = await import('./codex-runtime-generations.js');
|
|
1733
|
+
const { runtimeRoot: projectRuntimeRoot } = await import('@spexcode/spec-core');
|
|
1734
|
+
const r = await withCodexGenerationMutex(projectRuntimeRoot(cwd), () => codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined, codexLauncherThreadPolicy(launcherCmd)));
|
|
1735
|
+
if (!r.ok) {
|
|
1736
|
+
console.error(r.error);
|
|
1716
1737
|
process.exit(1);
|
|
1717
1738
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1739
|
+
if (prompt) {
|
|
1740
|
+
const t = await codexTurn(sock, r.threadId, prompt, cwd);
|
|
1741
|
+
if (!t.ok) {
|
|
1742
|
+
console.error(t.error);
|
|
1743
|
+
process.exit(1);
|
|
1744
|
+
}
|
|
1745
|
+
// The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
|
|
1746
|
+
// but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
|
|
1747
|
+
// lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
|
|
1748
|
+
// non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
|
|
1749
|
+
// fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
|
|
1750
|
+
if (!await waitForCodexRollout(r.threadId, 20000)) {
|
|
1751
|
+
console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`);
|
|
1752
|
+
process.exit(1);
|
|
1753
|
+
}
|
|
1726
1754
|
}
|
|
1755
|
+
if (sid)
|
|
1756
|
+
stageHarnessLaunchProof(sid, r.threadId, prompt);
|
|
1757
|
+
console.log(r.threadId);
|
|
1727
1758
|
}
|
|
1728
|
-
const sid = process.env.SPEXCODE_SESSION_ID;
|
|
1729
|
-
if (sid)
|
|
1730
|
-
stageHarnessLaunchProof(sid, r.threadId, prompt);
|
|
1731
|
-
console.log(r.threadId);
|
|
1732
1759
|
}
|
|
1733
1760
|
else if (sub === 'opencode-capture') {
|
|
1734
1761
|
// opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
|
|
@@ -1804,6 +1831,25 @@ else if (cmd === 'internal') {
|
|
|
1804
1831
|
process.exit(1);
|
|
1805
1832
|
}
|
|
1806
1833
|
}
|
|
1834
|
+
else if (sub === 'codex-reopen') {
|
|
1835
|
+
// A headless resume reloads its evicted thread into the shared app-server (thread/resume, no turn), so
|
|
1836
|
+
// readiness — which proves online only for a RESIDENT thread — can see it. The visible TUI does this
|
|
1837
|
+
// implicitly by attaching; headless has no TUI, so its launch.sh resume branch calls this.
|
|
1838
|
+
const { codexReopenThread } = await import('./harness.js');
|
|
1839
|
+
const sock = process.argv[4], tid = process.argv[5];
|
|
1840
|
+
if (!sock || !tid) {
|
|
1841
|
+
console.error('usage: spex internal codex-reopen <sock> <threadId>');
|
|
1842
|
+
process.exit(2);
|
|
1843
|
+
}
|
|
1844
|
+
const r = await codexReopenThread(sock, tid);
|
|
1845
|
+
if (r.ok) {
|
|
1846
|
+
console.log('reopened');
|
|
1847
|
+
}
|
|
1848
|
+
else {
|
|
1849
|
+
console.error(r.error);
|
|
1850
|
+
process.exit(1);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1807
1853
|
else if (sub === 'check-staged') {
|
|
1808
1854
|
// the pre-commit hook's eval backstop: a staged stray evidence blob or malformed eval.md rejects the
|
|
1809
1855
|
// commit. Logic lives in spec-eval; the hook shims here.
|
|
@@ -1845,6 +1891,22 @@ else if (cmd === 'internal') {
|
|
|
1845
1891
|
const idled = mark(() => s.markIdle(sess));
|
|
1846
1892
|
console.log(idled.ok ? 'idle' : idled.reason ?? 'noop (no session record, or not active)');
|
|
1847
1893
|
}
|
|
1894
|
+
else if (sub === 'session-hook-state') {
|
|
1895
|
+
// Stop/idle/failure hooks are thin CLI shims. They may use the envelope for governed identity, but
|
|
1896
|
+
// lifecycle claims must come from the canonical session application after the JSON cutover.
|
|
1897
|
+
const id = flag('session');
|
|
1898
|
+
if (!id) {
|
|
1899
|
+
console.error('usage: spex internal session-hook-state --session <id>');
|
|
1900
|
+
process.exit(2);
|
|
1901
|
+
}
|
|
1902
|
+
const { sessionHookState } = await import('./sessions.js');
|
|
1903
|
+
const state = sessionHookState(id);
|
|
1904
|
+
if (!state)
|
|
1905
|
+
process.exit(1);
|
|
1906
|
+
// Tab-separated fields are deliberately closed vocabulary: governed, lifecycle, proposal. Notes are
|
|
1907
|
+
// not needed by the hook and must not become shell-parsed input.
|
|
1908
|
+
console.log([state.governed ? '1' : '0', state.status, state.proposal ?? ''].join('\t'));
|
|
1909
|
+
}
|
|
1848
1910
|
else if (sub === 'commit-gate') {
|
|
1849
1911
|
// the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
|
|
1850
1912
|
// ready to declare done, else print the reason and exit 1. Takes the PROPOSAL being judged — `merge`
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CockpitReview } from './cockpit.js';
|
|
2
2
|
import type { SessionEvalRevision } from '@spexcode/spec-eval/sessioneval';
|
|
3
|
-
import { type Session, type
|
|
3
|
+
import { type Session, type Resolved, type DispatchResult } from './sessions.js';
|
|
4
4
|
export declare class BackendError extends Error {
|
|
5
5
|
readonly status?: number | undefined;
|
|
6
6
|
readonly transport?: unknown | undefined;
|
|
@@ -9,7 +9,6 @@ export declare class BackendError extends Error {
|
|
|
9
9
|
export declare function backendConnectionRefused(error: unknown): boolean;
|
|
10
10
|
export declare function localCachedSessions(includeArchived?: boolean): Session[];
|
|
11
11
|
export declare function clientListSessions(includeArchived?: boolean): Promise<Session[]>;
|
|
12
|
-
export declare function clientSessionClosure(selector: string): Promise<SessionClosure | null>;
|
|
13
12
|
export declare function clientResources(): Promise<import('./host-resources.js').ResourceReport>;
|
|
14
13
|
export declare function resolveClientSession(selector: string, includeArchived?: boolean): Promise<Resolved>;
|
|
15
14
|
export type CaptureResult = {
|
|
@@ -94,7 +93,6 @@ export declare function clientRestoreQuarantine(id: string): Promise<{
|
|
|
94
93
|
sha256: string;
|
|
95
94
|
observedAt: string;
|
|
96
95
|
}>;
|
|
97
|
-
export declare function clientArchive(id: string, on?: boolean): Promise<boolean>;
|
|
98
96
|
export declare function clientRename(id: string, name: string): Promise<boolean>;
|
|
99
97
|
export declare function clientReparent(children: string[], parent: string | null): Promise<import('./sessions.js').SessionReparentResult>;
|
|
100
98
|
export declare function clientSendRawKeys(id: string, keys: string[]): Promise<boolean>;
|