@logicseek/hello-cc 1.0.2 → 1.0.4

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 (40) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/docs/dsh.md +3 -3
  3. package/docs/dsh.zh-CN.md +3 -3
  4. package/lib/cli/app.mjs +1 -1
  5. package/lib/cli/commands/hook.mjs +3 -1
  6. package/lib/cli/commands/lifecycle.mjs +14 -3
  7. package/lib/cli/commands/query.mjs +15 -10
  8. package/lib/cli/commands/run.mjs +7 -6
  9. package/lib/db/connection.mjs +82 -9
  10. package/lib/discover.mjs +4 -4
  11. package/lib/integrations/hooks.mjs +132 -40
  12. package/lib/integrations/peers/identity.mjs +42 -7
  13. package/lib/process/identity.mjs +36 -15
  14. package/lib/runtime/client.mjs +9 -1
  15. package/lib/runtime/native/service.mjs +107 -24
  16. package/lib/runtime/native/store.mjs +63 -6
  17. package/lib/runtime/project-path.mjs +90 -7
  18. package/lib/shared/file-lock.mjs +62 -17
  19. package/lib/shared/socket-lock-worker.mjs +15 -6
  20. package/lib/web/codex-app-server.mjs +19 -1
  21. package/lib/web/codex-sessions.mjs +2 -0
  22. package/lib/web/cookie-auth.mjs +1 -1
  23. package/lib/web/http-routes.mjs +11 -7
  24. package/lib/web/http.mjs +20 -2
  25. package/lib/web/native-sessions.mjs +13 -6
  26. package/lib/web/peer-actions.mjs +27 -27
  27. package/lib/web/runtime-main.mjs +24 -7
  28. package/lib/web/runtime.mjs +18 -4
  29. package/lib/web/session-serialize.mjs +1 -1
  30. package/lib/web/startup.mjs +84 -10
  31. package/lib/web/task-results.mjs +8 -5
  32. package/lib/web/terminal-assets.mjs +18 -0
  33. package/lib/web/tmux-client-snapshot.mjs +85 -0
  34. package/lib/web/tmux-stream.mjs +3 -4
  35. package/lib/web/ui-codex.mjs +137 -20
  36. package/lib/web/ui-interactions.mjs +37 -3
  37. package/lib/web/ui-native.mjs +21 -9
  38. package/lib/web/ui-template.mjs +8 -2
  39. package/package.json +1 -1
  40. package/scripts/regression.mjs +212 -47
@@ -11,6 +11,8 @@ import { commandPaletteCss, commandPaletteHtml, commandPaletteScript } from './u
11
11
  import { terminalFindCss, terminalFindHtml, terminalFindScript } from './ui-terminal-find.mjs';
12
12
  import { workspaceCss, workspaceBarHtml, workspaceSecondaryHtml, workspaceScript } from './ui-workspace.mjs';
13
13
 
14
+ const SHELL_APOSTROPHE_ESCAPE = JSON.stringify(`'"'"'`);
15
+
14
16
  function nonceAttribute(nonce) {
15
17
  const value = String(nonce || '');
16
18
  if (!/^[A-Za-z0-9_-]{16,}$/.test(value)) {
@@ -2130,11 +2132,15 @@ export function webIndexHtml({ nonce, pane = false } = {}) {
2130
2132
  };
2131
2133
  }
2132
2134
 
2135
+ function terminalShellQuote(value) {
2136
+ return "'" + String(value).split("'").join(${SHELL_APOSTROPHE_ESCAPE}) + "'";
2137
+ }
2138
+
2133
2139
  function terminalCommandForAction(action, info) {
2134
2140
  const session = info.session || {};
2135
- const peerId = info.peerId;
2141
+ const peerId = terminalShellQuote(info.peerId);
2136
2142
  const lines = {
2137
- register: \`hcc register --peer \${peerId} --kind \${session.kind || 'other'} --role \${session.role || 'peer'}\`,
2143
+ register: \`hcc register --peer \${peerId} --kind \${terminalShellQuote(session.kind || 'other')} --role \${terminalShellQuote(session.role || 'peer')}\`,
2138
2144
  inbox: \`hcc msg inbox --peer \${peerId}\`,
2139
2145
  'task-next': \`hcc task next --peer \${peerId}\`,
2140
2146
  state: \`hcc state --peer \${peerId}\`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,12 +8,14 @@ import path from 'node:path';
8
8
  import process from 'node:process';
9
9
  import { spawn, spawnSync } from 'node:child_process';
10
10
  import crypto from 'node:crypto';
11
+ import { performance } from 'node:perf_hooks';
11
12
  import { setTimeout as sleep } from 'node:timers/promises';
12
13
  import { DatabaseSync } from 'node:sqlite';
13
14
  import { Readable } from 'node:stream';
14
15
  import { pathToFileURL } from 'node:url';
15
16
  import WebSocket from 'ws';
16
- import { inspectProcessIdentity } from '../lib/process/identity.mjs';
17
+ import { inspectProcessIdentity, compareProcessIdentity } from '../lib/process/identity.mjs';
18
+ import { redactSecrets } from '../lib/shared/redact.mjs';
17
19
  import { applyBufferPlan, planBufferFiles } from '../lib/runtime/buffer-gc.mjs';
18
20
 
19
21
  const repoRoot = path.resolve(import.meta.dirname, '..');
@@ -29,14 +31,16 @@ const secondProjectRoot = path.join(root, 'second-project');
29
31
  const TERMINAL_WEBSOCKET_TIMEOUT_MS = 15_000;
30
32
  const realHome = process.env.HOME || os.homedir();
31
33
  const realRegistryFile = path.join(realHome, '.hello-cc', 'projects.json');
32
- const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
33
- encoding: 'utf8',
34
- stdio: ['ignore', 'pipe', 'ignore']
35
- }).stdout.trim();
34
+ let realTmuxBin = '';
36
35
  const port = 22000 + (process.pid % 10000);
37
36
 
38
37
  let tmuxStarted = false;
39
38
  let runtimePid = null;
39
+ let runtimeOwner = null;
40
+ let currentStage = 'startup';
41
+ let lastStopDiagnostic = null;
42
+ const diagnosticSecrets = new Set();
43
+ const fixtureFailures = new WeakSet();
40
44
  const managedTmuxSessions = new Set();
41
45
 
42
46
  const env = {
@@ -53,18 +57,153 @@ delete env.TMUX;
53
57
  delete env.TMUX_PANE;
54
58
 
55
59
  function log(message) {
60
+ currentStage = String(message);
56
61
  process.stdout.write(`${message}\n`);
57
62
  }
58
63
 
59
64
  function fail(message) {
65
+ const error = fixtureFailureError(message);
60
66
  if (process.env.GITHUB_ACTIONS === 'true') {
61
- const escaped = String(message)
67
+ const escaped = fixtureDiagnosticText({ stage: currentStage, failure: fixtureFailureDiagnostic(error) }, diagnosticSecrets)
62
68
  .replace(/%/g, '%25')
63
69
  .replace(/\r/g, '%0D')
64
70
  .replace(/\n/g, '%0A');
65
71
  process.stderr.write(`::error::${escaped}\n`);
66
72
  }
67
- throw new Error(message);
73
+ throw error;
74
+ }
75
+
76
+ export function fixtureFailureError(message) {
77
+ const error = new Error(String(message));
78
+ Object.defineProperty(error, 'fixtureStack', {
79
+ value: Object.freeze(new Error('fixture failure').stack.split('\n').slice(1, 7))
80
+ });
81
+ fixtureFailures.add(error);
82
+ return error;
83
+ }
84
+
85
+ export function fixtureFailureDiagnostic(error) {
86
+ const names = ['Error', 'TypeError', 'RangeError', 'SyntaxError', 'AssertionError', 'AbortError'];
87
+ return {
88
+ name: names.includes(error?.name) ? error.name : 'Error',
89
+ stack: fixtureFailures.has(error)
90
+ ? error.fixtureStack
91
+ : new Error('regression failure handler').stack.split('\n').slice(1, 7)
92
+ };
93
+ }
94
+
95
+ export function fixtureDiagnosticText(value, secrets = []) {
96
+ function scrub(text) {
97
+ for (const secret of secrets) {
98
+ if (typeof secret === 'string' && secret.length >= 8) text = text.split(secret).join('[REDACTED]');
99
+ }
100
+ return redactSecrets(text)
101
+ .replace(/("(?:[^"\\]*token|authorization|cookie|[^"\\]*secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, '$1"[REDACTED]"');
102
+ }
103
+ function visit(entry) {
104
+ if (typeof entry === 'string') return scrub(entry);
105
+ if (Array.isArray(entry)) return entry.map(visit);
106
+ if (entry && typeof entry === 'object') return Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, visit(item)]));
107
+ return entry;
108
+ }
109
+ return typeof value === 'string' ? scrub(value) : JSON.stringify(visit(redactSecrets(value)));
110
+ }
111
+
112
+ function fixturePsState(pid) {
113
+ const result = spawnSync('ps', ['-p', String(pid), '-o', 'stat='], {
114
+ encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore']
115
+ });
116
+ const state = String(result.stdout || '').trim();
117
+ return result.status === 0 && /^[A-Za-z+<>NslLWE-]{1,16}$/.test(state) ? state : null;
118
+ }
119
+
120
+ export function observeFixtureOwner(owner, { inspect = inspectProcessIdentity, psState = fixturePsState } = {}) {
121
+ if (compareProcessIdentity(owner, owner) !== 'live') return { state: 'unknown', reason: 'missing_owner_identity' };
122
+ const observed = inspect(owner.pid);
123
+ const state = psState(owner.pid);
124
+ const details = { pid: owner.pid, psState: state, observedState: observed.state };
125
+ if (observed.state === 'dead') return { ...details, state: 'exited', reason: 'process_dead' };
126
+ if (/^[ZX]/i.test(state || '')) return { ...details, state: 'exited', reason: 'zombie_or_dead' };
127
+ if (observed.state !== 'live' || compareProcessIdentity(observed.identity, observed.identity) !== 'live') {
128
+ return { ...details, state: 'unknown', reason: 'identity_unavailable' };
129
+ }
130
+ details.birthHash = crypto.createHash('sha256').update(observed.identity.startToken).digest('hex');
131
+ details.commandHash = observed.identity.commandHash;
132
+ if (compareProcessIdentity(owner, observed.identity) === 'dead') return { ...details, state: 'exited', reason: 'pid_reused' };
133
+ if (owner.commandHash !== observed.identity.commandHash) return { ...details, state: 'unknown', reason: 'command_changed' };
134
+ return { ...details, state: 'same-owner', reason: 'identity_match' };
135
+ }
136
+
137
+ export function signalFixtureOwner(owner, signal, { observe = observeFixtureOwner, send = process.kill.bind(process) } = {}) {
138
+ const observation = observe(owner);
139
+ if (observation.state !== 'same-owner') return { signal, sent: false, observation };
140
+ try { send(owner.pid, signal); return { signal, sent: true, observation }; }
141
+ catch (error) { return { signal, sent: false, observation, code: error.code || 'SIGNAL_FAILED' }; }
142
+ }
143
+
144
+ export async function waitForFixtureOwnerExit(owner, timeoutMs, {
145
+ observe = observeFixtureOwner, monotonicNow = () => performance.now(), wait = sleep
146
+ } = {}) {
147
+ const deadline = monotonicNow() + timeoutMs;
148
+ for (;;) {
149
+ const observation = observe(owner);
150
+ if (observation.state === 'exited') return observation;
151
+ const remaining = deadline - monotonicNow();
152
+ if (remaining <= 0) {
153
+ const error = new Error('runtime fixture owner did not exit before deadline');
154
+ error.observation = observation;
155
+ throw error;
156
+ }
157
+ await wait(Math.min(100, remaining));
158
+ }
159
+ }
160
+
161
+ export function fixtureDownResult(result) {
162
+ let payload = null;
163
+ try { payload = JSON.parse(result.stdout || ''); } catch {}
164
+ const extra = payload?.error?.extra || payload?.error || {};
165
+ const code = payload?.error?.code || result.error?.code;
166
+ const numeric = ['pid', 'elapsedMs', 'timeoutMs'];
167
+ return {
168
+ status: Number.isSafeInteger(result.status) ? result.status : null,
169
+ signal: /^SIG[A-Z0-9]{1,12}$/.test(result.signal || '') ? result.signal : null,
170
+ code: typeof code === 'string' && /^[A-Z][A-Z0-9_]{0,79}$/.test(code) ? code : null,
171
+ extra: Object.fromEntries(numeric.filter(key => typeof extra[key] === 'number' &&
172
+ Number.isFinite(extra[key]) && extra[key] >= 0).map(key => [key, extra[key]]))
173
+ };
174
+ }
175
+
176
+ export function fixtureStopEnvironment(base) {
177
+ const local = { ...base, HCC_RUNTIME_LOCAL_ONLY: '1' };
178
+ delete local.HCC_RUNTIME_URL;
179
+ delete local.HCC_RUNTIME_TOKEN;
180
+ return local;
181
+ }
182
+
183
+ function rememberRuntimePid(pid) {
184
+ runtimePid = pid;
185
+ runtimeOwner = null;
186
+ try {
187
+ const pointer = currentRuntime();
188
+ if (pointer.token) {
189
+ diagnosticSecrets.add(pointer.token);
190
+ for (const part of pointer.token.split(/\s+/)) if (part.length >= 8) diagnosticSecrets.add(part);
191
+ }
192
+ if (pointer.process_identity?.pid === pid && compareProcessIdentity(pointer.process_identity, pointer.process_identity) === 'live') {
193
+ runtimeOwner = { ...pointer.process_identity };
194
+ }
195
+ } catch {}
196
+ }
197
+
198
+ function collectRuntimeDiagnostic(extra = {}) {
199
+ const diagnostic = { stage: currentStage, pid: runtimePid,
200
+ owner: observeFixtureOwner(runtimeOwner), pointerExists: fs.existsSync(path.join(root, '.hello-cc', 'runtime.json')), ...extra };
201
+ try {
202
+ const db = new DatabaseSync(path.join(root, '.hello-cc', 'mesh.db'), { readOnly: true, timeout: 200 });
203
+ try { diagnostic.events = db.prepare('SELECT id, type FROM events ORDER BY id DESC LIMIT 12').all(); }
204
+ finally { db.close(); }
205
+ } catch {}
206
+ return fixtureDiagnosticText(diagnostic, diagnosticSecrets);
68
207
  }
69
208
 
70
209
  function commandText(command, args) {
@@ -1587,15 +1726,7 @@ function assertHtmlCsp(response, html, label) {
1587
1726
  }
1588
1727
 
1589
1728
  function processIsAlive(pid) {
1590
- try {
1591
- process.kill(pid, 0);
1592
- // Linux keeps an exited child addressable until PID 1 reaps its zombie.
1593
- // Product liveness treats that state as dead, and the regression wait must
1594
- // use the same semantic boundary instead of waiting forever on kill(0).
1595
- return process.platform !== 'linux' || inspectProcessIdentity(pid).state !== 'dead';
1596
- } catch {
1597
- return false;
1598
- }
1729
+ return inspectProcessIdentity(pid).state !== 'dead' && !/^[ZX]/i.test(fixturePsState(pid) || '');
1599
1730
  }
1600
1731
 
1601
1732
  async function waitForProcessExit(pid, label, timeoutMs = 5000) {
@@ -2546,7 +2677,7 @@ function startRuntime(options = {}) {
2546
2677
  const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance'], { env: runtimeEnv });
2547
2678
  const match = output.match(/^pid:\s*(\d+)/m);
2548
2679
  if (!match) fail(`hcc web did not print background pid:\n${output}`);
2549
- runtimePid = Number.parseInt(match[1], 10);
2680
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2550
2681
  if (!output.includes('web started in background')) fail(`hcc web did not report background start:\n${output}`);
2551
2682
  }
2552
2683
 
@@ -2644,7 +2775,7 @@ async function webSecretRedactionWorkflow() {
2644
2775
  ]);
2645
2776
  const match = output.match(/^pid:\s*(\d+)/m);
2646
2777
  if (!match) fail(`redaction web did not print background pid`);
2647
- runtimePid = Number.parseInt(match[1], 10);
2778
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2648
2779
 
2649
2780
  try {
2650
2781
  await waitRuntime();
@@ -2738,6 +2869,7 @@ async function assertWebWrapperParentSurvives() {
2738
2869
  const wrapperRoot = fs.mkdtempSync(path.join(os.tmpdir(), `hcc-reg-wrapper-root-${testId}-`));
2739
2870
  const wrapperPort = port + 101;
2740
2871
  let wrapperPid = null;
2872
+ let wrapperOwner = null;
2741
2873
  try {
2742
2874
  const script = [
2743
2875
  'set -e',
@@ -2752,14 +2884,15 @@ async function assertWebWrapperParentSurvives() {
2752
2884
  if (!match) fail(`wrapper hcc web did not print background pid:\n${result.stdout}`);
2753
2885
  wrapperPid = Number.parseInt(match[1], 10);
2754
2886
  ensureFile(path.join(wrapperRoot, '.hello-cc', 'runtime.json'));
2887
+ wrapperOwner = JSON.parse(fs.readFileSync(path.join(wrapperRoot, '.hello-cc', 'runtime.json'), 'utf8')).process_identity;
2755
2888
  } finally {
2756
- runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env });
2889
+ runMaybe(process.execPath, [hccBin, '--root', wrapperRoot, 'down'], { env: fixtureStopEnvironment(env) });
2757
2890
  if (wrapperPid) {
2758
2891
  try {
2759
- await waitForProcessExit(wrapperPid, 'wrapper runtime process exit', 5000);
2892
+ await waitForFixtureOwnerExit(wrapperOwner, 5000);
2760
2893
  } catch {
2761
- try { process.kill(wrapperPid, 'SIGTERM'); } catch {}
2762
- try { await waitForProcessExit(wrapperPid, 'wrapper runtime process exit after SIGTERM', 2000); } catch {}
2894
+ signalFixtureOwner(wrapperOwner, 'SIGTERM');
2895
+ try { await waitForFixtureOwnerExit(wrapperOwner, 2000); } catch {}
2763
2896
  }
2764
2897
  }
2765
2898
  try { fs.rmSync(wrapperRoot, { recursive: true, force: true }); } catch {}
@@ -2768,28 +2901,42 @@ async function assertWebWrapperParentSurvives() {
2768
2901
 
2769
2902
  async function stopRuntime() {
2770
2903
  if (!runtimePid) return;
2771
- const pid = runtimePid;
2772
- hccMaybe(['down']);
2904
+ const owner = runtimeOwner;
2905
+ const before = observeFixtureOwner(owner);
2906
+ const down = fixtureDownResult(stopFixtureCli());
2907
+ lastStopDiagnostic = { before, down, stack: new Error('stopRuntime').stack.split('\n').slice(1, 7), signals: [] };
2773
2908
  try {
2774
- await waitForProcessExit(pid, 'runtime process exit', 5000);
2909
+ await waitForFixtureOwnerExit(owner, 5000);
2775
2910
  } catch (err) {
2776
- try { process.kill(pid, 'SIGTERM'); } catch {}
2911
+ lastStopDiagnostic.atDeadline = collectRuntimeDiagnostic({ before, down });
2912
+ lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGTERM'));
2777
2913
  try {
2778
- await waitForProcessExit(pid, 'runtime process exit after SIGTERM', 2000);
2914
+ await waitForFixtureOwnerExit(owner, 2000);
2779
2915
  } catch {
2780
- try { process.kill(pid, 'SIGKILL'); } catch {}
2781
- try { await waitForProcessExit(pid, 'runtime process exit after SIGKILL', 2000); } catch {}
2782
- throw err;
2916
+ lastStopDiagnostic.signals.push(signalFixtureOwner(owner, 'SIGKILL'));
2917
+ try { await waitForFixtureOwnerExit(owner, 2000); } catch {}
2783
2918
  }
2919
+ fail(`runtime stop failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
2784
2920
  }
2921
+ if (down.status !== 0) fail(`runtime down command failed: ${collectRuntimeDiagnostic(lastStopDiagnostic)}`);
2785
2922
  runtimePid = null;
2923
+ runtimeOwner = null;
2924
+ }
2925
+
2926
+ function stopFixtureCli() {
2927
+ try {
2928
+ const pointer = currentRuntime();
2929
+ if (compareProcessIdentity(runtimeOwner, pointer.process_identity) === 'live' &&
2930
+ runtimeOwner.commandHash === pointer.process_identity.commandHash) {
2931
+ return hccMaybe(['--json', 'down'], { env: fixtureStopEnvironment(env) });
2932
+ }
2933
+ } catch {}
2934
+ return { status: null, error: { code: 'FIXTURE_OWNER_POINTER_UNAVAILABLE' } };
2786
2935
  }
2787
2936
 
2788
2937
  function cleanup() {
2789
- try { hccMaybe(['down']); } catch {}
2790
- if (runtimePid) {
2791
- try { process.kill(runtimePid, 'SIGTERM'); } catch {}
2792
- }
2938
+ try { stopFixtureCli(); } catch {}
2939
+ if (runtimePid) signalFixtureOwner(runtimeOwner, 'SIGTERM');
2793
2940
  const managedPrefixes = [
2794
2941
  tmuxManagedSessionPrefix(root),
2795
2942
  tmuxManagedSessionPrefix(secondProjectRoot)
@@ -2877,7 +3024,7 @@ async function setupRegression() {
2877
3024
  const output = hcc(['web', '--local', '--port', String(port), '--no-discover', '--no-guidance']);
2878
3025
  const match = output.match(/^pid:\s*(\d+)/m);
2879
3026
  if (!match) fail(`hcc web did not print background pid during bootstrap:\n${output}`);
2880
- runtimePid = Number.parseInt(match[1], 10);
3027
+ rememberRuntimePid(Number.parseInt(match[1], 10));
2881
3028
  await waitRuntime();
2882
3029
  for (const file of liveBufferFiles) ensureFile(file);
2883
3030
  for (const file of liveBufferFiles) fs.rmSync(file, { force: true });
@@ -2957,7 +3104,7 @@ async function setupRegression() {
2957
3104
  const directTlsOutput = hcc(['web', '--local', '--tls', '--port', String(port), '--no-discover', '--no-guidance']);
2958
3105
  const directTlsMatch = directTlsOutput.match(/^pid:\s*(\d+)/m);
2959
3106
  if (!directTlsMatch) fail(`direct TLS web did not print background pid:\n${directTlsOutput}`);
2960
- runtimePid = Number.parseInt(directTlsMatch[1], 10);
3107
+ rememberRuntimePid(Number.parseInt(directTlsMatch[1], 10));
2961
3108
  await waitFor(async () => {
2962
3109
  const runtimeFile = path.join(root, '.hello-cc', 'runtime.json');
2963
3110
  if (!fs.existsSync(runtimeFile)) return false;
@@ -3016,7 +3163,7 @@ async function setupRegression() {
3016
3163
  const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3017
3164
  const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
3018
3165
  if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
3019
- runtimePid = Number.parseInt(tokenMatch[1], 10);
3166
+ rememberRuntimePid(Number.parseInt(tokenMatch[1], 10));
3020
3167
  if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
3021
3168
  fail(`default web output did not include remote token URL:\n${tokenOutput}`);
3022
3169
  }
@@ -3200,7 +3347,7 @@ async function setupRegression() {
3200
3347
  ]);
3201
3348
  const proxyPidMatch = proxyOutput.match(/^pid:\s*(\d+)/m);
3202
3349
  if (!proxyPidMatch) fail(`trusted-proxy web did not print background pid:\n${proxyOutput}`);
3203
- runtimePid = Number.parseInt(proxyPidMatch[1], 10);
3350
+ rememberRuntimePid(Number.parseInt(proxyPidMatch[1], 10));
3204
3351
  await waitRuntime();
3205
3352
  const proxyRuntime = currentRuntime();
3206
3353
  if (proxyRuntime.proxy_origin !== proxyOrigin || proxyRuntime.trust_proxy !== true) {
@@ -3286,7 +3433,7 @@ async function setupRegression() {
3286
3433
  ]);
3287
3434
  const restoredPidMatch = restoredOutput.match(/^pid:\s*(\d+)/m);
3288
3435
  if (!restoredPidMatch) fail(`post-proxy web did not print background pid:\n${restoredOutput}`);
3289
- runtimePid = Number.parseInt(restoredPidMatch[1], 10);
3436
+ rememberRuntimePid(Number.parseInt(restoredPidMatch[1], 10));
3290
3437
  await waitRuntime();
3291
3438
 
3292
3439
  const badJsonResponse = await runtimeFetch('/api/projects', {
@@ -3313,7 +3460,7 @@ async function setupRegression() {
3313
3460
  const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3314
3461
  const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
3315
3462
  if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
3316
- runtimePid = Number.parseInt(stableTokenMatch[1], 10);
3463
+ rememberRuntimePid(Number.parseInt(stableTokenMatch[1], 10));
3317
3464
  await waitRuntime();
3318
3465
  const stableTokenRuntime = currentRuntime();
3319
3466
  if (stableTokenRuntime.token === tokenRuntime.token) {
@@ -3327,7 +3474,7 @@ async function setupRegression() {
3327
3474
  });
3328
3475
  const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
3329
3476
  if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
3330
- runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
3477
+ rememberRuntimePid(Number.parseInt(fixedTokenMatch[1], 10));
3331
3478
  await waitRuntime();
3332
3479
  const fixedTokenRuntime = currentRuntime();
3333
3480
  if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
@@ -3338,7 +3485,7 @@ async function setupRegression() {
3338
3485
  const postFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
3339
3486
  const postFixedMatch = postFixedOutput.match(/^pid:\s*(\d+)/m);
3340
3487
  if (!postFixedMatch) fail(`post-fixed-token web did not print background pid:\n${postFixedOutput}`);
3341
- runtimePid = Number.parseInt(postFixedMatch[1], 10);
3488
+ rememberRuntimePid(Number.parseInt(postFixedMatch[1], 10));
3342
3489
  await waitRuntime();
3343
3490
  const postFixedRuntime = currentRuntime();
3344
3491
  if (postFixedRuntime.token === fixedToken || fs.existsSync(tokenFile)) {
@@ -3351,7 +3498,7 @@ async function setupRegression() {
3351
3498
  const noTokenOutput = hcc(['web', '--local', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
3352
3499
  const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
3353
3500
  if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
3354
- runtimePid = Number.parseInt(noTokenMatch[1], 10);
3501
+ rememberRuntimePid(Number.parseInt(noTokenMatch[1], 10));
3355
3502
  if (noTokenOutput.includes('token=')) {
3356
3503
  fail(`explicit no-token web output included token:\n${noTokenOutput}`);
3357
3504
  }
@@ -4420,7 +4567,14 @@ async function multiProjectWebWorkflow() {
4420
4567
 
4421
4568
  const htmlResponse = await fetch(currentRuntimeUrl('/'));
4422
4569
  const html = await htmlResponse.text();
4423
- for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
4570
+ const startForm = html.match(/<form\b[^>]*\bid="startForm"[^>]*>[\s\S]*?<\/form>/)?.[0];
4571
+ if (!startForm) fail('web form missing startForm');
4572
+ // Approval details may display the working directory. The simplified launch
4573
+ // form must still derive it from the selected project instead of an input.
4574
+ if (startForm.includes('Working directory') || /<input\b[^>]*(?:id|name)="(?:cwd|workdir)"/.test(startForm)) {
4575
+ fail('web launch form still exposes Working directory');
4576
+ }
4577
+ for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'commandbar', 'lineInput', 'Send text to active terminal']) {
4424
4578
  if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
4425
4579
  }
4426
4580
  for (const expected of [
@@ -11151,11 +11305,16 @@ function gcCoverageWorkflow() {
11151
11305
  }
11152
11306
 
11153
11307
  async function main() {
11308
+ realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
11309
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']
11310
+ }).stdout.trim();
11154
11311
  process.once('SIGINT', () => { cleanup(); process.exit(130); });
11155
11312
  process.once('SIGTERM', () => { cleanup(); process.exit(143); });
11156
11313
 
11157
11314
  await setupRegression();
11315
+ currentStage = 'webSecretRedactionWorkflow';
11158
11316
  await webSecretRedactionWorkflow();
11317
+ currentStage = 'cookieSessionExpiryWorkflow';
11159
11318
  await cookieSessionExpiryWorkflow();
11160
11319
  log('[2/13] runtime');
11161
11320
  startRuntime();
@@ -11187,12 +11346,18 @@ async function main() {
11187
11346
  log('FULL_REGRESSION_OK');
11188
11347
  }
11189
11348
 
11190
- main().catch((err) => {
11349
+ if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) main().catch((err) => {
11350
+ const failure = fixtureFailureDiagnostic(err);
11351
+ const diagnostic = collectRuntimeDiagnostic({ failure, stop: lastStopDiagnostic });
11352
+ process.stderr.write(`[regression failure diagnostic] ${diagnostic}\n`);
11353
+ if (process.env.GITHUB_ACTIONS === 'true') {
11354
+ process.stderr.write(`::error::${diagnostic.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')}\n`);
11355
+ }
11191
11356
  try { assertNoRealProjectRegistryLeak(); } catch (leakErr) {
11192
- process.stderr.write(`${leakErr.stack || leakErr.message}\n`);
11357
+ process.stderr.write(`${fixtureDiagnosticText(leakErr.stack || leakErr.message, diagnosticSecrets)}\n`);
11193
11358
  }
11194
11359
  cleanup();
11195
- process.stderr.write(`${err.stack || err.message}\n`);
11360
+ process.stderr.write(`${fixtureDiagnosticText(err.stack || err.message, diagnosticSecrets)}\n`);
11196
11361
  process.exit(1);
11197
11362
  }).finally(() => {
11198
11363
  try {