@logicseek/hello-cc 1.0.0 → 1.0.2-dsh.3

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 (123) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +36 -0
  3. package/README.zh-CN.md +35 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/guide.md +58 -0
  11. package/docs/guide.zh-CN.md +55 -0
  12. package/docs/mcp.md +9 -0
  13. package/docs/mcp.zh-CN.md +23 -0
  14. package/docs/native.md +295 -0
  15. package/docs/native.zh-CN.md +253 -0
  16. package/docs/web-handoff.zh-CN.md +53 -0
  17. package/lib/cli/app.mjs +27 -6
  18. package/lib/cli/commands/coordination.mjs +3 -1
  19. package/lib/cli/commands/doctor.mjs +17 -2
  20. package/lib/cli/commands/dsh.mjs +79 -0
  21. package/lib/cli/commands/gc.mjs +182 -32
  22. package/lib/cli/commands/hook.mjs +241 -90
  23. package/lib/cli/commands/lock.mjs +34 -19
  24. package/lib/cli/commands/mcp.mjs +28 -0
  25. package/lib/cli/commands/misc.mjs +15 -0
  26. package/lib/cli/commands/native.mjs +130 -0
  27. package/lib/cli/commands/peer.mjs +21 -5
  28. package/lib/cli/commands/run.mjs +66 -21
  29. package/lib/cli/commands/task.mjs +6 -3
  30. package/lib/cli-args.mjs +4 -0
  31. package/lib/coordination-state.mjs +15 -1
  32. package/lib/core/coordination/event-retention.mjs +14 -2
  33. package/lib/core/coordination/gc-plan.mjs +3 -13
  34. package/lib/core/coordination/locks.mjs +45 -0
  35. package/lib/core/coordination/results.mjs +79 -0
  36. package/lib/core/peers/bindings.mjs +9 -3
  37. package/lib/diagnostics/codex.mjs +121 -0
  38. package/lib/integrations/codex-interactions.mjs +114 -0
  39. package/lib/integrations/dsh-collaboration.mjs +170 -0
  40. package/lib/integrations/dsh-cordis.d.ts +19 -0
  41. package/lib/integrations/dsh-cordis.mjs +177 -0
  42. package/lib/integrations/dsh.bundle.yml +7 -0
  43. package/lib/integrations/dsh.mjs +287 -0
  44. package/lib/integrations/native/claude.mjs +451 -0
  45. package/lib/integrations/native/codex.mjs +242 -0
  46. package/lib/integrations/native/dsh-acp.mjs +343 -0
  47. package/lib/integrations/native/index.mjs +19 -0
  48. package/lib/integrations/native/interactions.mjs +53 -0
  49. package/lib/integrations/native/jsonrpc.mjs +185 -0
  50. package/lib/integrations/peers/identity.mjs +36 -10
  51. package/lib/integrations/peers/processes.mjs +22 -8
  52. package/lib/integrations/providers.mjs +6 -2
  53. package/lib/mcp/scope.mjs +104 -0
  54. package/lib/mcp/stdio.mjs +80 -0
  55. package/lib/mcp/tools.mjs +197 -0
  56. package/lib/process/identity.mjs +33 -1
  57. package/lib/release/release-notes.mjs +9 -0
  58. package/lib/runtime/buffer-directory-lease.mjs +2 -1
  59. package/lib/runtime/client.mjs +3 -0
  60. package/lib/runtime/native/client.mjs +38 -0
  61. package/lib/runtime/native/service.mjs +511 -0
  62. package/lib/runtime/native/store.mjs +121 -0
  63. package/lib/shared/file-lock.mjs +11 -4
  64. package/lib/shared/socket-lock-worker.mjs +73 -24
  65. package/lib/terminal/tmux.mjs +53 -23
  66. package/lib/ui/help.mjs +5 -2
  67. package/lib/web/adoption-state.mjs +55 -0
  68. package/lib/web/auto-attach.mjs +18 -2
  69. package/lib/web/buffer-gc-runtime.mjs +2 -2
  70. package/lib/web/codex-app-server.mjs +557 -0
  71. package/lib/web/codex-sessions.mjs +287 -0
  72. package/lib/web/control-lease.mjs +204 -0
  73. package/lib/web/http-routes.mjs +197 -15
  74. package/lib/web/http.mjs +5 -4
  75. package/lib/web/native-sessions.mjs +286 -0
  76. package/lib/web/project-contexts.mjs +8 -2
  77. package/lib/web/pty-sessions.mjs +60 -33
  78. package/lib/web/runtime-main.mjs +101 -6
  79. package/lib/web/session-serialize.mjs +0 -0
  80. package/lib/web/startup.mjs +4 -2
  81. package/lib/web/task-results.mjs +76 -0
  82. package/lib/web/tmux-sessions.mjs +92 -11
  83. package/lib/web/tmux-stream.mjs +2 -2
  84. package/lib/web/ui-codex.mjs +550 -0
  85. package/lib/web/ui-command-palette.mjs +119 -0
  86. package/lib/web/ui-handoff.mjs +102 -0
  87. package/lib/web/ui-history.mjs +118 -0
  88. package/lib/web/ui-i18n.mjs +841 -0
  89. package/lib/web/ui-interactions.mjs +78 -0
  90. package/lib/web/ui-login.mjs +101 -0
  91. package/lib/web/ui-native.mjs +165 -0
  92. package/lib/web/ui-preferences.mjs +278 -0
  93. package/lib/web/ui-review.mjs +132 -0
  94. package/lib/web/ui-template.mjs +1106 -576
  95. package/lib/web/ui-terminal-find.mjs +98 -0
  96. package/lib/web/ui-workbench.mjs +94 -0
  97. package/lib/web/ui-workspace.mjs +222 -0
  98. package/package.json +12 -4
  99. package/scripts/check-free-identifiers.mjs +67 -8
  100. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  101. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  102. package/scripts/dsh-installed-acceptance.mjs +247 -0
  103. package/scripts/dsh-official-acceptance.mjs +180 -0
  104. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  105. package/scripts/github-release.mjs +3 -1
  106. package/scripts/native-live-acceptance.mjs +369 -0
  107. package/scripts/regression.mjs +338 -187
  108. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  109. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  110. package/scripts/web-native-model-acceptance.mjs +138 -0
  111. package/docs/architecture-split-review.md +0 -217
  112. package/docs/current-state-review-2026-08.md +0 -341
  113. package/docs/defect-review-2026-07.md +0 -382
  114. package/docs/layout-split-stack-audit.md +0 -298
  115. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +0 -294
  116. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +0 -400
  117. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +0 -284
  118. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +0 -303
  119. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +0 -71
  120. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +0 -117
  121. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +0 -120
  122. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +0 -117
  123. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +0 -104
@@ -78,13 +78,13 @@ function startupTimeoutError(key) {
78
78
  return error;
79
79
  }
80
80
 
81
- function canonicalTarget(value) {
81
+ function canonicalTarget(value, { createParent = true } = {}) {
82
82
  if (typeof value !== 'string' || value.length === 0) {
83
83
  throw new TypeError('file lock target must be a non-empty path');
84
84
  }
85
85
  const supplied = path.resolve(value);
86
86
  const suppliedParent = path.dirname(supplied);
87
- fs.mkdirSync(suppliedParent, { recursive: true, mode: 0o700 });
87
+ if (createParent) fs.mkdirSync(suppliedParent, { recursive: true, mode: 0o700 });
88
88
  const canonicalParent = fs.realpathSync.native(suppliedParent);
89
89
  const parentIdentity = fs.statSync(canonicalParent);
90
90
  if (!parentIdentity.isDirectory()) {
@@ -330,8 +330,12 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
330
330
  return function withConfiguredFileLock(
331
331
  target,
332
332
  fn,
333
- { timeoutMs = 5000, retryMs = 25, nonblocking = false } = {}
333
+ options = {}
334
334
  ) {
335
+ const timeoutMs = options.timeoutMs === undefined ? 5000 : options.timeoutMs;
336
+ const retryMs = options.retryMs === undefined ? 25 : options.retryMs;
337
+ const nonblocking = options.nonblocking === undefined ? false : options.nonblocking;
338
+ const createParent = options.createParent === undefined ? true : options.createParent;
335
339
  if (typeof fn !== 'function') throw new TypeError('file lock callback must be a function');
336
340
  if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
337
341
  throw new RangeError('file lock timeoutMs must be a non-negative finite number');
@@ -342,11 +346,14 @@ export function createFileLock({ workerFactory = defaultWorkerFactory } = {}) {
342
346
  if (typeof nonblocking !== 'boolean') {
343
347
  throw new TypeError('file lock nonblocking must be a boolean');
344
348
  }
349
+ if (typeof createParent !== 'boolean') {
350
+ throw new TypeError('file lock createParent must be a boolean');
351
+ }
345
352
  if (fn.constructor?.name === 'AsyncFunction') {
346
353
  throw new TypeError('withFileLock requires a synchronous callback');
347
354
  }
348
355
 
349
- const location = canonicalTarget(target);
356
+ const location = canonicalTarget(target, { createParent });
350
357
  const resources = acquireWorker(location, { timeoutMs, retryMs, nonblocking }, workerFactory);
351
358
  let result;
352
359
  let primary = NO_ERROR;
@@ -10,9 +10,12 @@ export function runSocketLockWorker(workerData) {
10
10
  const state = new Int32Array(workerData.stateBuffer);
11
11
  const states = workerData.states;
12
12
  const targets = workerData.targets;
13
+ const acceptedSockets = new Set();
13
14
  let servers = [];
15
+ let closeOperation = null;
14
16
  let contentionDeadline = null;
15
17
  let releasing = false;
18
+ let terminalFailureRequested = false;
16
19
  let finished = false;
17
20
  let generation = 0;
18
21
 
@@ -24,37 +27,69 @@ export function runSocketLockWorker(workerData) {
24
27
 
25
28
  function finish(status, detail = 0) {
26
29
  if (finished) return;
30
+ if (terminalFailureRequested) {
31
+ status = states.FAILED;
32
+ detail = 1;
33
+ }
27
34
  finished = true;
28
35
  publish(status, detail);
29
36
  parentPort.close();
30
37
  }
31
38
 
32
39
  function closeServers(callback) {
33
- const closing = servers;
34
- servers = [];
35
- if (closing.length === 0) {
36
- callback(null);
40
+ if (closeOperation !== null) {
41
+ closeOperation.callbacks.push(callback);
42
+ if (closeOperation.cleanupStarted) destroyAcceptedSockets();
37
43
  return;
38
44
  }
39
- let remaining = closing.length;
40
- let firstError = null;
45
+ const closing = servers;
46
+ servers = [];
47
+ const operation = {
48
+ callbacks: [callback],
49
+ cleanupStarted: false,
50
+ closing,
51
+ completed: false,
52
+ firstError: null,
53
+ remaining: closing.length
54
+ };
55
+ closeOperation = operation;
56
+ const complete = () => {
57
+ if (operation.completed || !operation.cleanupStarted || operation.remaining !== 0) return;
58
+ operation.completed = true;
59
+ closeOperation = null;
60
+ const callbacks = operation.callbacks;
61
+ operation.callbacks = [];
62
+ operation.closing = [];
63
+ for (const waiting of callbacks) waiting(operation.firstError);
64
+ };
41
65
  const closed = (error = null) => {
42
- if (error && !firstError) firstError = error;
43
- remaining -= 1;
44
- if (remaining === 0) callback(firstError);
66
+ if (error && !operation.firstError) operation.firstError = error;
67
+ operation.remaining -= 1;
68
+ complete();
45
69
  };
46
- for (const server of closing) {
70
+ for (const server of operation.closing) {
71
+ let settled = false;
72
+ const serverClosed = (error = null) => {
73
+ if (settled) return;
74
+ settled = true;
75
+ closed(error);
76
+ };
47
77
  try {
48
- server.close(closed);
78
+ server.close(serverClosed);
49
79
  } catch (error) {
50
- closed(error);
80
+ serverClosed(error);
51
81
  }
52
82
  }
83
+ destroyAcceptedSockets();
84
+ operation.cleanupStarted = true;
85
+ complete();
53
86
  }
54
87
 
55
- function closeAndFinish(status, detail = 0) {
88
+ function requestTerminalFailure() {
89
+ if (finished) return;
90
+ terminalFailureRequested = true;
56
91
  generation += 1;
57
- closeServers((error) => finish(error ? states.FAILED : status, error ? 1 : detail));
92
+ closeServers(() => finish(states.FAILED, 1));
58
93
  }
59
94
 
60
95
  function retryAfterContention() {
@@ -77,8 +112,8 @@ export function runSocketLockWorker(workerData) {
77
112
  if (finished || attemptGeneration !== generation) return;
78
113
  generation += 1;
79
114
  closeServers((error) => {
80
- if (error) finish(states.FAILED, 1);
81
- else retryAfterContention();
115
+ if (error) requestTerminalFailure();
116
+ else if (!terminalFailureRequested) retryAfterContention();
82
117
  });
83
118
  }
84
119
 
@@ -86,6 +121,19 @@ export function runSocketLockWorker(workerData) {
86
121
  return `${PROTOCOL_PREFIX}${identity}\n`;
87
122
  }
88
123
 
124
+ function serveLock(socket, identity) {
125
+ acceptedSockets.add(socket);
126
+ socket.once('close', () => acceptedSockets.delete(socket));
127
+ socket.on('error', () => {});
128
+ socket.end(lockBanner(identity));
129
+ }
130
+
131
+ function destroyAcceptedSockets() {
132
+ for (const socket of acceptedSockets) {
133
+ try { socket.destroy(); } catch {}
134
+ }
135
+ }
136
+
89
137
  function probeOccupant(port, identity, callback) {
90
138
  const expected = lockBanner(identity);
91
139
  let received = '';
@@ -129,12 +177,11 @@ export function runSocketLockWorker(workerData) {
129
177
  const port = target.ports[candidateIndex];
130
178
  let candidate;
131
179
  try {
132
- candidate = net.createServer((socket) => socket.end(lockBanner(target.identity)));
180
+ candidate = net.createServer((socket) => serveLock(socket, target.identity));
133
181
  const bindError = (error) => {
134
182
  if (finished || attemptGeneration !== generation) return;
135
183
  if (error?.code !== 'EADDRINUSE') {
136
- generation += 1;
137
- closeServers(() => finish(states.FAILED, 1));
184
+ requestTerminalFailure();
138
185
  return;
139
186
  }
140
187
  probeOccupant(port, target.identity, (matching) => {
@@ -150,13 +197,12 @@ export function runSocketLockWorker(workerData) {
150
197
  return;
151
198
  }
152
199
  candidate.off('error', bindError);
153
- candidate.on('error', () => closeAndFinish(states.FAILED, 1));
200
+ candidate.on('error', requestTerminalFailure);
154
201
  servers.push(candidate);
155
202
  bindCandidate(targetIndex, candidateIndex + 1, boundForTarget + 1, attemptGeneration);
156
203
  });
157
204
  } catch {
158
- generation += 1;
159
- closeServers(() => finish(states.FAILED, 1));
205
+ requestTerminalFailure();
160
206
  }
161
207
  }
162
208
 
@@ -184,7 +230,10 @@ export function runSocketLockWorker(workerData) {
184
230
  Atomics.load(state, STATE_INDEX) !== states.ACQUIRED) return;
185
231
  releasing = true;
186
232
  generation += 1;
187
- closeServers((error) => finish(error ? states.RELEASE_FAILED : states.RELEASED, error ? 1 : 0));
233
+ closeServers((error) => {
234
+ if (terminalFailureRequested) finish(states.FAILED, 1);
235
+ else finish(error ? states.RELEASE_FAILED : states.RELEASED, error ? 1 : 0);
236
+ });
188
237
  });
189
238
 
190
239
  try {
@@ -196,6 +245,6 @@ export function runSocketLockWorker(workerData) {
196
245
  }
197
246
  attemptAll();
198
247
  } catch {
199
- closeAndFinish(states.FAILED, 1);
248
+ requestTerminalFailure();
200
249
  }
201
250
  }
@@ -36,14 +36,20 @@ export function runTmux(args, opts = {}) {
36
36
  return result.stdout || '';
37
37
  }
38
38
 
39
- function tmuxInstallHint() {
40
- if (process.platform === 'darwin') return 'Install tmux with: brew install tmux';
41
- if (process.platform === 'linux') {
42
- if (fs.existsSync('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
43
- if (fs.existsSync('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
44
- if (fs.existsSync('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
45
- if (fs.existsSync('/etc/fedora-release')) return 'Install tmux with: sudo dnf install -y tmux';
46
- return 'Install tmux with your system package manager.';
39
+ export function tmuxInstallHint({
40
+ platform = process.platform,
41
+ pathExists = fs.existsSync
42
+ } = {}) {
43
+ if (platform === 'darwin') return 'Install tmux with: brew install tmux';
44
+ if (platform === 'linux') {
45
+ if (pathExists('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
46
+ if (pathExists('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
47
+ if (pathExists('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
48
+ if (pathExists('/etc/fedora-release') || pathExists('/etc/redhat-release')) {
49
+ return 'Install tmux with: sudo dnf install -y tmux (or sudo yum install -y tmux on older systems)';
50
+ }
51
+ if (pathExists('/etc/SuSE-release')) return 'Install tmux with: sudo zypper --non-interactive install tmux';
52
+ return 'Install tmux with your Linux system package manager (apt/apt-get, dnf/yum, apk, pacman, or zypper).';
47
53
  }
48
54
  return 'Install tmux and make sure it is on PATH.';
49
55
  }
@@ -54,6 +60,36 @@ function commandExists(name) {
54
60
  }).status === 0;
55
61
  }
56
62
 
63
+ export function tmuxInstallPlan({
64
+ platform = process.platform,
65
+ commandAvailable = commandExists,
66
+ isRoot = typeof process.getuid === 'function' && process.getuid() === 0
67
+ } = {}) {
68
+ if (platform === 'darwin') {
69
+ return commandAvailable('brew')
70
+ ? { manager: 'brew', commands: ['brew install tmux'] }
71
+ : null;
72
+ }
73
+ if (platform !== 'linux') return null;
74
+
75
+ if (!isRoot && !commandAvailable('sudo')) return null;
76
+ const sudo = isRoot ? '' : 'sudo ';
77
+ if (commandAvailable('apt-get')) {
78
+ return {
79
+ manager: 'apt-get',
80
+ commands: [`${sudo}apt-get update`, `${sudo}apt-get install -y tmux`]
81
+ };
82
+ }
83
+ if (commandAvailable('dnf')) return { manager: 'dnf', commands: [`${sudo}dnf install -y tmux`] };
84
+ if (commandAvailable('yum')) return { manager: 'yum', commands: [`${sudo}yum install -y tmux`] };
85
+ if (commandAvailable('apk')) return { manager: 'apk', commands: [`${sudo}apk add tmux`] };
86
+ if (commandAvailable('pacman')) return { manager: 'pacman', commands: [`${sudo}pacman -S --noconfirm tmux`] };
87
+ if (commandAvailable('zypper')) {
88
+ return { manager: 'zypper', commands: [`${sudo}zypper --non-interactive install tmux`] };
89
+ }
90
+ return null;
91
+ }
92
+
57
93
  function runInstallCommand(command) {
58
94
  const result = spawnSync('sh', ['-lc', command], {
59
95
  encoding: 'utf8',
@@ -70,22 +106,16 @@ export function tryInstallTmux() {
70
106
  if (process.env.HCC_NO_AUTO_INSTALL_TMUX === '1') {
71
107
  return { ok: false, output: 'automatic tmux installation disabled by HCC_NO_AUTO_INSTALL_TMUX=1' };
72
108
  }
73
- const sudo = typeof process.getuid === 'function' && process.getuid() === 0 ? '' : (commandExists('sudo') ? 'sudo ' : '');
74
- if (process.platform === 'darwin' && commandExists('brew')) {
75
- return runInstallCommand('brew install tmux');
76
- }
77
- if (process.platform === 'linux') {
78
- if (commandExists('apt-get')) {
79
- const update = runInstallCommand(`${sudo}apt-get update`);
80
- if (!update.ok) return update;
81
- return runInstallCommand(`${sudo}apt-get install -y tmux`);
82
- }
83
- if (commandExists('dnf')) return runInstallCommand(`${sudo}dnf install -y tmux`);
84
- if (commandExists('yum')) return runInstallCommand(`${sudo}yum install -y tmux`);
85
- if (commandExists('apk')) return runInstallCommand(`${sudo}apk add tmux`);
86
- if (commandExists('pacman')) return runInstallCommand(`${sudo}pacman -S --noconfirm tmux`);
109
+ const plan = tmuxInstallPlan();
110
+ if (!plan) return { ok: false, output: 'no supported package manager with sufficient privileges found' };
111
+
112
+ const output = [];
113
+ for (const command of plan.commands) {
114
+ const result = runInstallCommand(command);
115
+ if (result.output) output.push(result.output);
116
+ if (!result.ok) return { ok: false, output: output.join('\n') };
87
117
  }
88
- return { ok: false, output: 'no supported package manager found' };
118
+ return { ok: true, output: output.join('\n') };
89
119
  }
90
120
 
91
121
  export function ensureTmuxAvailable({ autoInstall = true } = {}) {
package/lib/ui/help.mjs CHANGED
@@ -8,13 +8,15 @@ export function createHelpFunctions(config = {}) {
8
8
  helpMain() {
9
9
  console.log(`${productName} ${version}
10
10
 
11
- Project-local coordination bus for multiple Claude Code and Codex CLI sessions.
11
+ Project-local coordination bus for Claude Code, Codex, and DeepSeek Harness sessions.
12
12
 
13
13
  Usage:
14
14
  ${cliName} [--root DIR] [--db FILE] [--json] <command> [args]
15
15
 
16
16
  Commands:
17
17
  web Start coordination, shims, tmux, and browser console
18
+ dsh <setup|status|web> Connect DeepSeek Harness through project-local hooks
19
+ native <subcommand> Run owned Codex/Claude/dsh workers through native protocols
18
20
  up Initialize local coordination only
19
21
  down Stop the running hello-cc web runtime
20
22
  update Update the global npm install of hello-cc
@@ -44,7 +46,8 @@ Commands:
44
46
  gc [--older-than DAYS] [--history] [--yes] Clean technical state; opt in to history cleanup
45
47
  doctor Run a read-only database health check (integrity, sizes, row counts)
46
48
  Internal:
47
- hook Hook entrypoint used by Claude/Codex
49
+ mcp serve --peer ID Serve executor-authorized project coordination tools over stdio
50
+ hook Hook entrypoint used by Claude/Codex/dsh
48
51
  find-root Shim helper
49
52
  which-real Shim helper
50
53
 
@@ -0,0 +1,55 @@
1
+ // Persist Web terminal detachment separately from process liveness. Existing
2
+ // audit events survive a runtime restart without changing the database schema.
3
+
4
+ function identityFor(value = {}) {
5
+ const identity = value.adoptionIdentity || value.identity || value;
6
+ const processIdentity = identity.process_identity || identity.processIdentity || {};
7
+ return {
8
+ pid: identity.pid || processIdentity.pid || value.pid || null,
9
+ process_start_token: identity.process_start_token || processIdentity.startToken || null,
10
+ tmux_session_created: identity.tmux_session_created || identity.session_created || null,
11
+ tmux_session_id: identity.tmux_session_id || identity.session_id || null
12
+ };
13
+ }
14
+
15
+ export function writeAdoptionState(db, session, paused, time) {
16
+ const peer = session?.peerId || session?.peer || session?.id;
17
+ if (typeof peer !== 'string' || !peer || typeof paused !== 'boolean' ||
18
+ !Number.isSafeInteger(time) || time < 0) {
19
+ throw new TypeError('Adoption state requires a peer, a boolean paused value, and an integer timestamp');
20
+ }
21
+ const payload = {
22
+ peer,
23
+ pane: session.pane || null,
24
+ identity: identityFor(session),
25
+ source: 'web',
26
+ target_peer: peer,
27
+ runtime_session_id: session.id || null
28
+ };
29
+ db.prepare(`
30
+ INSERT INTO events(type, actor, task_id, payload, created_at)
31
+ VALUES (?, 'web', NULL, ?, ?)
32
+ `).run(paused ? 'web.adoption.paused' : 'web.adoption.resumed', JSON.stringify(payload), time);
33
+ return { ...payload, paused };
34
+ }
35
+
36
+ export function adoptionPaused(db, { peer, pane = null, ...observed } = {}) {
37
+ if (typeof peer !== 'string' || !peer) return false;
38
+ const event = db.prepare(`
39
+ SELECT type, payload FROM events
40
+ WHERE type IN ('web.adoption.paused', 'web.adoption.resumed')
41
+ AND json_valid(payload)
42
+ AND json_extract(payload, '$.peer') = ?
43
+ AND (? IS NULL OR json_extract(payload, '$.pane') = ? OR json_extract(payload, '$.pane') IS NULL)
44
+ ORDER BY id DESC LIMIT 1
45
+ `).get(peer, pane, pane);
46
+ if (!event || event.type !== 'web.adoption.paused') return false;
47
+ const stored = identityFor(JSON.parse(event.payload));
48
+ const current = identityFor(observed);
49
+ // Known identity changes mean a different live terminal. An unavailable
50
+ // observation cannot silently revoke an intentional pause.
51
+ for (const key of Object.keys(stored)) {
52
+ if (stored[key] !== null && current[key] !== null && String(stored[key]) !== String(current[key])) return false;
53
+ }
54
+ return true;
55
+ }
@@ -5,6 +5,9 @@
5
5
  import { inferPeerKind } from '../integrations/providers.mjs';
6
6
  import { runTmux } from '../tmux.mjs';
7
7
  import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
8
+ import { isDshHookBinding } from '../core/peers/bindings.mjs';
9
+ import { inspectProcessIdentity } from '../process/identity.mjs';
10
+ import { adoptionPaused } from './adoption-state.mjs';
8
11
 
9
12
  export function createAutoAttach(deps) {
10
13
  const {
@@ -17,6 +20,8 @@ export function createAutoAttach(deps) {
17
20
  resolveSessionPeerId,
18
21
  reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
19
22
  } = deps;
23
+ const execTmux = deps.runTmux || runTmux;
24
+ const inspectIdentity = deps.inspectProcessIdentity || inspectProcessIdentity;
20
25
 
21
26
  function listTmuxPanesOnce() {
22
27
  // Use '|' not '\t' as the field separator: older tmux (3.3a) replaces
@@ -25,7 +30,7 @@ export function createAutoAttach(deps) {
25
30
  // user-created session name could contain '|', corrupting the parse. Callers
26
31
  // that need the session name use tmuxSessionNameForPane(pane.pane) which
27
32
  // queries a single field with no separator issue.
28
- const result = runTmux([
33
+ const result = execTmux([
29
34
  'list-panes',
30
35
  '-a',
31
36
  '-F',
@@ -98,7 +103,7 @@ export function createAutoAttach(deps) {
98
103
  SELECT p.id, p.kind, p.role, p.worktree, p.pid,
99
104
  b.peer AS binding_peer, b.provider, b.provider_session_id,
100
105
  b.provider_session_name, b.resume_mode, b.resume_arg,
101
- b.command AS binding_command, b.runtime_session_id,
106
+ b.command AS binding_command, b.transport AS binding_transport, b.runtime_session_id,
102
107
  b.runtime_target
103
108
  FROM peers p
104
109
  LEFT JOIN peer_bindings b ON b.peer = p.id
@@ -130,12 +135,23 @@ export function createAutoAttach(deps) {
130
135
  const attached = attachedTmuxState(ctx, db);
131
136
 
132
137
  for (const row of rows) {
138
+ if (['native', 'app-server'].includes(row.binding_transport)) continue;
139
+ // A dsh PID/pane belongs to the service, which may host many Agent
140
+ // sessions. A hook's conversation binding grants no terminal control.
141
+ if (isDshHookBinding({ provider: row.provider || row.kind, transport: row.binding_transport })) continue;
133
142
  if (attached.peers.has(row.id)) continue;
134
143
  if (row.binding_peer && attached.peers.has(row.binding_peer)) continue;
135
144
  if (row.runtime_session_id && attached.peers.has(row.runtime_session_id)) continue;
136
145
 
137
146
  const pane = paneByPid.get(Number(row.pid));
138
147
  if (!pane || attached.panes.has(pane.pane)) continue;
148
+ const identity = inspectIdentity(pane.pid);
149
+ if (adoptionPaused(db, {
150
+ peer: row.id,
151
+ pane: pane.pane,
152
+ pid: pane.pid,
153
+ process_identity: identity?.state === 'live' ? identity.identity : null
154
+ })) continue;
139
155
 
140
156
  const binding = row.binding_peer ? {
141
157
  provider: row.provider,
@@ -147,8 +147,8 @@ export function createBufferGcRuntime(deps) {
147
147
  olderThanDays: 14,
148
148
  dryRun: false,
149
149
  scope: 'auto',
150
- collectBufferEvidenceNow: () => collectRuntimeBufferEvidence([
151
- bufferDirectory(projectCtx)
150
+ collectBufferEvidenceNow: (directory) => collectRuntimeBufferEvidence([
151
+ directory
152
152
  ])
153
153
  });
154
154
  const appliedDatabaseRows = result.expired_locks + result.old_events;