@logicseek/hello-cc 1.1.0-rc.8 → 1.1.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/docs/README.md +2 -0
  5. package/docs/README.zh-CN.md +2 -0
  6. package/docs/commands.md +16 -0
  7. package/docs/commands.zh-CN.md +14 -0
  8. package/docs/dsh.md +6 -0
  9. package/docs/dsh.zh-CN.md +5 -0
  10. package/docs/native.md +21 -1
  11. package/docs/native.zh-CN.md +19 -1
  12. package/docs/private-state.md +115 -0
  13. package/docs/private-state.zh-CN.md +92 -0
  14. package/lib/cli/app.mjs +46 -5
  15. package/lib/cli/commands/hook.mjs +31 -4
  16. package/lib/cli/commands/mcp.mjs +6 -0
  17. package/lib/cli/commands/native.mjs +17 -4
  18. package/lib/cli/commands/peer.mjs +3 -0
  19. package/lib/cli/commands/run.mjs +196 -62
  20. package/lib/cli/commands/tmux.mjs +28 -19
  21. package/lib/cli/commands/up.mjs +25 -17
  22. package/lib/cli-runtime.mjs +16 -1
  23. package/lib/core/coordination/event-retention.mjs +2 -0
  24. package/lib/core/sessions/codex-thread-root.mjs +144 -0
  25. package/lib/core/sessions/launch.mjs +4 -0
  26. package/lib/discover.mjs +95 -231
  27. package/lib/integrations/dsh-collaboration.mjs +9 -2
  28. package/lib/integrations/native/claude.mjs +6 -0
  29. package/lib/integrations/native/codex.mjs +4 -2
  30. package/lib/integrations/native/dsh-acp.mjs +1 -1
  31. package/lib/integrations/native/jsonrpc.mjs +2 -2
  32. package/lib/mcp/scope.mjs +50 -8
  33. package/lib/mcp/tools.mjs +15 -2
  34. package/lib/process/pinned-cwd.mjs +64 -0
  35. package/lib/process/pinned-guidance.mjs +1 -0
  36. package/lib/process/pinned-project-state.mjs +1 -0
  37. package/lib/process/tmux-cwd-handoff.mjs +277 -0
  38. package/lib/runtime/native/cli-submissions.mjs +220 -0
  39. package/lib/runtime/native/client.mjs +7 -1
  40. package/lib/runtime/native/launcher.mjs +20 -4
  41. package/lib/runtime/native/service.mjs +143 -50
  42. package/lib/runtime/native/store.mjs +303 -14
  43. package/lib/runtime/private-state.mjs +435 -24
  44. package/lib/runtime/project-path.mjs +53 -1
  45. package/lib/runtime/project-trust.mjs +6 -2
  46. package/lib/runtime/state-migration.mjs +66 -13
  47. package/lib/shared/redact.mjs +4 -1
  48. package/lib/terminal/tmux.mjs +13 -2
  49. package/lib/ui/help.mjs +1 -0
  50. package/lib/web/codex-app-server.mjs +3 -1
  51. package/lib/web/codex-sessions.mjs +68 -8
  52. package/lib/web/context-files.mjs +194 -41
  53. package/lib/web/http-routes.mjs +43 -12
  54. package/lib/web/native-sessions.mjs +3 -0
  55. package/lib/web/project-contexts.mjs +27 -5
  56. package/lib/web/project-files.mjs +108 -26
  57. package/lib/web/pty-sessions.mjs +15 -2
  58. package/lib/web/session-serialize.mjs +6 -1
  59. package/lib/web/startup.mjs +3 -1
  60. package/lib/web/tmux-sessions.mjs +153 -50
  61. package/lib/web/ui-interactions.mjs +7 -4
  62. package/native/bin/darwin-arm64/hcc-cwd-handoff +0 -0
  63. package/native/bin/darwin-x64/hcc-cwd-handoff +0 -0
  64. package/native/bin/linux-arm64/hcc-cwd-handoff +0 -0
  65. package/native/bin/linux-x64/hcc-cwd-handoff +0 -0
  66. package/native/cwd-handoff/README.md +45 -0
  67. package/native/cwd-handoff/hcc-cwd-handoff.c +693 -0
  68. package/package.json +8 -2
  69. package/scripts/build-cwd-handoff.mjs +47 -0
  70. package/scripts/pack-cwd-handoff.mjs +27 -0
  71. package/scripts/regression.mjs +113 -7
  72. package/scripts/verify-cwd-handoff-package.mjs +60 -0
  73. package/scripts/web-workbench-acceptance.mjs +79 -1
  74. package/scripts/web-workbench-installed-acceptance.mjs +11 -1
@@ -1,9 +1,13 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { createHash } from 'node:crypto';
4
+ import { execFile } from 'node:child_process';
4
5
  import path from 'node:path';
5
- import { TextDecoder } from 'node:util';
6
+ import process from 'node:process';
7
+ import { promisify, TextDecoder } from 'node:util';
6
8
  import { CliError } from '../shared/errors.mjs';
9
+ import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
10
+ import { captureSelectedCwdIdentity } from '../process/selected-cwd-identity.mjs';
7
11
 
8
12
  export const PROJECT_FILE_LIMITS = Object.freeze({ entries: 500, scannedEntries: 5000,
9
13
  textBytes: 1024 * 1024, mediaBytes: 10 * 1024 * 1024, sniffBytes: 4096 });
@@ -31,10 +35,11 @@ const errorStatus = new Map([['PROJECT_FILE_BAD_PATH', 400], ['PROJECT_FILE_FORB
31
35
 
32
36
  export function projectFileErrorStatus(error) { return errorStatus.get(error?.code); }
33
37
 
34
- function privateComponent(name) {
38
+ export function privateComponent(name) {
35
39
  const lower = name.toLowerCase();
36
- return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[.-]|$)/.test(lower) ||
37
- /\.env(?:[.-]|$)/.test(lower) ||
40
+ return ignored.has(lower) || privateNames.has(lower) || /^\.hcc-file-write-/.test(lower) || /^(?:\.?env)(?:[._-]|$)/.test(lower) ||
41
+ /\.env(?:[._-]|$)/.test(lower) ||
42
+ /^(?:auth|accounts)(?:[._-](?:backup|bak|old|orig|copy))?\.(?:json|jsonl)(?:[._-](?:backup|bak|old|orig|copy))?$/.test(lower) ||
38
43
  /(?:^|[._-])(?:secrets?|credentials?|tokens?)(?:[._-]|$)/.test(lower) ||
39
44
  /^(?:id_(?:rsa|dsa|ecdsa|ed25519)|terraform\.tfstate)(?:[.-]|$)/.test(lower) ||
40
45
  /^service[-_]account(?:[._-]|$)/.test(lower) || /\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ovpn)(?:[.-]|$)/.test(lower);
@@ -93,6 +98,86 @@ function publicError(error) {
93
98
  return new CliError('PROJECT_FILE_UNAVAILABLE', 'The requested project path could not be read');
94
99
  }
95
100
 
101
+ const execFileAsync = promisify(execFile);
102
+
103
+ // The helper is launched with a checked directory identity and uses only
104
+ // relative names after entering that inode. It never opens a project path.
105
+ export async function runPinnedDirectoryWorker(directory, expectedIdentity, source, input) {
106
+ const binding = preparePinnedCwdLaunch(directory, process.execPath,
107
+ ['-e', source, JSON.stringify(input)], {
108
+ purpose: 'maintenance', expectedIdentity,
109
+ env: { PATH: '/usr/bin:/bin', NODE_OPTIONS: '', NODE_NO_WARNINGS: '1' }
110
+ });
111
+ try {
112
+ const { stdout } = await execFileAsync(binding.command, binding.args, {
113
+ cwd: binding.cwd, env: binding.env, timeout: 10000, maxBuffer: 4 * 1024 * 1024
114
+ });
115
+ return JSON.parse(stdout);
116
+ } catch (error) {
117
+ const stderr = String(error.stderr || '');
118
+ if (/HCC_PINNED_CWD_CHANGED:/.test(stderr)) throw changed();
119
+ const workerError = /HCC_DIRECTORY_WORKER_FAILED:([A-Z_]+)/.exec(stderr);
120
+ if (workerError?.[1] === 'PROJECT_FILE_CHANGED') throw changed();
121
+ if (workerError) { const failure = new Error(workerError[1]); failure.code = workerError[1]; throw failure; }
122
+ throw error;
123
+ } finally { binding.release(); }
124
+ }
125
+
126
+ function projectListWorker() {
127
+ const fs = require('node:fs');
128
+ const { scannedEntries } = JSON.parse(process.argv[1]);
129
+ const entries = [];
130
+ let scanned = 0, truncated = false, directory;
131
+ try {
132
+ directory = fs.opendirSync('.');
133
+ for (let item; (item = directory.readSync()) !== null;) {
134
+ if (++scanned > scannedEntries) { truncated = true; break; }
135
+ if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
136
+ if (!item.isDirectory() && !item.isFile()) continue;
137
+ let stat;
138
+ try { stat = fs.lstatSync(item.name, { bigint: true }); }
139
+ catch (error) { if (error.code === 'ENOENT') error.code = 'PROJECT_FILE_CHANGED'; throw error; }
140
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
141
+ entries.push({ name: item.name, type: stat.isDirectory() ? 'directory' : 'file',
142
+ ...(stat.isFile() ? { size: Number(stat.size) } : {}) });
143
+ }
144
+ process.stdout.write(JSON.stringify({ entries, truncated }));
145
+ } catch (error) {
146
+ process.stderr.write('HCC_DIRECTORY_WORKER_FAILED:' + (error.code || 'PROJECT_FILE_UNAVAILABLE') + '\n');
147
+ process.exitCode = 1;
148
+ } finally { directory?.closeSync(); }
149
+ }
150
+
151
+ export const PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE = `const ignored = new Set(${JSON.stringify([...ignored])});\n` +
152
+ `const privateNames = new Set(${JSON.stringify([...privateNames])});\n` +
153
+ `const privateComponent = ${privateComponent.toString()};\n`;
154
+ const PROJECT_LIST_WORKER_SOURCE = PROJECT_PRIVATE_COMPONENT_WORKER_SOURCE +
155
+ `(${projectListWorker.toString()})()`;
156
+
157
+ // Windows has no trusted pinned-cwd launch. Keep its existing read-only
158
+ // listing behavior; transient ancestor replacement remains outside this POSIX fix.
159
+ async function listProjectFilesByPath(snapshot) {
160
+ let directory;
161
+ try {
162
+ directory = await fs.opendir(snapshot.absolute);
163
+ const entries = [];
164
+ let scanned = 0, truncated = false;
165
+ for await (const item of directory) {
166
+ if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
167
+ if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
168
+ if (!item.isDirectory() && !item.isFile()) continue;
169
+ let stat;
170
+ try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
171
+ catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
172
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
173
+ entries.push({ name: item.name, type: stat.isDirectory() ? 'directory' : 'file',
174
+ ...(stat.isFile() ? { size: Number(stat.size) } : {}) });
175
+ }
176
+ directory = null;
177
+ return { entries, truncated };
178
+ } finally { await directory?.close().catch(() => {}); }
179
+ }
180
+
96
181
  async function inspectPath(root, parts, directory, rootIdentity) {
97
182
  assertBoundRoot(rootIdentity);
98
183
  const base = await fs.realpath(root), chain = [];
@@ -149,34 +234,31 @@ async function openChecked(snapshot, directory) {
149
234
 
150
235
  /** Single-level listing only. No project module or executable is loaded. */
151
236
  export async function listProjectFiles(root, relative = '', { rootIdentity } = {}) {
152
- let handle, directory;
237
+ let handle, ownedIdentity;
153
238
  try {
154
- const parts = relativePath(relative, true), snapshot = await inspectPath(root, parts, true, rootIdentity);
239
+ const parts = relativePath(relative, true);
240
+ ownedIdentity = rootIdentity ? null : captureSelectedCwdIdentity(root);
241
+ const identity = rootIdentity || ownedIdentity;
242
+ const snapshot = await inspectPath(root, parts, true, identity);
155
243
  handle = await openChecked(snapshot, true);
156
- directory = await fs.opendir(snapshot.absolute);
157
- const entries = [];
158
- let scanned = 0, truncated = false;
159
- for await (const item of directory) {
160
- if (++scanned > PROJECT_FILE_LIMITS.scannedEntries) { truncated = true; break; }
161
- if (privateComponent(item.name) || /[\\:\x00-\x1f\x7f]/.test(item.name) || item.isSymbolicLink()) continue;
162
- if (!item.isDirectory() && !item.isFile()) continue;
163
- let stat;
164
- try { stat = await fs.lstat(path.join(snapshot.absolute, item.name), { bigint: true }); }
165
- catch (error) { if (error.code === 'ENOENT') throw changed(); throw error; }
166
- if (stat.isSymbolicLink() || (!stat.isDirectory() && (!stat.isFile() || stat.nlink !== 1n))) continue;
167
- const entry = { name: item.name, path: relative ? relative + '/' + item.name : item.name,
168
- type: stat.isDirectory() ? 'directory' : 'file' };
169
- if (stat.isFile()) entry.size = Number(stat.size);
170
- entries.push(entry);
244
+ let listing;
245
+ if (process.platform === 'win32') listing = await listProjectFilesByPath(snapshot);
246
+ else {
247
+ const expectedIdentity = { canonical: snapshot.absolute, identity: {
248
+ dev: snapshot.stat.dev.toString(), ino: snapshot.stat.ino.toString(),
249
+ birthtimeNs: snapshot.stat.birthtimeNs?.toString() || null } };
250
+ listing = await runPinnedDirectoryWorker(snapshot.absolute, expectedIdentity,
251
+ PROJECT_LIST_WORKER_SOURCE, { scannedEntries: PROJECT_FILE_LIMITS.scannedEntries });
171
252
  }
172
- directory = null; // for-await closes the handle on completion and break.
253
+ const entries = listing.entries.map(item => ({ ...item,
254
+ path: relative ? relative + '/' + item.name : item.name }));
173
255
  await verifyPath(snapshot, handle);
174
- assertBoundRoot(rootIdentity, snapshot.chain[0].stat);
256
+ assertBoundRoot(identity, snapshot.chain[0].stat);
175
257
  entries.sort((a, b) => a.type === b.type ? (a.name < b.name ? -1 : a.name > b.name ? 1 : 0) : a.type === 'directory' ? -1 : 1);
176
- if (entries.length > PROJECT_FILE_LIMITS.entries) truncated = true;
177
- return { path: relative, entries: entries.slice(0, PROJECT_FILE_LIMITS.entries), truncated };
258
+ return { path: relative, entries: entries.slice(0, PROJECT_FILE_LIMITS.entries),
259
+ truncated: listing.truncated || entries.length > PROJECT_FILE_LIMITS.entries };
178
260
  } catch (error) { throw publicError(error); }
179
- finally { await directory?.close().catch(() => {}); await handle?.close().catch(() => {}); }
261
+ finally { await handle?.close().catch(() => {}); ownedIdentity?.release(); }
180
262
  }
181
263
 
182
264
  async function readBytes(handle, length) {
@@ -17,6 +17,7 @@ import { detectBranch } from '../project-context.mjs';
17
17
  import { tx } from '../db/schema.mjs';
18
18
  import { spawnPinnedCwdPty } from '../process/pinned-cwd.mjs';
19
19
  import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
20
+ import { assertCodexTerminalLaunch, CODEX_SESSION_ORIGIN_ENV } from '../core/sessions/codex-thread-root.mjs';
20
21
 
21
22
  export function createPtySessions(deps) {
22
23
  const {
@@ -60,13 +61,20 @@ export function createPtySessions(deps) {
60
61
  const expectedCwdIdentity = input.cwdIdentity ||
61
62
  (cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
62
63
  captureSelectedCwdSnapshot(cwd);
64
+ const rootDb = connectWebProject(pctx);
65
+ let codexOrigin;
66
+ try {
67
+ codexOrigin = assertCodexTerminalLaunch(rootDb, input.binding, { command, root: pctx.root,
68
+ rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity, cwd, cwdIdentity: expectedCwdIdentity });
69
+ } finally { rootDb.close(); }
63
70
  const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
64
71
  const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
65
72
  const env = childSessionEnv({
66
73
  HCC_PEER: id,
67
74
  HCC_ROOT: pctx.root,
68
75
  HCC_DB: pctx.dbPath,
69
- TERM: 'xterm-256color'
76
+ TERM: 'xterm-256color',
77
+ ...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {})
70
78
  }, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
71
79
  const size = input.size || { cols: 100, rows: 30 };
72
80
  const previousSession = sessions.get(key);
@@ -199,7 +207,12 @@ export function createPtySessions(deps) {
199
207
  if (!['claude', 'codex', 'shell'].includes(kind)) {
200
208
  throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
201
209
  }
202
- if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };
210
+ if (input.command || input.binding) {
211
+ if (kind === 'codex' && input.mode && input.mode !== 'new') {
212
+ throw new CliError('BAD_REQUEST', 'Codex history mode cannot be combined with a custom command or binding');
213
+ }
214
+ return { ...input, kind, projectCtx: pctx };
215
+ }
203
216
 
204
217
  const opts = webSessionBuildOptions(input);
205
218
  if (kind === 'shell' && hasResumeOpts(opts)) {
@@ -13,7 +13,12 @@ export function createSessionSerialize({
13
13
  localClientObservation = () => null
14
14
  }) {
15
15
  function sessionKey(projectCtx, id) {
16
- return `${projectCtx.root}\0${id}`;
16
+ const owner = projectCtx.rootIdentity || projectCtx.initialRootIdentity;
17
+ const identity = owner?.identity;
18
+ const binding = identity
19
+ ? `${owner.canonical}\0${identity.dev}\0${identity.ino}\0${identity.birthtimeNs}`
20
+ : projectCtx.root;
21
+ return `${binding}\0${projectCtx.dbPath || ''}\0${id}`;
17
22
  }
18
23
 
19
24
  function sessionIdentityMatches(session, projectCtx) {
@@ -373,6 +373,8 @@ async function startWebBackgroundLocked(ctx, args, opts) {
373
373
  const childEnv = {
374
374
  ...process.env,
375
375
  [WEB_CHILD_ENV]: '1',
376
+ HCC_PINNED_ROOT_IDENTITY: JSON.stringify({ canonical: expectedRootIdentity.canonical,
377
+ identity: expectedRootIdentity.identity }),
376
378
  HCC_ROOT: boundRoot,
377
379
  HCC_DB: boundCtx.dbPath
378
380
  };
@@ -404,7 +406,7 @@ async function waitForStartedRuntime(ctx, child, logFile) {
404
406
  const deadline = Date.now() + WEB_RUNTIME_START_TIMEOUT_MS;
405
407
  while (Date.now() < deadline) {
406
408
  const runtime = await readHealthyGlobalRuntime();
407
- if (runtime) return runtime;
409
+ if (runtime && runtime.pid === child.pid && runtime.root === ctx.root && runtime.db === ctx.dbPath) return runtime;
408
410
  if (exitInfo) {
409
411
  const detail = redactedLogText(tailFile(logFile));
410
412
  throw new CliError('RUNTIME_START_FAILED',
@@ -12,8 +12,11 @@ import { CliError } from '../shared/errors.mjs';
12
12
  import { tx } from '../db/schema.mjs';
13
13
  import { required } from '../cli-args.mjs';
14
14
  import { readClockGraceUntil } from '../shared/clock-grace.mjs';
15
- import { preparePinnedCwdLaunch } from '../process/pinned-cwd.mjs';
15
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
16
+ import { assertPinnedSessionLaunchAllowed } from '../process/pinned-cwd.mjs';
17
+ import { prepareTmuxCwdHandoff } from '../process/tmux-cwd-handoff.mjs';
16
18
  import { captureSelectedCwdSnapshot } from '../process/selected-cwd-identity.mjs';
19
+ import { assertCodexTerminalLaunch, CODEX_SESSION_ORIGIN_ENV } from '../core/sessions/codex-thread-root.mjs';
17
20
  import { peerEvidenceAllowsReap } from '../core/peers/evidence.mjs';
18
21
  import { isDshHookBinding } from '../core/peers/bindings.mjs';
19
22
  import { conditionalTmuxKill, conditionalTmuxRename, prepareTmuxRestartBinding, rollbackTmuxRestartBinding, validateTmuxDestructiveEvidence } from '../core/peers/tmux-safety.mjs';
@@ -25,8 +28,8 @@ import { adoptionPaused } from './adoption-state.mjs';
25
28
  import { ensureTmuxAvailable, runTmux, tmuxCapturePane, tmuxEnvironmentArgs, tmuxHasSession, tmuxKillSession, tmuxLaunchFingerprint, tmuxListSessionNames, tmuxManagedSessionName, tmuxManagedSessionNameMatches, tmuxPaneInfo, tmuxProviderState, tmuxSendLiteral, tmuxSessionEnvironmentValue, tmuxSessionHasClients } from '../tmux.mjs';
26
29
 
27
30
  // tmux returns after creating a pane, not after its command has entered cwd.
28
- // Keep the parent's captured directory FD alive until the bootstrap confirms
29
- // the checked chdir, or tmux definitively reports that the pane exited.
31
+ // The broker holds the captured FD until the pane confirms fchdir, or the
32
+ // bounded handoff fails without granting access to the selected directory.
30
33
  export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPaneInfo, intervalMs = 100 } = {}) {
31
34
  let timer;
32
35
  let released = false;
@@ -39,11 +42,13 @@ export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPa
39
42
  function check() {
40
43
  if (released) return true;
41
44
  if (binding.acknowledged()) { release(); return true; }
45
+ if (binding.failed?.()) { release(); return true; }
42
46
  try {
43
47
  if (paneInfo(paneTarget).dead) { release(); return true; }
44
48
  } catch {
45
49
  // Even a missing pane/server does not prove its child process exited:
46
- // a tmux crash can leave an orphan bootstrap alive. Retain the FD.
50
+ // a tmux crash can leave an orphan client alive. The broker timeout
51
+ // still bounds this pending handoff without granting an unknown PID.
47
52
  }
48
53
  return false;
49
54
  }
@@ -54,15 +59,15 @@ export function monitorPinnedTmuxLaunch(binding, paneTarget, { paneInfo = tmuxPa
54
59
  }
55
60
 
56
61
  export function runPinnedTmuxNewSession({ cwd, expectedIdentity, shell, command, env, sessionName, tmuxEnv },
57
- { run = runTmux, paneInfo = tmuxPaneInfo, bootstrapCwd } = {}) {
58
- const binding = preparePinnedCwdLaunch(cwd, shell, ['-c', command], {
59
- env, expectedIdentity, acknowledge: true, ...(bootstrapCwd ? { bootstrapCwd } : {})
60
- });
62
+ { run = runTmux, paneInfo = tmuxPaneInfo, prepare = prepareTmuxCwdHandoff, onCreated } = {}) {
63
+ const binding = prepare({ cwd, expectedIdentity, shell, command, env });
61
64
  let args;
62
65
  try {
63
- // Multiple shell-command arguments make tmux exec the command directly,
64
- // without its default shell or shell startup hooks running before pinning.
65
- args = ['new-session', '-d', '-s', sessionName, '-c', binding.cwd,
66
+ // -P atomically reports the pane's original PID. The native broker
67
+ // authorizes exactly that PID before sending the captured directory FD;
68
+ // tmux and its shell never resolve the user's selected pathname.
69
+ args = ['new-session', '-d', '-P', '-F', '#{pane_id}|#{pane_pid}|#{session_id}|#{session_created}',
70
+ '-s', sessionName, '-c', binding.cwd,
66
71
  ...tmuxEnvironmentArgs(tmuxEnv), '--',
67
72
  ...isolatedEnvCommandArgs(binding.env), binding.command, ...binding.args];
68
73
  } catch (error) {
@@ -70,14 +75,80 @@ export function runPinnedTmuxNewSession({ cwd, expectedIdentity, shell, command,
70
75
  throw error;
71
76
  }
72
77
  try {
73
- run(args);
78
+ const receipt = String(run(args) || '').trim();
79
+ const fields = receipt.split('|');
80
+ if (fields.length !== 4 || !/^%\d+$/.test(fields[0]) ||
81
+ !/^[1-9]\d*$/.test(fields[1]) || !/^\$\d+$/.test(fields[2]) ||
82
+ !/^[1-9]\d*$/.test(fields[3]) || !Number.isSafeInteger(Number(fields[1]))) {
83
+ throw new CliError('TMUX_ERROR', 'tmux did not report an unambiguous new pane identity');
84
+ }
85
+ // The caller must know there is a candidate session to roll back even if
86
+ // authorization or the subsequent bind acknowledgement fails.
87
+ onCreated?.(fields);
88
+ binding.authorize(Number(fields[1]));
89
+ // A tmux pane receipt proves only creation. Do not publish/attach it or
90
+ // tear down a parked old session until the native client reports fchdir.
91
+ binding.waitBoundSync();
92
+ return monitorPinnedTmuxLaunch(binding, fields[0], { paneInfo });
74
93
  } catch (error) {
75
- // A timed-out tmux command may have created the pane. Do not release its
76
- // directory FD until a checked bootstrap ACK or definitive pane exit.
77
- monitorPinnedTmuxLaunch(binding, `${sessionName}:0.0`, { paneInfo });
94
+ // A timed-out tmux command may have created a pane, but without the
95
+ // immutable receipt no PID is authorized. Closing the broker leaves any
96
+ // orphan client unable to enter the selected directory.
97
+ binding.release();
78
98
  throw error;
79
99
  }
80
- return monitorPinnedTmuxLaunch(binding, `${sessionName}:0.0`, { paneInfo });
100
+ }
101
+
102
+ // A missing tmux pane is only evidence about tmux. The captured process
103
+ // fingerprint decides whether the original owner exited or became orphaned.
104
+ export function pollTmuxSessionExit(session, state, {
105
+ paneInfo = tmuxPaneInfo,
106
+ inspectProcess = inspectProcessIdentity,
107
+ detach
108
+ } = {}) {
109
+ const stored = session.adoptionIdentity?.process_identity;
110
+ function originalStatus() {
111
+ if (compareProcessIdentity(stored, stored) !== 'live') return 'detached';
112
+ let observation;
113
+ try { observation = inspectProcess(stored.pid); }
114
+ catch { return 'detached'; }
115
+ if (observation?.state === 'dead') return 'exited';
116
+ if (observation?.state !== 'live') return 'detached';
117
+ return compareProcessIdentity(stored, observation.identity) === 'dead' ? 'exited' : 'detached';
118
+ }
119
+
120
+ let result = 'unknown';
121
+ let paneReused = false;
122
+ try {
123
+ const fresh = paneInfo(session.pane);
124
+ if (!fresh || fresh.dead) {
125
+ result = originalStatus();
126
+ } else if ((fresh.pane && fresh.pane !== session.pane) ||
127
+ Number(fresh.pid) !== Number(stored?.pid)) {
128
+ paneReused = true;
129
+ result = originalStatus();
130
+ } else {
131
+ let observation;
132
+ try { observation = inspectProcess(stored.pid); }
133
+ catch { observation = null; }
134
+ if (observation?.state === 'dead') result = 'exited';
135
+ else if (observation?.state === 'live') {
136
+ const match = compareProcessIdentity(stored, observation.identity);
137
+ result = match === 'live' ? 'running' : match === 'dead' ? 'exited' : 'unknown';
138
+ }
139
+ paneReused = result === 'exited';
140
+ }
141
+ } catch (error) {
142
+ const message = error?.code === 'TMUX_ERROR' ? String(error.message || '') : '';
143
+ if (/can't find pane|can't find session|no server running/i.test(message)) result = originalStatus();
144
+ }
145
+
146
+ if (result === 'running' || result === 'unknown') {
147
+ state.deadCount = 0;
148
+ } else if (paneReused || ++state.deadCount >= 3) {
149
+ detach(session, result);
150
+ }
151
+ return result;
81
152
  }
82
153
 
83
154
  export function createTmuxSessions(deps) {
@@ -105,6 +176,8 @@ export function createTmuxSessions(deps) {
105
176
  const adoptionSessionNames = deps.tmuxListSessionNames || tmuxListSessionNames;
106
177
  const adoptionSessionEnvironment = deps.tmuxSessionEnvironmentValue || tmuxSessionEnvironmentValue;
107
178
  const adoptionSessionMatches = deps.tmuxManagedSessionNameMatches || tmuxManagedSessionNameMatches;
179
+ const inspectExitProcess = deps.inspectProcessIdentity || inspectProcessIdentity;
180
+ const scheduleExitPoller = deps.scheduleTmuxExitPoller || setInterval;
108
181
 
109
182
  function isAdoptionPaused(projectCtx, info, peer, existingDb = null) {
110
183
  const db = existingDb || connectWebProject(projectCtx);
@@ -709,29 +782,12 @@ export function createTmuxSessions(deps) {
709
782
  let rebindOldPlan = null;
710
783
  let rebindOldBindingSubject = null;
711
784
 
712
- // Detect pane death (retry 3 times before detaching — handles Ctrl+C transient states)
713
- let deadCount = 0;
714
- session.exitPoller = setInterval(() => {
785
+ const exitState = { deadCount: 0 };
786
+ session.exitPoller = scheduleExitPoller(() => {
715
787
  if (isStopping()) return;
716
- try {
717
- const fresh = tmuxPaneInfo(session.pane);
718
- if (fresh.dead) {
719
- deadCount++;
720
- if (deadCount >= 3) detachTmuxSession(session, 'exited');
721
- } else {
722
- deadCount = 0;
723
- }
724
- } catch (err) {
725
- // sess-03: only a definitive "pane/session is gone" error counts toward
726
- // detach; a transient tmux failure (busy server, momentary hiccup) must
727
- // not detach a live session after 3 attempts.
728
- const message = err?.code === 'TMUX_ERROR' ? String(err.message || '') : '';
729
- const definitivelyGone = /can't find pane|can't find session|no server running/i.test(message);
730
- if (definitivelyGone) {
731
- deadCount++;
732
- if (deadCount >= 3) detachTmuxSession(session, 'exited');
733
- }
734
- }
788
+ pollTmuxSessionExit(session, exitState, {
789
+ paneInfo: adoptionPaneInfo, inspectProcess: inspectExitProcess, detach: detachTmuxSession
790
+ });
735
791
  }, 3000);
736
792
 
737
793
  let db = null;
@@ -909,7 +965,6 @@ export function createTmuxSessions(deps) {
909
965
  for (const existing of sessions.values()) {
910
966
  if (providerSessionBindingMatches(existing.binding, input.binding)) input.authorizeMutation?.(existing);
911
967
  }
912
- ensureTmuxAvailable({ autoInstall: false });
913
968
  const pctx = input.projectCtx || ctx;
914
969
  const kind = input.kind || 'shell';
915
970
  const id = input.id || nextProjectSessionId(pctx, kind);
@@ -924,6 +979,13 @@ export function createTmuxSessions(deps) {
924
979
  const expectedCwdIdentity = input.cwdIdentity ||
925
980
  (cwd === pctx.root ? pctx.rootIdentity || pctx.initialRootIdentity : null) ||
926
981
  captureSelectedCwdSnapshot(cwd);
982
+ const historyDb = connectWebProject(pctx);
983
+ let codexOrigin;
984
+ try {
985
+ codexOrigin = assertCodexTerminalLaunch(historyDb, input.binding, { command, root: pctx.root,
986
+ rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity, cwd, cwdIdentity: expectedCwdIdentity });
987
+ } finally { historyDb.close(); }
988
+ ensureTmuxAvailable({ autoInstall: false });
927
989
  const sessionName = tmuxManagedSessionName(pctx, id);
928
990
  let paneTarget = `${sessionName}:0.0`;
929
991
  const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
@@ -931,14 +993,15 @@ export function createTmuxSessions(deps) {
931
993
  HCC_PEER: id,
932
994
  HCC_ROOT: pctx.root,
933
995
  HCC_DB: pctx.dbPath,
934
- TERM: 'xterm-256color'
996
+ TERM: 'xterm-256color',
997
+ ...(codexOrigin ? { [CODEX_SESSION_ORIGIN_ENV]: codexOrigin } : {})
935
998
  }, callerEnv, { rootIdentity: pctx.rootIdentity || pctx.initialRootIdentity });
936
999
  env[LAUNCH_FINGERPRINT_ENV] = launchFingerprint({ command, cwd, env });
937
1000
  let hasSession = tmuxHasSession(sessionName);
938
1001
  const relaunchableProvider = isRelaunchableProviderSession(kind, command, input.binding || {});
939
1002
  const oldTmuxTargetsForRebind = [];
940
1003
  const parkedOldTmuxSessions = [];
941
- let createdTmuxSession = false;
1004
+ let createdTmuxSession = null;
942
1005
  let pendingOldSession = null;
943
1006
  let pendingRestartAudit = null;
944
1007
 
@@ -954,29 +1017,59 @@ export function createTmuxSessions(deps) {
954
1017
  if (tmuxHasSession(parked.originalName)) {
955
1018
  quarantineName = `hcc-rollback-${Date.now().toString(36)}-${randomBytes(4).toString('hex')}`;
956
1019
  try {
957
- runTmux(['rename-session', '-t', parked.originalName, quarantineName]);
1020
+ if (!createdTmuxSession || createdTmuxSession.session !== parked.originalName) {
1021
+ throw new CliError('TMUX_CONDITIONAL_RENAME_MISMATCH',
1022
+ 'Cannot prove the session occupying the original name is our failed candidate');
1023
+ }
1024
+ conditionalTmuxRename(runTmux, createdTmuxSession, quarantineName, { allowClients: true });
958
1025
  } catch (err) {
959
- failures.push(`could not quarantine failed replacement ${parked.originalName}: ${err?.message || err}`);
1026
+ failures.push(`could not prove and quarantine failed replacement ${parked.originalName}: ${err?.message || err}`);
960
1027
  continue;
961
1028
  }
962
1029
  }
963
1030
  try {
964
- runTmux(['rename-session', '-t', parked.parkedName, parked.originalName]);
1031
+ conditionalTmuxRename(runTmux, parked.authority, parked.originalName);
965
1032
  } catch (err) {
966
1033
  failures.push(`could not restore ${parked.parkedName} to ${parked.originalName}: ${err?.message || err}`);
967
1034
  if (quarantineName && !tmuxHasSession(parked.originalName)) {
968
- try { runTmux(['rename-session', '-t', quarantineName, parked.originalName]); } catch {}
1035
+ try {
1036
+ conditionalTmuxRename(runTmux,
1037
+ { ...createdTmuxSession, session: quarantineName }, parked.originalName,
1038
+ { allowClients: true });
1039
+ } catch {}
969
1040
  }
970
1041
  continue;
971
1042
  }
1043
+ if (parked.bindingPreparation?.restored) {
1044
+ let bindingRolledBack = false;
1045
+ let bindingRollbackError = null;
1046
+ try {
1047
+ const rollbackDb = connectWebProject(pctx);
1048
+ try {
1049
+ bindingRolledBack = rollbackTmuxRestartBinding(rollbackDb, parked.bindingPreparation);
1050
+ } finally { rollbackDb.close(); }
1051
+ } catch (err) {
1052
+ bindingRollbackError = err?.message || String(err);
1053
+ }
1054
+ if (!bindingRolledBack) {
1055
+ const reason = bindingRollbackError || 'binding changed';
1056
+ failures.push(`could not roll back parked binding ${parked.originalName}: ${reason}`);
1057
+ }
1058
+ }
972
1059
  if (quarantineName) {
973
- try { tmuxKillSession(quarantineName); } catch { quarantined.push(quarantineName); }
1060
+ try {
1061
+ conditionalTmuxKill(runTmux,
1062
+ { ...createdTmuxSession, session: quarantineName }, { allowClients: true });
1063
+ } catch { quarantined.push(quarantineName); }
974
1064
  }
975
1065
  }
976
1066
  return { failures, quarantined };
977
1067
  }
978
1068
 
979
1069
  function restartExistingTmuxSession(reason) {
1070
+ // A held rollout may still attach to a live pane, but must not park it
1071
+ // before discovering that a replacement launch is disallowed.
1072
+ assertPinnedSessionLaunchAllowed({ env });
980
1073
  const existing = getSession(pctx, id);
981
1074
  const hasWebClients = hasOpenClients(existing);
982
1075
  const hasTmuxClients = tmuxSessionHasClients(sessionName);
@@ -1056,7 +1149,8 @@ export function createTmuxSessions(deps) {
1056
1149
  binding_rollback_failed: !bindingRolledBack
1057
1150
  });
1058
1151
  }
1059
- parkedOldTmuxSessions.push({ oldTarget, originalName: sessionName, parkedName });
1152
+ parkedOldTmuxSessions.push({ oldTarget, originalName: sessionName, parkedName,
1153
+ authority: { ...storedAuthority, session: parkedName }, bindingPreparation });
1060
1154
  if (oldTarget) oldTmuxTargetsForRebind.push({ oldPeer: id, oldTarget, allowedSessionName: parkedName });
1061
1155
  if (existing) {
1062
1156
  oldTarget = oldTarget || existing.pane || null;
@@ -1112,8 +1206,17 @@ export function createTmuxSessions(deps) {
1112
1206
  };
1113
1207
  if (relaunchableProvider) tmuxEnv[PROVIDER_STATE_ENV] = 'starting';
1114
1208
  runPinnedTmuxNewSession({ cwd, expectedIdentity: expectedCwdIdentity,
1115
- shell, command, env, sessionName, tmuxEnv });
1116
- createdTmuxSession = true;
1209
+ shell, command, env, sessionName, tmuxEnv }, {
1210
+ onCreated(fields) {
1211
+ createdTmuxSession = {
1212
+ session: sessionName,
1213
+ session_created: fields[3],
1214
+ session_id: fields[2],
1215
+ pane: fields[0],
1216
+ process_identity: { pid: Number(fields[1]) }
1217
+ };
1218
+ }
1219
+ });
1117
1220
  }
1118
1221
 
1119
1222
  pane = tmuxPaneInfo(paneTarget).pane;
@@ -1152,7 +1255,7 @@ export function createTmuxSessions(deps) {
1152
1255
  });
1153
1256
  } catch (err) {
1154
1257
  if (createdTmuxSession) {
1155
- try { tmuxKillSession(sessionName); } catch {}
1258
+ try { conditionalTmuxKill(runTmux, createdTmuxSession, { allowClients: true }); } catch {}
1156
1259
  }
1157
1260
  const rollback = restoreParkedOldTmuxSessions();
1158
1261
  // attachTmuxSession removes a failed candidate from the map. Restore the
@@ -8,6 +8,9 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
8
8
  (() => {
9
9
  if (window.hccInteractions) return;
10
10
  const mcpForms = createForms(), mcpUrls = createUrls();
11
+ if (typeof document !== 'undefined') document.addEventListener?.('submit', event => {
12
+ if (event.target?.matches?.('.hcc-interaction-form')) event.preventDefault();
13
+ });
11
14
  const drafts = new Map(), expanded = new Map();
12
15
  let focus = null;
13
16
  function remember(container) {
@@ -107,7 +110,7 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
107
110
  try { ({ fields } = mcpForms.describe(request.params)); }
108
111
  catch (_) { return '<p role="alert">' + esc(text('This MCP request mode or schema is unsupported. Decline or cancel this request.', '暂不支持此 MCP 请求模式或表单结构,请拒绝或取消请求。')) + '</p>'; }
109
112
  if (!fields.length) return '';
110
- const attr = suffix => ' data-interaction-field="' + esc(key + ':mcp:' + suffix) + '"';
113
+ const attr = suffix => ' name="' + esc(key + ':mcp:' + suffix) + '" data-interaction-field="' + esc(key + ':mcp:' + suffix) + '"';
111
114
  const select = (i, choices, value) => '<select data-mcp-field="' + i + '"' + attr(i) + '><option value="">' + esc(text('Choose a value', '请选择')) + '</option>'
112
115
  + choices.map((choice, n) => '<option value="' + n + '"' + (value === choice.value ? ' selected' : '') + '>' + esc(choice.label) + '</option>').join('') + '</select>';
113
116
  return '<p>' + esc(text('Required fields are marked *. Optional fields are sent only when selected. Responses are not saved in browser storage.', '带 * 的为必填项;可选项仅在勾选填写后发送。答复不会保存到浏览器存储。')) + '</p>'
@@ -140,8 +143,8 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
140
143
  + (destination.loopback ? '<p>' + esc(text('This address belongs to the executor computer. A browser on another device may not reach it; open this task on the executor computer to authorize.', '此地址位于执行器所在电脑。其他设备的浏览器可能无法访问,请在执行器所在电脑打开本任务完成授权。')) + '</p>' : '');
141
144
  }
142
145
  if (request.kind === 'mcp') return mcpForm(request, key, esc, text);
143
- const field = suffix => ' data-interaction-field="' + esc(key + ':' + suffix) + '"';
144
- if (request.kind === 'userInput') return (request.params?.questions || []).map((q, i) => {
146
+ const field = suffix => ' name="' + esc(key + ':' + suffix) + '" data-interaction-field="' + esc(key + ':' + suffix) + '"';
147
+ if (request.kind === 'userInput') return '<form class="hcc-interaction-form" autocomplete="off">' + (request.params?.questions || []).map((q, i) => {
145
148
  const label = '<label>' + esc(q.header || '') + '<p>' + esc(q.question || '') + '</p>';
146
149
  const free = '<input data-answer="' + i + '" type="' + (q.isSecret ? 'password' : 'text') + '" maxlength="8192" autocomplete="off"' + field('answer:' + i) + '>';
147
150
  if (!q.options?.length) return label + free + '</label>';
@@ -149,7 +152,7 @@ export function installInteractionPanel(createForms = createMcpFormValidator, cr
149
152
  + q.options.map((o, n) => '<option value="' + n + '">' + esc(o.label + (o.description ? ' — ' + o.description : '')) + '</option>').join('')
150
153
  + (q.isOther ? '<option value="other">' + esc(text('Other (enter below)', '其他(在下方输入)')) + '</option>' : '') + '</select>'
151
154
  + (q.isOther ? free : '') + '</label>';
152
- }).join('');
155
+ }).join('') + '</form>';
153
156
  if (request.kind !== 'permissions') return '';
154
157
  const p = request.params?.permissions || {}, fs = p.fileSystem || {};
155
158
  const choice = (name, index, label) => '<label><input type="checkbox" data-permission="' + name + '" data-index="' + index + '"' + field(name + ':' + index) + '> ' + esc(label) + '</label>';