@jhizzard/termdeck 1.2.0 → 1.3.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/package.json +2 -2
- package/packages/cli/src/index.js +53 -16
- package/packages/cli/src/init-mnestra.js +131 -0
- package/packages/cli/src/init.js +617 -0
- package/packages/cli/src/mcp-supabase-provision.js +685 -0
- package/packages/cli/src/os-detect.js +297 -0
- package/packages/server/src/agent-adapters/claude.js +11 -0
- package/packages/server/src/agent-adapters/codex.js +203 -1
- package/packages/server/src/agent-adapters/gemini.js +4 -0
- package/packages/server/src/agent-adapters/grok.js +4 -0
- package/packages/server/src/index.js +280 -6
- package/packages/server/src/setup/supabase-mcp.js +42 -3
- package/packages/stack-installer/assets/hooks/memory-pre-compact.js +277 -0
- package/packages/stack-installer/assets/hooks/memory-session-end.js +14 -2
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Sprint 64 T1 — OS detection module for the unified `termdeck init` wizard.
|
|
2
|
+
//
|
|
3
|
+
// Returns a normalized facts object the wizard branches on for: default shell,
|
|
4
|
+
// node-pty rebuild guidance, install path, autostart unit emission, and the
|
|
5
|
+
// in-Docker fixture detection that Path B / Sprint 67+ multi-port work depends on.
|
|
6
|
+
//
|
|
7
|
+
// All inputs are injectable via the `deps` parameter so tests can pin every
|
|
8
|
+
// branch without touching the actual host. Production callers pass nothing
|
|
9
|
+
// and get real `process.platform` / `os.arch()` / `fs.readFileSync` behavior.
|
|
10
|
+
//
|
|
11
|
+
// Cross-references:
|
|
12
|
+
// - Sprint 59 T2's `resolveSpawnShell` at packages/server/src/index.js — that
|
|
13
|
+
// runtime helper chains `config.shell` → `$SHELL` → `/bin/sh`. THIS module's
|
|
14
|
+
// `defaultShell` field is what the wizard reports / writes; both should agree.
|
|
15
|
+
// - BACKLOG §D.5 multi-port verification 2026-05-14 15:28 ET — second-instance
|
|
16
|
+
// boot under WAL-mode SQLite confirmed safe. Path B (per-instance DB, signal
|
|
17
|
+
// handling, autostart) is Sprint 67+. This module emits autostart STUBS only
|
|
18
|
+
// with a TODO marker — full wiring deferred per T1 brief §1.2.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
// Minimal /etc/os-release parser. Strips matched surrounding quotes. Returns
|
|
25
|
+
// an object of all parsed keys (ID, VERSION_ID, PRETTY_NAME, ID_LIKE, etc.)
|
|
26
|
+
// or null if nothing usable parsed.
|
|
27
|
+
function parseOsRelease(content) {
|
|
28
|
+
if (!content || typeof content !== 'string') return null;
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
31
|
+
const line = rawLine.trim();
|
|
32
|
+
if (!line || line.startsWith('#')) continue;
|
|
33
|
+
const eq = line.indexOf('=');
|
|
34
|
+
if (eq <= 0) continue;
|
|
35
|
+
const key = line.slice(0, eq).trim();
|
|
36
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue;
|
|
37
|
+
let value = line.slice(eq + 1).trim();
|
|
38
|
+
if (value.length >= 2) {
|
|
39
|
+
const first = value[0];
|
|
40
|
+
const last = value[value.length - 1];
|
|
41
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
42
|
+
value = value.slice(1, -1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
out[key] = value;
|
|
46
|
+
}
|
|
47
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Distro → defaults map. Used by detectOS() for the Linux branch.
|
|
51
|
+
// Keep in sync with INSTALLER-PITFALLS.md taxonomy if new distros show up
|
|
52
|
+
// in Brad's tester pool (currently Ubuntu 24.04 r730; macOS for Joshua).
|
|
53
|
+
const LINUX_DEFAULTS = {
|
|
54
|
+
ubuntu: { defaultShell: 'bash', rebuildHint: 'sudo apt install -y build-essential python3' },
|
|
55
|
+
debian: { defaultShell: 'bash', rebuildHint: 'sudo apt install -y build-essential python3' },
|
|
56
|
+
pop: { defaultShell: 'bash', rebuildHint: 'sudo apt install -y build-essential python3' },
|
|
57
|
+
linuxmint:{ defaultShell: 'bash', rebuildHint: 'sudo apt install -y build-essential python3' },
|
|
58
|
+
fedora: { defaultShell: 'bash', rebuildHint: 'sudo dnf install -y gcc-c++ make python3' },
|
|
59
|
+
rhel: { defaultShell: 'bash', rebuildHint: 'sudo dnf install -y gcc-c++ make python3' },
|
|
60
|
+
centos: { defaultShell: 'bash', rebuildHint: 'sudo dnf install -y gcc-c++ make python3' },
|
|
61
|
+
rocky: { defaultShell: 'bash', rebuildHint: 'sudo dnf install -y gcc-c++ make python3' },
|
|
62
|
+
alma: { defaultShell: 'bash', rebuildHint: 'sudo dnf install -y gcc-c++ make python3' },
|
|
63
|
+
alpine: { defaultShell: 'sh', rebuildHint: 'apk add --no-cache build-base python3' },
|
|
64
|
+
arch: { defaultShell: 'bash', rebuildHint: 'sudo pacman -S --needed base-devel python' },
|
|
65
|
+
manjaro: { defaultShell: 'bash', rebuildHint: 'sudo pacman -S --needed base-devel python' },
|
|
66
|
+
opensuse: { defaultShell: 'bash', rebuildHint: 'sudo zypper install -t pattern devel_C_C++' },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const ID_LIKE_FALLBACK = [
|
|
70
|
+
{ match: /\b(debian|ubuntu)\b/, defaults: LINUX_DEFAULTS.debian },
|
|
71
|
+
{ match: /\b(rhel|fedora|centos)\b/, defaults: LINUX_DEFAULTS.fedora },
|
|
72
|
+
{ match: /\b(alpine|musl)\b/, defaults: LINUX_DEFAULTS.alpine },
|
|
73
|
+
{ match: /\b(arch)\b/, defaults: LINUX_DEFAULTS.arch },
|
|
74
|
+
{ match: /\b(suse)\b/, defaults: LINUX_DEFAULTS.opensuse },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
// Heuristic in-container detection. Multiple signals because no single one
|
|
78
|
+
// is reliable across container runtimes (Docker, Podman, BuildKit, K8s).
|
|
79
|
+
function detectInContainer({ existsSync, readFile, getEnv }) {
|
|
80
|
+
if (getEnv('container')) return true; // systemd-nspawn / Podman set this
|
|
81
|
+
if (existsSync('/.dockerenv')) return true;
|
|
82
|
+
if (existsSync('/run/.containerenv')) return true; // Podman fixture
|
|
83
|
+
const cgroup = readFile('/proc/1/cgroup');
|
|
84
|
+
if (cgroup && /\b(docker|kubepods|containerd|libpod)\b/.test(cgroup)) return true;
|
|
85
|
+
const mountinfo = readFile('/proc/self/mountinfo');
|
|
86
|
+
if (mountinfo && /\b(overlay|docker|kubelet)\b/.test(mountinfo)) return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// launchd plist stub for macOS. Path B / Sprint 65+ replaces the stub with
|
|
91
|
+
// full wiring including KeepAlive policy variants, log rotation, EnvironmentVariables,
|
|
92
|
+
// and per-port instance labels. v1.3.0 ships stub only.
|
|
93
|
+
function launchdPlistStub(homedir) {
|
|
94
|
+
return [
|
|
95
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
96
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTD/PropertyList-1.0.dtd">',
|
|
97
|
+
'<plist version="1.0">',
|
|
98
|
+
'<dict>',
|
|
99
|
+
' <!-- TermDeck launchd autostart STUB — Sprint 64 T1. Full wiring in Sprint 65+. -->',
|
|
100
|
+
' <!-- TODO(sprint-65): per-port label, log rotation, EnvironmentVariables block. -->',
|
|
101
|
+
' <key>Label</key>',
|
|
102
|
+
' <string>com.jhizzard.termdeck</string>',
|
|
103
|
+
' <key>ProgramArguments</key>',
|
|
104
|
+
' <array>',
|
|
105
|
+
' <string>/usr/local/bin/termdeck</string>',
|
|
106
|
+
' <string>--service</string>',
|
|
107
|
+
' </array>',
|
|
108
|
+
' <key>RunAtLoad</key>',
|
|
109
|
+
' <true/>',
|
|
110
|
+
' <key>KeepAlive</key>',
|
|
111
|
+
' <true/>',
|
|
112
|
+
' <key>StandardOutPath</key>',
|
|
113
|
+
` <string>${homedir}/.termdeck/termdeck.log</string>`,
|
|
114
|
+
' <key>StandardErrorPath</key>',
|
|
115
|
+
` <string>${homedir}/.termdeck/termdeck.err.log</string>`,
|
|
116
|
+
'</dict>',
|
|
117
|
+
'</plist>',
|
|
118
|
+
'',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// systemd user unit stub for Linux (outside Docker). Path B / Sprint 65+
|
|
123
|
+
// replaces the stub with full wiring (per-port unit instance, Environment=,
|
|
124
|
+
// proper Restart policy tuning, journald integration). v1.3.0 ships stub only.
|
|
125
|
+
function systemdUserUnitStub() {
|
|
126
|
+
return [
|
|
127
|
+
'# TermDeck systemd user unit STUB — Sprint 64 T1. Full wiring in Sprint 65+.',
|
|
128
|
+
'# TODO(sprint-65): templated unit @<port>, Environment=TERMDECK_PORT=, journald gates.',
|
|
129
|
+
'#',
|
|
130
|
+
'# Install: systemctl --user enable --now termdeck.service',
|
|
131
|
+
'# Status: systemctl --user status termdeck.service',
|
|
132
|
+
'# Logs: journalctl --user -u termdeck.service -f',
|
|
133
|
+
'',
|
|
134
|
+
'[Unit]',
|
|
135
|
+
'Description=TermDeck — browser terminal multiplexer',
|
|
136
|
+
'After=network-online.target',
|
|
137
|
+
'',
|
|
138
|
+
'[Service]',
|
|
139
|
+
'Type=simple',
|
|
140
|
+
'ExecStart=/usr/local/bin/termdeck --service',
|
|
141
|
+
'Restart=on-failure',
|
|
142
|
+
'RestartSec=5s',
|
|
143
|
+
'StandardOutput=append:%h/.termdeck/termdeck.log',
|
|
144
|
+
'StandardError=append:%h/.termdeck/termdeck.err.log',
|
|
145
|
+
'',
|
|
146
|
+
'[Install]',
|
|
147
|
+
'WantedBy=default.target',
|
|
148
|
+
'',
|
|
149
|
+
].join('\n');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Detect the host OS and build the wizard-facing facts object.
|
|
153
|
+
//
|
|
154
|
+
// Deps (all optional; tests inject):
|
|
155
|
+
// platform — process.platform replacement ('darwin'|'linux'|'win32'|...)
|
|
156
|
+
// arch — os.arch() replacement
|
|
157
|
+
// homedir — os.homedir() replacement
|
|
158
|
+
// getEnv — (key) => string|undefined (defaults to process.env[key])
|
|
159
|
+
// readFile — (path) => string|null
|
|
160
|
+
// existsSync — (path) => boolean
|
|
161
|
+
// macosVersion — pre-resolved 'YY.YY.YY' string (e.g. '14.3.1'); when omitted
|
|
162
|
+
// the function does NOT shell out (no spawnSync to `sw_vers`)
|
|
163
|
+
// — keeps boot deterministic for the wizard. Callers wanting
|
|
164
|
+
// the version can pass it via deps.
|
|
165
|
+
//
|
|
166
|
+
// Returns: {
|
|
167
|
+
// family: 'macos' | 'linux' | 'docker' | 'unknown'
|
|
168
|
+
// distro: string | null (e.g. 'ubuntu', 'fedora', 'alpine')
|
|
169
|
+
// version: string | null (VERSION_ID for linux, macosVersion for macos)
|
|
170
|
+
// isAppleSilicon:boolean (true only on darwin+arm64)
|
|
171
|
+
// inDocker: boolean (always present)
|
|
172
|
+
// defaultShell: 'zsh' | 'bash' | 'sh'
|
|
173
|
+
// rebuildHint: string (node-pty rebuild remediation)
|
|
174
|
+
// paths: {
|
|
175
|
+
// home, termdeck, secretsEnv, configYaml, autostartDir
|
|
176
|
+
// }
|
|
177
|
+
// autostartUnit: { kind, path, content, note? } (kind: 'launchd'|'systemd'|null)
|
|
178
|
+
// }
|
|
179
|
+
function detectOS(deps) {
|
|
180
|
+
deps = deps || {};
|
|
181
|
+
const platform = deps.platform || process.platform;
|
|
182
|
+
const arch = deps.arch || os.arch();
|
|
183
|
+
const homedir = deps.homedir || os.homedir();
|
|
184
|
+
const getEnv = deps.getEnv || ((k) => process.env[k]);
|
|
185
|
+
const readFile = deps.readFile || ((p) => {
|
|
186
|
+
try { return fs.readFileSync(p, 'utf8'); }
|
|
187
|
+
catch (_e) { return null; }
|
|
188
|
+
});
|
|
189
|
+
const existsSync = deps.existsSync || ((p) => fs.existsSync(p));
|
|
190
|
+
|
|
191
|
+
const termdeckDir = path.join(homedir, '.termdeck');
|
|
192
|
+
const basePaths = {
|
|
193
|
+
home: homedir,
|
|
194
|
+
termdeck: termdeckDir,
|
|
195
|
+
secretsEnv: path.join(termdeckDir, 'secrets.env'),
|
|
196
|
+
configYaml: path.join(termdeckDir, 'config.yaml'),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (platform === 'darwin') {
|
|
200
|
+
const isAppleSilicon = arch === 'arm64';
|
|
201
|
+
const macosVersion = deps.macosVersion || null;
|
|
202
|
+
return {
|
|
203
|
+
family: 'macos',
|
|
204
|
+
distro: null,
|
|
205
|
+
version: macosVersion,
|
|
206
|
+
isAppleSilicon,
|
|
207
|
+
inDocker: false,
|
|
208
|
+
defaultShell: 'zsh',
|
|
209
|
+
rebuildHint: 'xcode-select --install # installs the macOS Command Line Tools (clang, make)',
|
|
210
|
+
paths: {
|
|
211
|
+
...basePaths,
|
|
212
|
+
autostartDir: path.join(homedir, 'Library', 'LaunchAgents'),
|
|
213
|
+
},
|
|
214
|
+
autostartUnit: {
|
|
215
|
+
kind: 'launchd',
|
|
216
|
+
path: path.join(homedir, 'Library', 'LaunchAgents', 'com.jhizzard.termdeck.plist'),
|
|
217
|
+
content: launchdPlistStub(homedir),
|
|
218
|
+
note: 'STUB only — full wiring deferred to Sprint 65+',
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (platform === 'linux') {
|
|
224
|
+
const inDocker = detectInContainer({ existsSync, readFile, getEnv });
|
|
225
|
+
const osRelease = parseOsRelease(readFile('/etc/os-release')) || {};
|
|
226
|
+
const rawId = (osRelease.ID || '').toLowerCase();
|
|
227
|
+
const idLike = (osRelease.ID_LIKE || '').toLowerCase();
|
|
228
|
+
const version = osRelease.VERSION_ID || null;
|
|
229
|
+
|
|
230
|
+
let distro = rawId || null;
|
|
231
|
+
let defaults = LINUX_DEFAULTS[rawId];
|
|
232
|
+
if (!defaults && idLike) {
|
|
233
|
+
for (const f of ID_LIKE_FALLBACK) {
|
|
234
|
+
if (f.match.test(idLike)) {
|
|
235
|
+
defaults = f.defaults;
|
|
236
|
+
if (!distro) distro = idLike.split(/\s+/)[0];
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!defaults) {
|
|
242
|
+
defaults = { defaultShell: 'bash', rebuildHint: 'install your distro\'s C++ build toolchain (gcc, make, python3)' };
|
|
243
|
+
if (!distro) distro = 'unknown';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const autostartDir = path.join(homedir, '.config', 'systemd', 'user');
|
|
247
|
+
return {
|
|
248
|
+
family: inDocker ? 'docker' : 'linux',
|
|
249
|
+
distro,
|
|
250
|
+
version,
|
|
251
|
+
isAppleSilicon: false,
|
|
252
|
+
inDocker,
|
|
253
|
+
defaultShell: defaults.defaultShell,
|
|
254
|
+
rebuildHint: defaults.rebuildHint,
|
|
255
|
+
paths: {
|
|
256
|
+
...basePaths,
|
|
257
|
+
autostartDir: inDocker ? null : autostartDir,
|
|
258
|
+
},
|
|
259
|
+
autostartUnit: inDocker
|
|
260
|
+
? {
|
|
261
|
+
kind: null,
|
|
262
|
+
path: null,
|
|
263
|
+
content: null,
|
|
264
|
+
note: 'in-container fixture — TermDeck runs as the container entrypoint; no per-user systemd unit',
|
|
265
|
+
}
|
|
266
|
+
: {
|
|
267
|
+
kind: 'systemd',
|
|
268
|
+
path: path.join(autostartDir, 'termdeck.service'),
|
|
269
|
+
content: systemdUserUnitStub(),
|
|
270
|
+
note: 'STUB only — full wiring deferred to Sprint 65+',
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
family: 'unknown',
|
|
277
|
+
distro: null,
|
|
278
|
+
version: null,
|
|
279
|
+
isAppleSilicon: false,
|
|
280
|
+
inDocker: false,
|
|
281
|
+
defaultShell: 'sh',
|
|
282
|
+
rebuildHint: 'install your platform\'s C++ toolchain manually (gcc, make, python3) before re-running termdeck init',
|
|
283
|
+
paths: { ...basePaths, autostartDir: null },
|
|
284
|
+
autostartUnit: { kind: null, path: null, content: null, note: 'unknown platform — autostart unit not auto-emitted' },
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
module.exports = {
|
|
289
|
+
detectOS,
|
|
290
|
+
parseOsRelease,
|
|
291
|
+
// Exported for tests:
|
|
292
|
+
_LINUX_DEFAULTS: LINUX_DEFAULTS,
|
|
293
|
+
_ID_LIKE_FALLBACK: ID_LIKE_FALLBACK,
|
|
294
|
+
_launchdPlistStub: launchdPlistStub,
|
|
295
|
+
_systemdUserUnitStub: systemdUserUnitStub,
|
|
296
|
+
_detectInContainer: detectInContainer,
|
|
297
|
+
};
|
|
@@ -221,6 +221,17 @@ const claudeAdapter = {
|
|
|
221
221
|
binary: 'claude',
|
|
222
222
|
defaultArgs: [],
|
|
223
223
|
env: {},
|
|
224
|
+
// Sprint 64 T2 (carve-out 2.4) — when shellWrap is `false`, spawnTerminalSession
|
|
225
|
+
// bypasses the `zsh -c <command>` wrapper and execs `binary` + `defaultArgs`
|
|
226
|
+
// directly via `pty.spawn`. Preserves the user's PATH lookup of `claude`
|
|
227
|
+
// while keeping the PTY rooted in an interactive context (zsh -c discards
|
|
228
|
+
// the TTY-interactive flags Claude Code's input handler needs). Falls back
|
|
229
|
+
// to the legacy wrapper when the user-supplied `command` carries additional
|
|
230
|
+
// args (e.g. `claude --resume <uuid>` from a launcher button) so user args
|
|
231
|
+
// are not silently dropped. See packages/server/src/index.js:1118-1175 for
|
|
232
|
+
// the dispatch logic and packages/server/tests/adapter-spawn-shell-wrap.test.js
|
|
233
|
+
// for the fence.
|
|
234
|
+
shellWrap: false,
|
|
224
235
|
},
|
|
225
236
|
patterns: {
|
|
226
237
|
prompt: PROMPT,
|
|
@@ -155,6 +155,41 @@ function _codexCandidateDirs(homedir, now) {
|
|
|
155
155
|
return out;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// Sprint 64 T2 (carve-out 2.1) — `min(birthtime, mtime)` is the right gate
|
|
159
|
+
// for cross-panel contamination. Sprint 63 EXIT-CAPTURE-VERIFICATION.md
|
|
160
|
+
// Finding #1 documents the failure mode: when codex panel-B spawned and
|
|
161
|
+
// self-exited during the 0.129→0.130 auto-update, panel-A's rollout was
|
|
162
|
+
// still being written by panel-A's ongoing turns; A's `mtimeMs` exceeded
|
|
163
|
+
// B's `createdAtMs`, so A was returned as B's transcript.
|
|
164
|
+
//
|
|
165
|
+
// Why `min(birthtime, mtime)` rather than birthtime alone or mtime alone:
|
|
166
|
+
// • Cross-panel contamination (Sprint 63 Finding #1): Panel-A active panel
|
|
167
|
+
// has `birthtime=T_A_create` (in the past) and `mtime=NOW` (bumped each
|
|
168
|
+
// turn). min = birthtime — correctly rejects when birthtime < spawn time.
|
|
169
|
+
// • Backdated-mtime stale rollouts: mtime < birthtime. min = mtime —
|
|
170
|
+
// correctly rejects when backdated mtime < spawn time.
|
|
171
|
+
// • Same-session rollout (this panel's own): birthtime AND mtime both
|
|
172
|
+
// post-spawn. min = birthtime ≈ mtime — correctly admits.
|
|
173
|
+
// • Platforms without birthtime (some Linux tmpfs returns birthtimeMs=0):
|
|
174
|
+
// fall back to `mtime` for both terms of the min → equivalent to mtime
|
|
175
|
+
// gate, same behavior as pre-fix.
|
|
176
|
+
//
|
|
177
|
+
// Gate epsilon (per Sprint 64 T4-CODEX 16:21 AUDIT-CONCERN — deterministic
|
|
178
|
+
// pre-spawn rejection on birthtime-capable platforms):
|
|
179
|
+
// • Birthtime-capable platforms (APFS, ext4 with `statx`, NTFS): STRICT,
|
|
180
|
+
// no epsilon. Birthtime is deterministic FS metadata — no jitter, no
|
|
181
|
+
// quantization beyond ~1ns. A file with `birthtimeMs < spawnTimestampMs`
|
|
182
|
+
// was unambiguously created before this panel spawned and CANNOT be
|
|
183
|
+
// this panel's rollout. Strict gate is correct.
|
|
184
|
+
// • Mtime-fallback platforms (rare; some Linux tmpfs): use
|
|
185
|
+
// `_CODEX_GATE_EPSILON_MS_MTIME_FALLBACK = 5000ms` to absorb FS time
|
|
186
|
+
// quantization rounding plus any small clock-skew between OS time and
|
|
187
|
+
// `Date.now()`. mtime can drift in production (active concurrent panel
|
|
188
|
+
// bumps it), so this epsilon path is intentionally narrower than
|
|
189
|
+
// birthtime — it's a structural fallback, not a tolerance knob.
|
|
190
|
+
const _CODEX_GATE_EPSILON_MS_BIRTHTIME = 0;
|
|
191
|
+
const _CODEX_GATE_EPSILON_MS_MTIME_FALLBACK = 5000;
|
|
192
|
+
|
|
158
193
|
async function resolveTranscriptPath(session) {
|
|
159
194
|
const fs = require('fs');
|
|
160
195
|
const path = require('path');
|
|
@@ -164,6 +199,15 @@ async function resolveTranscriptPath(session) {
|
|
|
164
199
|
const createdAtMs = session.meta.createdAt
|
|
165
200
|
? Date.parse(session.meta.createdAt)
|
|
166
201
|
: 0;
|
|
202
|
+
// Sprint 64 T2 (carve-out 2.1) — spawnTimestampMs is set in spawnTerminalSession
|
|
203
|
+
// immediately after `pty.spawn` returns; strictly later than createdAt (which
|
|
204
|
+
// is set in `sessions.create` BEFORE pty.spawn). Use it when present; fall
|
|
205
|
+
// back to createdAt for older sessions reloaded from SQLite that pre-date the
|
|
206
|
+
// field. The `- _CODEX_GATE_EPSILON_MS` accounts for filesystem time-stamp
|
|
207
|
+
// quantization rounding (worst-case 1s on some platforms).
|
|
208
|
+
const spawnAtMs = (typeof session.meta.spawnTimestampMs === 'number' && session.meta.spawnTimestampMs > 0)
|
|
209
|
+
? session.meta.spawnTimestampMs
|
|
210
|
+
: createdAtMs;
|
|
167
211
|
const candidates = [];
|
|
168
212
|
for (const dir of _codexCandidateDirs(os.homedir(), Date.now())) {
|
|
169
213
|
let entries;
|
|
@@ -174,7 +218,18 @@ async function resolveTranscriptPath(session) {
|
|
|
174
218
|
const full = path.join(dir, name);
|
|
175
219
|
let st;
|
|
176
220
|
try { st = fs.statSync(full); } catch (_) { continue; }
|
|
177
|
-
|
|
221
|
+
// Per-file gate: prefer strict birthtime when the platform exposes it;
|
|
222
|
+
// fall back to epsilon-tolerant mtime only when birthtime is unavailable.
|
|
223
|
+
// Either signal indicates "this rollout existed before the panel
|
|
224
|
+
// spawned" → reject the candidate.
|
|
225
|
+
const hasBirthtime = (typeof st.birthtimeMs === 'number' && st.birthtimeMs > 0);
|
|
226
|
+
const epsilonForFile = hasBirthtime
|
|
227
|
+
? _CODEX_GATE_EPSILON_MS_BIRTHTIME
|
|
228
|
+
: _CODEX_GATE_EPSILON_MS_MTIME_FALLBACK;
|
|
229
|
+
const gateMsForFile = spawnAtMs > 0 ? spawnAtMs - epsilonForFile : 0;
|
|
230
|
+
const fileBirthMs = hasBirthtime ? st.birthtimeMs : st.mtimeMs;
|
|
231
|
+
const fileMinMs = Math.min(fileBirthMs, st.mtimeMs);
|
|
232
|
+
if (gateMsForFile && fileMinMs < gateMsForFile) continue;
|
|
178
233
|
candidates.push({ full, mtime: st.mtimeMs });
|
|
179
234
|
}
|
|
180
235
|
}
|
|
@@ -264,6 +319,138 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
264
319
|
].join('\n');
|
|
265
320
|
}
|
|
266
321
|
|
|
322
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
323
|
+
// probeCodexVersion — Sprint 64 T2 (carve-out 2.3).
|
|
324
|
+
//
|
|
325
|
+
// Pre-spawn version probe for the Codex CLI auto-update lifecycle hazard
|
|
326
|
+
// documented in Sprint 63 EXIT-CAPTURE-VERIFICATION.md Finding #1. Codex CLI
|
|
327
|
+
// has no `--no-update` flag (verified 2026-05-11 against codex 0.130.0), so a
|
|
328
|
+
// stale codex panel may fire its interactive update picker on spawn, accept
|
|
329
|
+
// "Update now," `npm install -g @openai/codex`, and exit 0 — BEFORE any canary
|
|
330
|
+
// inject lands. Joshua's Sprint 63 T2 lost a codex canary panel to exactly
|
|
331
|
+
// this failure mode at 13:26 ET.
|
|
332
|
+
//
|
|
333
|
+
// Approach (per Sprint 64 ORCH SCOPE 16:14 ET adjudication of T4-CODEX 16:11
|
|
334
|
+
// AUDIT-CONCERN #3 default-install visibility): two complementary WARN paths.
|
|
335
|
+
//
|
|
336
|
+
// • **Persisted last-seen-version drift.** Read
|
|
337
|
+
// `~/.termdeck/.last-codex-version`. Absent → write `observed` silently,
|
|
338
|
+
// no WARN (first run is "baseline," not "drift"). Present and
|
|
339
|
+
// `observed !== persisted` → log WARN + update persisted to new observed
|
|
340
|
+
// (self-heals: next spawn is silent on the new version). Catches the
|
|
341
|
+
// Sprint 63 auto-update hazard for the default operator with no env-var
|
|
342
|
+
// setup required. Doesn't false-alarm on stable installs (no env, no
|
|
343
|
+
// persisted file changes once written).
|
|
344
|
+
//
|
|
345
|
+
// • **`CODEX_PINNED_VERSION` env knob.** Operator-explicit pin retained
|
|
346
|
+
// as a separate signal — useful in CI / multi-user installs where the
|
|
347
|
+
// persisted file is per-user but the pin is global. WARN on observed ≠
|
|
348
|
+
// pinned; independent of the drift path above.
|
|
349
|
+
//
|
|
350
|
+
// Why not a hardcoded "known-good window"? Codex shipped 0.125 → 0.129 →
|
|
351
|
+
// 0.130 in ~10 days; a baked-in version list goes stale in a week. The
|
|
352
|
+
// persisted-self-heal path is the deterministic answer.
|
|
353
|
+
//
|
|
354
|
+
// Why not a wrapper shim (option B) that intercepts the update picker? The
|
|
355
|
+
// picker has already shifted shape across recent codex releases; a shim that
|
|
356
|
+
// answers "n\n" today may answer "yes\n" to a future renamed prompt. Real
|
|
357
|
+
// fix lives upstream — file a `--no-update` flag against the Codex CLI repo.
|
|
358
|
+
// Tracking that filing is cheaper than maintaining a shim.
|
|
359
|
+
//
|
|
360
|
+
// Dependency-injected `spawnSync` + `logger` + `fsApi` keep the fence test
|
|
361
|
+
// free of a live codex binary on PATH or filesystem dependence.
|
|
362
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
// Module-level constants for testability — ORCH SCOPE 16:14 ET. Fence tests
|
|
365
|
+
// override the path by passing `{ persistedVersionPath: '...' }`.
|
|
366
|
+
const _CODEX_PERSISTED_VERSION_FILENAME = '.last-codex-version';
|
|
367
|
+
|
|
368
|
+
function _defaultPersistedVersionPath() {
|
|
369
|
+
const os = require('os');
|
|
370
|
+
const path = require('path');
|
|
371
|
+
return path.join(os.homedir(), '.termdeck', _CODEX_PERSISTED_VERSION_FILENAME);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function probeCodexVersion({
|
|
375
|
+
pinnedVersion = process.env.CODEX_PINNED_VERSION,
|
|
376
|
+
spawnSync = require('child_process').spawnSync,
|
|
377
|
+
logger = console,
|
|
378
|
+
fsApi = require('fs'),
|
|
379
|
+
persistedVersionPath = _defaultPersistedVersionPath(),
|
|
380
|
+
} = {}) {
|
|
381
|
+
let observed = null;
|
|
382
|
+
try {
|
|
383
|
+
const res = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 2000 });
|
|
384
|
+
if (!res || res.status !== 0 || !res.stdout) {
|
|
385
|
+
return { ok: null, observed: null, reason: 'probe-failed' };
|
|
386
|
+
}
|
|
387
|
+
const match = String(res.stdout).match(/(\d+\.\d+\.\d+)/);
|
|
388
|
+
observed = match ? match[1] : null;
|
|
389
|
+
} catch (_) {
|
|
390
|
+
return { ok: null, observed: null, reason: 'probe-error' };
|
|
391
|
+
}
|
|
392
|
+
if (!observed) {
|
|
393
|
+
return { ok: null, observed: null, reason: 'no-version-string' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Drift path: compare observed against persisted last-seen value.
|
|
397
|
+
let persisted = null;
|
|
398
|
+
let driftDetected = false;
|
|
399
|
+
try {
|
|
400
|
+
if (fsApi.existsSync(persistedVersionPath)) {
|
|
401
|
+
const raw = fsApi.readFileSync(persistedVersionPath, 'utf8');
|
|
402
|
+
const trimmed = String(raw || '').trim();
|
|
403
|
+
persisted = trimmed.length > 0 ? trimmed : null;
|
|
404
|
+
}
|
|
405
|
+
} catch (_) {
|
|
406
|
+
// Read failure is non-fatal — treat as absent. Persistence is best-effort.
|
|
407
|
+
persisted = null;
|
|
408
|
+
}
|
|
409
|
+
if (persisted === null) {
|
|
410
|
+
// First-run baseline — write silently, no WARN.
|
|
411
|
+
_writePersistedVersion(fsApi, persistedVersionPath, observed);
|
|
412
|
+
} else if (persisted !== observed) {
|
|
413
|
+
driftDetected = true;
|
|
414
|
+
if (logger && typeof logger.warn === 'function') {
|
|
415
|
+
logger.warn(
|
|
416
|
+
`[codex] version drift detected: observed=${observed} persisted=${persisted} — `
|
|
417
|
+
+ 'codex CLI may have auto-updated since last spawn (Sprint 63 lifecycle hazard).'
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
_writePersistedVersion(fsApi, persistedVersionPath, observed);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Pin path: independent of drift. Warns on every spawn where pin ≠ observed.
|
|
424
|
+
let pinnedMismatch = false;
|
|
425
|
+
if (pinnedVersion && observed !== pinnedVersion) {
|
|
426
|
+
pinnedMismatch = true;
|
|
427
|
+
if (logger && typeof logger.warn === 'function') {
|
|
428
|
+
logger.warn(
|
|
429
|
+
`[codex] version pin mismatch: observed=${observed} pinned=${pinnedVersion} — `
|
|
430
|
+
+ 'CODEX_PINNED_VERSION env var requires explicit re-pin (Sprint 63 lifecycle hazard).'
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (driftDetected || pinnedMismatch) {
|
|
436
|
+
return { ok: false, observed, persisted, pinned: pinnedVersion || null, driftDetected, pinnedMismatch };
|
|
437
|
+
}
|
|
438
|
+
return { ok: true, observed, persisted, pinned: pinnedVersion || null, driftDetected: false, pinnedMismatch: false };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function _writePersistedVersion(fsApi, p, version) {
|
|
442
|
+
try {
|
|
443
|
+
const path = require('path');
|
|
444
|
+
const dir = path.dirname(p);
|
|
445
|
+
try { fsApi.mkdirSync(dir, { recursive: true }); }
|
|
446
|
+
catch (_) { /* fail-soft — usually already exists */ }
|
|
447
|
+
fsApi.writeFileSync(p, `${version}\n`, 'utf8');
|
|
448
|
+
} catch (_) {
|
|
449
|
+
// Persistence failure is non-fatal — WARN behavior is unaffected the
|
|
450
|
+
// next spawn (we'll re-detect drift against whatever is/isn't on disk).
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
267
454
|
const codexAdapter = {
|
|
268
455
|
name: 'codex',
|
|
269
456
|
sessionType: 'codex',
|
|
@@ -274,6 +461,14 @@ const codexAdapter = {
|
|
|
274
461
|
binary: 'codex',
|
|
275
462
|
defaultArgs: [],
|
|
276
463
|
env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY },
|
|
464
|
+
// Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
|
|
465
|
+
// the launching command is exactly the binary name. Sprint 63
|
|
466
|
+
// EXIT-CAPTURE-VERIFICATION.md § 6 flagged this as a probable contributor
|
|
467
|
+
// to codex's fast-death window during the 2026-05-11 13:26 ET update-picker
|
|
468
|
+
// event — codex spawned through `zsh -c codex` may have lost the
|
|
469
|
+
// interactive-TTY context the update-picker dialog needed. See claude.js
|
|
470
|
+
// for the full rationale + fallback semantics.
|
|
471
|
+
shellWrap: false,
|
|
277
472
|
},
|
|
278
473
|
patterns: {
|
|
279
474
|
prompt: PROMPT,
|
|
@@ -329,4 +524,11 @@ const codexAdapter = {
|
|
|
329
524
|
},
|
|
330
525
|
};
|
|
331
526
|
|
|
527
|
+
// Sprint 64 T2 (carve-out 2.3) — expose probeCodexVersion on the adapter object
|
|
528
|
+
// so call sites can `require('./codex').probeCodexVersion(...)` without
|
|
529
|
+
// threading through the registry. Adapter-shape parity tests (Sprint 45 T4's
|
|
530
|
+
// tests/agent-adapter-parity.test.js) iterate a fixed allowlist of fields and
|
|
531
|
+
// tolerate extra properties — adding this function is safe.
|
|
532
|
+
codexAdapter.probeCodexVersion = probeCodexVersion;
|
|
533
|
+
|
|
332
534
|
module.exports = codexAdapter;
|
|
@@ -247,6 +247,10 @@ const geminiAdapter = {
|
|
|
247
247
|
// not for in-adapter overriding. OAuth-personal is the typical auth
|
|
248
248
|
// path (settings.json `security.auth.selectedType: 'oauth-personal'`).
|
|
249
249
|
env: {},
|
|
250
|
+
// Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
|
|
251
|
+
// the launching command is exactly the binary name. See claude.js for the
|
|
252
|
+
// full rationale + fallback semantics.
|
|
253
|
+
shellWrap: false,
|
|
250
254
|
},
|
|
251
255
|
patterns: {
|
|
252
256
|
prompt: PROMPT,
|
|
@@ -446,6 +446,10 @@ const grokAdapter = {
|
|
|
446
446
|
env: {
|
|
447
447
|
GROK_MODEL: chooseModel(),
|
|
448
448
|
},
|
|
449
|
+
// Sprint 64 T2 (carve-out 2.4) — direct spawn (no `zsh -c` wrapper) when
|
|
450
|
+
// the launching command is exactly the binary name. See claude.js for the
|
|
451
|
+
// full rationale + fallback semantics.
|
|
452
|
+
shellWrap: false,
|
|
449
453
|
},
|
|
450
454
|
patterns: {
|
|
451
455
|
prompt: PROMPT,
|