@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -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/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -1606,23 +1606,26 @@ async function expectWebSocketMarker(peer, marker) {
1606
1606
  await new Promise((resolve, reject) => {
1607
1607
  let sawSnapshot = false;
1608
1608
  let sawMarker = false;
1609
- const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
1609
+ const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
1610
1610
  const timer = setTimeout(() => {
1611
1611
  try { ws.terminate(); } catch {}
1612
1612
  reject(new Error(`${peer} websocket timeout`));
1613
1613
  }, TERMINAL_WEBSOCKET_TIMEOUT_MS);
1614
- ws.on('open', () => {
1615
- const result = hccMaybe(['inject', peer, `echo ${marker}`]);
1616
- if (result.status !== 0) reject(new Error(result.stderr || result.stdout || 'inject failed'));
1617
- });
1618
1614
  ws.on('message', (raw) => {
1619
1615
  const msg = JSON.parse(String(raw));
1620
- if (msg.type === 'snapshot') sawSnapshot = true;
1616
+ if (msg.type === 'snapshot') {
1617
+ sawSnapshot = true;
1618
+ claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
1619
+ type: 'input', data: `echo ${marker}\r`, action_token: ws.hccActionToken,
1620
+ epoch: ws.hccControl.epoch
1621
+ }))).catch(reject);
1622
+ }
1621
1623
  if (['snapshot', 'data', 'replace'].includes(msg.type) && String(msg.data || '').includes(marker)) {
1622
1624
  sawMarker = true;
1623
1625
  }
1624
1626
  if (sawSnapshot && sawMarker) {
1625
1627
  clearTimeout(timer);
1628
+ releaseTerminalControl(ws);
1626
1629
  ws.close();
1627
1630
  resolve();
1628
1631
  }
@@ -1631,6 +1634,47 @@ async function expectWebSocketMarker(peer, marker) {
1631
1634
  });
1632
1635
  }
1633
1636
 
1637
+ // Keep the private token and public fencing epoch on each test connection.
1638
+ // Reconnecting helpers explicitly claim control before an authorized write.
1639
+ function trackTerminalControl(ws) {
1640
+ ws.on('message', (raw) => {
1641
+ let message;
1642
+ try { message = JSON.parse(String(raw)); } catch { return; }
1643
+ if (message.action_token) ws.hccActionToken = message.action_token;
1644
+ if (message.control) ws.hccControl = message.control;
1645
+ });
1646
+ return ws;
1647
+ }
1648
+
1649
+ async function claimTerminalControl(ws) {
1650
+ if (ws.hccControl?.can_control) return ws.hccControl;
1651
+ return new Promise((resolve, reject) => {
1652
+ const timer = setTimeout(() => finish(new Error('terminal control claim timeout')), 5000);
1653
+ const finish = (error = null) => {
1654
+ clearTimeout(timer);
1655
+ ws.off('message', onMessage);
1656
+ if (error) reject(error);
1657
+ else resolve(ws.hccControl);
1658
+ };
1659
+ const onMessage = (raw) => {
1660
+ let message;
1661
+ try { message = JSON.parse(String(raw)); } catch { return; }
1662
+ if (message.type === 'error') finish(new Error(`terminal control claim failed: ${JSON.stringify(message.error)}`));
1663
+ else if (message.type === 'control' && message.control?.can_control) finish();
1664
+ };
1665
+ ws.on('message', onMessage);
1666
+ ws.send(JSON.stringify({ type: 'control', action: 'claim', action_token: ws.hccActionToken,
1667
+ epoch: ws.hccControl?.epoch, force: true }));
1668
+ });
1669
+ }
1670
+
1671
+ function releaseTerminalControl(ws) {
1672
+ if (ws?.readyState === WebSocket.OPEN && ws.hccControl?.can_control) {
1673
+ ws.send(JSON.stringify({ type: 'control', action: 'release', action_token: ws.hccActionToken,
1674
+ epoch: ws.hccControl.epoch }));
1675
+ }
1676
+ }
1677
+
1634
1678
  async function openTerminalWebSocket(peer) {
1635
1679
  return new Promise((resolve, reject) => {
1636
1680
  const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
@@ -1657,7 +1701,7 @@ async function openSessionActionChannel(peer, params = {}) {
1657
1701
  for (const [key, value] of Object.entries(params)) {
1658
1702
  if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
1659
1703
  }
1660
- const ws = new WebSocket(url, runtimeWsOptions());
1704
+ const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
1661
1705
  const timer = setTimeout(() => {
1662
1706
  try { ws.terminate(); } catch {}
1663
1707
  reject(new Error(`${peer} action token fetch timeout`));
@@ -1666,7 +1710,7 @@ async function openSessionActionChannel(peer, params = {}) {
1666
1710
  const msg = JSON.parse(String(raw));
1667
1711
  if (msg.type === 'snapshot') {
1668
1712
  clearTimeout(timer);
1669
- resolve({ token: msg.action_token || '', ws });
1713
+ resolve({ token: msg.action_token || '', ws, get epoch() { return ws.hccControl?.epoch; } });
1670
1714
  }
1671
1715
  });
1672
1716
  ws.on('error', (err) => { clearTimeout(timer); reject(err); });
@@ -1677,8 +1721,23 @@ async function fetchSessionActionToken(peer, params = {}) {
1677
1721
  return (await openSessionActionChannel(peer, params)).token;
1678
1722
  }
1679
1723
 
1724
+ async function stopControlledSession(id, payload = {}, projectRoot = root) {
1725
+ const channel = await openSessionActionChannel(id, { root: projectRoot });
1726
+ try {
1727
+ await claimTerminalControl(channel.ws);
1728
+ return await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, {
1729
+ method: 'POST',
1730
+ headers: { 'Content-Type': 'application/json' },
1731
+ body: JSON.stringify({ ...payload, action_token: channel.token, epoch: channel.epoch })
1732
+ }, { root: projectRoot });
1733
+ } finally {
1734
+ await closeTerminalWebSocket(channel.ws);
1735
+ }
1736
+ }
1737
+
1680
1738
  async function closeTerminalWebSocket(ws) {
1681
1739
  if (!ws || ws.readyState === WebSocket.CLOSED) return;
1740
+ releaseTerminalControl(ws);
1682
1741
  await new Promise((resolve) => {
1683
1742
  const timer = setTimeout(() => {
1684
1743
  try { ws.terminate(); } catch {}
@@ -1701,7 +1760,7 @@ async function fetchTerminalSnapshot(peer, params = {}) {
1701
1760
  for (const [key, value] of Object.entries(params)) {
1702
1761
  if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
1703
1762
  }
1704
- const ws = new WebSocket(url, runtimeWsOptions());
1763
+ const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
1705
1764
  const timer = setTimeout(() => {
1706
1765
  try { ws.terminate(); } catch {}
1707
1766
  reject(new Error(`${peer} terminal snapshot timeout`));
@@ -1710,6 +1769,7 @@ async function fetchTerminalSnapshot(peer, params = {}) {
1710
1769
  const msg = JSON.parse(String(raw));
1711
1770
  if (msg.type !== 'snapshot') return;
1712
1771
  clearTimeout(timer);
1772
+ releaseTerminalControl(ws);
1713
1773
  try { ws.close(); } catch {}
1714
1774
  resolve(String(msg.data || ''));
1715
1775
  });
@@ -1796,12 +1856,12 @@ async function openCookieTerminalWebSocket(peer, sid, params = {}) {
1796
1856
  }
1797
1857
  return new Promise((resolve, reject) => {
1798
1858
  let settled = false;
1799
- const ws = new WebSocket(url, {
1859
+ const ws = trackTerminalControl(new WebSocket(url, {
1800
1860
  headers: {
1801
1861
  Cookie: `hcc_sid=${sid}`,
1802
1862
  Origin: new URL(baseUrl).origin
1803
1863
  }
1804
- });
1864
+ }));
1805
1865
  const timer = setTimeout(() => {
1806
1866
  if (settled) return;
1807
1867
  settled = true;
@@ -1922,23 +1982,25 @@ async function assertEvictionClosesCookieWebSocket(peer, params = {}) {
1922
1982
  async function expectResizeReplaceSnapshot(peer, marker) {
1923
1983
  await new Promise((resolve, reject) => {
1924
1984
  let sawSnapshot = false;
1925
- const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
1985
+ const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
1926
1986
  const timer = setTimeout(() => {
1927
1987
  try { ws.terminate(); } catch {}
1928
1988
  reject(new Error(`${peer} resize replace timeout`));
1929
1989
  }, TERMINAL_WEBSOCKET_TIMEOUT_MS);
1930
- ws.on('open', () => {
1931
- const result = hccMaybe(['inject', peer, `echo ${marker}`]);
1932
- if (result.status !== 0) reject(new Error(result.stderr || result.stdout || 'inject failed'));
1933
- });
1934
1990
  ws.on('message', (raw) => {
1935
1991
  const msg = JSON.parse(String(raw));
1936
1992
  if (msg.type === 'snapshot') {
1937
1993
  sawSnapshot = true;
1938
- ws.send(JSON.stringify({ type: 'resize', cols: 96, rows: 28 }));
1994
+ claimTerminalControl(ws).then(() => {
1995
+ ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r`,
1996
+ action_token: ws.hccActionToken, epoch: ws.hccControl.epoch }));
1997
+ ws.send(JSON.stringify({ type: 'resize', cols: 96, rows: 28,
1998
+ action_token: ws.hccActionToken, epoch: ws.hccControl.epoch }));
1999
+ }).catch(reject);
1939
2000
  }
1940
2001
  if (sawSnapshot && msg.type === 'replace' && String(msg.data || '').includes(marker)) {
1941
2002
  clearTimeout(timer);
2003
+ releaseTerminalControl(ws);
1942
2004
  ws.close();
1943
2005
  resolve();
1944
2006
  }
@@ -1953,8 +2015,7 @@ async function expectWebSocketInputVisible(peer, marker) {
1953
2015
  let sent = false;
1954
2016
  let sawMarkerAfterInput = false;
1955
2017
  let sawFrameAfterInput = false;
1956
- let actionToken = '';
1957
- const ws = new WebSocket(runtimeWsUrl(peer), runtimeWsOptions());
2018
+ const ws = trackTerminalControl(new WebSocket(runtimeWsUrl(peer), runtimeWsOptions()));
1958
2019
  const timer = setTimeout(() => {
1959
2020
  try { ws.terminate(); } catch {}
1960
2021
  reject(new Error(`${peer} websocket input visibility timeout`));
@@ -1964,10 +2025,11 @@ async function expectWebSocketInputVisible(peer, marker) {
1964
2025
  const data = String(msg.data || '');
1965
2026
  if (msg.type === 'snapshot') {
1966
2027
  sawSnapshot = true;
1967
- if (msg.action_token) actionToken = msg.action_token;
1968
2028
  if (!sent) {
1969
2029
  sent = true;
1970
- ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r`, action_token: actionToken }));
2030
+ claimTerminalControl(ws).then(() => ws.send(JSON.stringify({ type: 'input',
2031
+ data: `echo ${marker}\r`, action_token: ws.hccActionToken,
2032
+ epoch: ws.hccControl.epoch }))).catch(reject);
1971
2033
  }
1972
2034
  return;
1973
2035
  }
@@ -1977,6 +2039,7 @@ async function expectWebSocketInputVisible(peer, marker) {
1977
2039
  }
1978
2040
  if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
1979
2041
  clearTimeout(timer);
2042
+ releaseTerminalControl(ws);
1980
2043
  ws.close();
1981
2044
  resolve();
1982
2045
  }
@@ -1991,7 +2054,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
1991
2054
  for (const [key, value] of Object.entries(params)) {
1992
2055
  if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
1993
2056
  }
1994
- const ws = new WebSocket(url, runtimeWsOptions());
2057
+ const ws = trackTerminalControl(new WebSocket(url, runtimeWsOptions()));
1995
2058
  const rejectedMarker = `REJECTED_${testId}_${Math.random().toString(16).slice(2)}`;
1996
2059
  const acceptedMarker = `ACCEPTED_${testId}_${Math.random().toString(16).slice(2)}`;
1997
2060
  let snapshotToken = '';
@@ -2008,6 +2071,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
2008
2071
  if (settled) return;
2009
2072
  settled = true;
2010
2073
  clearTimeout(timer);
2074
+ releaseTerminalControl(ws);
2011
2075
  try { ws.close(); } catch {}
2012
2076
  if (err) reject(err);
2013
2077
  else resolve();
@@ -2018,7 +2082,7 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
2018
2082
  output += String(message.data || '');
2019
2083
  if (message.type === 'snapshot' && !snapshotToken) {
2020
2084
  snapshotToken = message.action_token || '';
2021
- const frame = { type: 'input', data: `echo ${rejectedMarker}\r` };
2085
+ const frame = { type: 'input', data: `echo ${rejectedMarker}\r`, epoch: ws.hccControl.epoch };
2022
2086
  if (suppliedToken !== undefined) frame.action_token = suppliedToken;
2023
2087
  ws.send(JSON.stringify(frame));
2024
2088
  setTimeout(() => {
@@ -2027,11 +2091,12 @@ async function assertTerminalInputTokenRejected(peer, suppliedToken, params = {}
2027
2091
  return;
2028
2092
  }
2029
2093
  checkedRejected = true;
2030
- ws.send(JSON.stringify({
2094
+ claimTerminalControl(ws).then(() => ws.send(JSON.stringify({
2031
2095
  type: 'input',
2032
2096
  data: `echo ${acceptedMarker}\r`,
2033
- action_token: snapshotToken
2034
- }));
2097
+ action_token: snapshotToken,
2098
+ epoch: ws.hccControl.epoch
2099
+ }))).catch(finish);
2035
2100
  }, 350);
2036
2101
  }
2037
2102
  if (checkedRejected && output.includes(acceptedMarker)) finish();
@@ -2538,7 +2603,8 @@ async function cookieSessionExpiryWorkflow() {
2538
2603
  ws.send(JSON.stringify({
2539
2604
  type: 'input',
2540
2605
  data: `echo ${marker}\r`,
2541
- action_token: ws.hccActionToken
2606
+ action_token: ws.hccActionToken,
2607
+ epoch: ws.hccControl.epoch
2542
2608
  }));
2543
2609
  const closeResult = await closed;
2544
2610
  if (closeResult.code !== 4001 || !closeResult.reason.includes('session expired')) {
@@ -2558,7 +2624,7 @@ async function cookieSessionExpiryWorkflow() {
2558
2624
  try { ws.terminate(); } catch {}
2559
2625
  }
2560
2626
  try {
2561
- await runtimeFetch(`/api/sessions/${encodeURIComponent(expirySessionId)}/stop`, { method: 'POST' }, { root });
2627
+ await stopControlledSession(expirySessionId);
2562
2628
  } catch {}
2563
2629
  await stopRuntime();
2564
2630
  }
@@ -2607,7 +2673,8 @@ async function webSecretRedactionWorkflow() {
2607
2673
  }, { root });
2608
2674
  if (!create.ok) fail(`redaction PTY create failed with status ${create.status}`);
2609
2675
  terminalWs = await openCookieTerminalWebSocket(sessionId, sid, { root });
2610
- terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28 }));
2676
+ terminalWs.send(JSON.stringify({ type: 'resize', cols: 90, rows: 28,
2677
+ action_token: terminalWs.hccActionToken, epoch: terminalWs.hccControl.epoch }));
2611
2678
 
2612
2679
  const badProject = await fetch(new URL(`/api/projects?token=${encodeURIComponent(secret)}`, runtime.base_url), {
2613
2680
  method: 'POST',
@@ -2652,7 +2719,7 @@ async function webSecretRedactionWorkflow() {
2652
2719
  try { terminalWs.terminate(); } catch {}
2653
2720
  }
2654
2721
  try {
2655
- await runtimeFetch(`/api/sessions/${encodeURIComponent(sessionId)}/stop`, { method: 'POST' }, { root });
2722
+ await stopControlledSession(sessionId);
2656
2723
  } catch {}
2657
2724
  await stopRuntime();
2658
2725
  }
@@ -4369,17 +4436,16 @@ async function multiProjectWebWorkflow() {
4369
4436
  }
4370
4437
  for (const expected of [
4371
4438
  'id="langSelect"',
4372
- "localStorage.getItem('hcc.lang')",
4373
- "localStorage.setItem('hcc.lang', lang)",
4374
- "document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'",
4375
- "zh: {",
4376
- "language: '语言'",
4377
- "projectState: '项目状态'",
4378
- "noSessionSelected: '未选择会话'",
4379
- "sendMessage: '发送消息'",
4380
- "peers: '协作方'",
4381
- "noPeers: '没有协作方。'",
4382
- "'status.active': '活跃'",
4439
+ 'window.hccUi',
4440
+ 'hccUi.language',
4441
+ 'hccUi.tr(key',
4442
+ '"language":"语言"',
4443
+ '"projectState":"项目状态"',
4444
+ '"noSessionSelected":"未选择会话"',
4445
+ '"sendMessage":"发送消息"',
4446
+ '"peers":"协作方"',
4447
+ '"noPeers":"没有协作方。"',
4448
+ '"status.active":"活跃"',
4383
4449
  "data-i18n=\"language\"",
4384
4450
  "data-i18n-placeholder=\"projectPathPlaceholder\"",
4385
4451
  "data-i18n-title=\"collapseSidebar\"",
@@ -4426,7 +4492,7 @@ async function multiProjectWebWorkflow() {
4426
4492
  'setPointerCapture',
4427
4493
  'sideIsCollapsed(opposite) ? 0',
4428
4494
  'Math.abs(delta) <= 3',
4429
- "localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
4495
+ "hccUi.safeSet('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
4430
4496
  'cursor: col-resize'
4431
4497
  ]) {
4432
4498
  if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
@@ -4436,13 +4502,13 @@ async function multiProjectWebWorkflow() {
4436
4502
  }
4437
4503
  for (const expected of [
4438
4504
  'function stateCardCollapsed(section)',
4439
- "localStorage.getItem('hcc.stateCard.' + section + '.collapsed')",
4505
+ "hccUi.safeGet('hcc.stateCard.' + section + '.collapsed')",
4440
4506
  'function stateCardHtml(section, title, count, bodyHtml)',
4441
4507
  'state-card-toggle',
4442
4508
  'aria-expanded=',
4443
4509
  'state-card-collapsed',
4444
4510
  'function bindStateCardToggles()',
4445
- "localStorage.setItem('hcc.stateCard.' + section + '.collapsed'",
4511
+ "hccUi.safeSet('hcc.stateCard.' + section + '.collapsed'",
4446
4512
  "stateCardHtml('automation'",
4447
4513
  "stateCardHtml('timeline'",
4448
4514
  "stateCardHtml('messages'",
@@ -4474,11 +4540,18 @@ async function multiProjectWebWorkflow() {
4474
4540
  for (const expected of [
4475
4541
  'term.onData((data) => {',
4476
4542
  'sendTerminalInput(data);',
4477
- 'function flushPendingTerminalInput(id, socket)',
4478
- "socket.send(JSON.stringify({ type: 'input', data, action_token: actionToken }))"
4543
+ "function sendTerminalInput(data, inputId = '')",
4544
+ 'epoch: controlEpoch()',
4545
+ "...(inputId ? { input_id: inputId } : {})",
4546
+ "msg.type === 'input_ack' && handoffStore.acknowledge(currentProject, id, msg.input_id)",
4547
+ 'handoffStore.uncertain(currentProject, id)',
4548
+ 'Connection recovery never replays terminal input or a draft.'
4479
4549
  ]) {
4480
4550
  if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
4481
4551
  }
4552
+ if (html.includes('flushPendingTerminalInput') || html.includes('pendingTerminalInput')) {
4553
+ fail('web terminal recovery must not buffer or replay terminal input');
4554
+ }
4482
4555
  if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
4483
4556
  fail('web state panel missing collaboration timeline or refresh routing');
4484
4557
  }
@@ -4530,14 +4603,7 @@ async function multiProjectWebWorkflow() {
4530
4603
  return json.session;
4531
4604
  };
4532
4605
  const stopSession = async (id, payload = null, projectRoot = root) => {
4533
- const options = payload
4534
- ? {
4535
- method: 'POST',
4536
- headers: { 'Content-Type': 'application/json' },
4537
- body: JSON.stringify(payload)
4538
- }
4539
- : { method: 'POST' };
4540
- const response = await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, options, { root: projectRoot });
4606
+ const response = await stopControlledSession(id, payload || {}, projectRoot);
4541
4607
  const json = await response.json();
4542
4608
  if (!response.ok) fail(`web provider session stop failed: ${JSON.stringify(json)}`);
4543
4609
  return json.session;
@@ -4685,7 +4751,8 @@ async function multiProjectWebWorkflow() {
4685
4751
  const inputResponse = await cookieRuntimeFetch(inputRoute, cookieAdmin, {
4686
4752
  method: 'POST',
4687
4753
  headers: { 'Content-Type': 'application/json' },
4688
- body: JSON.stringify({ data: `echo ${marker}\r` })
4754
+ body: JSON.stringify({ data: `echo ${marker}\r`, action_token: cookieAdminWs.hccActionToken,
4755
+ epoch: cookieAdminWs.hccControl.epoch })
4689
4756
  }, { root });
4690
4757
  if (!inputResponse.ok) fail(`same-origin cookie administrator input returned ${inputResponse.status}`);
4691
4758
  });
@@ -4708,7 +4775,7 @@ async function multiProjectWebWorkflow() {
4708
4775
  const cookieStop = await cookieRuntimeFetch(stopRoute, cookieAdmin, {
4709
4776
  method: 'POST',
4710
4777
  headers: { 'Content-Type': 'application/json' },
4711
- body: '{}'
4778
+ body: JSON.stringify({ action_token: cookieAdminWs.hccActionToken, epoch: cookieAdminWs.hccControl.epoch })
4712
4779
  }, { root });
4713
4780
  if (!cookieStop.ok) fail(`same-origin cookie administrator stop returned ${cookieStop.status}`);
4714
4781
  } finally {
@@ -4805,14 +4872,16 @@ async function multiProjectWebWorkflow() {
4805
4872
  kind: 'shell',
4806
4873
  command: 'bash --noprofile --norc'
4807
4874
  });
4808
- const encodedActionToken = await fetchSessionActionToken(encodedActionId, { root });
4875
+ const encodedActionChannel = await openSessionActionChannel(encodedActionId, { root });
4876
+ const encodedActionToken = encodedActionChannel.token;
4809
4877
  const siblingActionId = `web-action-sibling-${testId}`;
4810
4878
  const siblingActionSession = await startProvider({
4811
4879
  id: siblingActionId,
4812
4880
  kind: 'shell',
4813
4881
  command: 'bash --noprofile --norc'
4814
4882
  }, otherRoot);
4815
- const siblingActionToken = await fetchSessionActionToken(siblingActionId, { root: otherRoot });
4883
+ const siblingActionChannel = await openSessionActionChannel(siblingActionId, { root: otherRoot });
4884
+ const siblingActionToken = siblingActionChannel.token;
4816
4885
  if (!encodedActionToken || !siblingActionToken) {
4817
4886
  fail(`alternate sessions did not deliver action tokens:\n${JSON.stringify({ encodedActionSession, siblingActionSession }, null, 2)}`);
4818
4887
  }
@@ -4845,10 +4914,12 @@ async function multiProjectWebWorkflow() {
4845
4914
  }
4846
4915
  await closeTerminalWebSocket(secondManagedChannel.ws);
4847
4916
  await expectActionTokenRejected(secondManagedChannel.token, 'closed-connection');
4917
+ await claimTerminalControl(managedActionChannel.ws);
4918
+ await claimTerminalControl(encodedActionChannel.ws);
4848
4919
  const encodedPeerAction = await runtimeFetch(`/api/peers/${encodeURIComponent(encodedActionId)}/actions/heartbeat`, {
4849
4920
  method: 'POST',
4850
4921
  headers: { 'Content-Type': 'application/json' },
4851
- body: JSON.stringify({ action_token: encodedActionToken, renew_locks: false })
4922
+ body: JSON.stringify({ action_token: encodedActionToken, epoch: encodedActionChannel.epoch, renew_locks: false })
4852
4923
  }, { root });
4853
4924
  if (!encodedPeerAction.ok) {
4854
4925
  fail(`URL-encoded peer action rejected its own token:\n${await encodedPeerAction.text()}`);
@@ -4857,11 +4928,13 @@ async function multiProjectWebWorkflow() {
4857
4928
  const actionNext = await (await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionSession.peer_id || managedActionSession.id)}/actions/task-next`, {
4858
4929
  method: 'POST',
4859
4930
  headers: { 'Content-Type': 'application/json' },
4860
- body: JSON.stringify({ action_token: managedActionToken })
4931
+ body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch })
4861
4932
  }, { root })).json();
4862
4933
  if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
4863
4934
  fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
4864
4935
  }
4936
+ await closeTerminalWebSocket(encodedActionChannel.ws);
4937
+ await closeTerminalWebSocket(siblingActionChannel.ws);
4865
4938
  await stopSession(encodedActionSession.id);
4866
4939
  await stopSession(siblingActionSession.id, null, otherRoot);
4867
4940
  // Cookie-authenticated terminal sockets are tied to the opaque browser
@@ -4875,7 +4948,7 @@ async function multiProjectWebWorkflow() {
4875
4948
  method: 'POST',
4876
4949
  headers: { 'Content-Type': 'application/json' },
4877
4950
  body: JSON.stringify({
4878
- action_token: managedActionToken,
4951
+ action_token: managedActionToken, epoch: managedActionChannel.epoch,
4879
4952
  resource: webTtlResource,
4880
4953
  task: Number(taskMatch[1]),
4881
4954
  ttl: 60
@@ -4893,7 +4966,7 @@ async function multiProjectWebWorkflow() {
4893
4966
  method: 'POST',
4894
4967
  headers: { 'Content-Type': 'application/json' },
4895
4968
  body: JSON.stringify({
4896
- action_token: managedActionToken,
4969
+ action_token: managedActionToken, epoch: managedActionChannel.epoch,
4897
4970
  renew_locks: true,
4898
4971
  ...(ttlOverride === null ? {} : { ttl: ttlOverride })
4899
4972
  })
@@ -4937,7 +5010,7 @@ async function multiProjectWebWorkflow() {
4937
5010
  const unknownHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
4938
5011
  method: 'POST',
4939
5012
  headers: { 'Content-Type': 'application/json' },
4940
- body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
5013
+ body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
4941
5014
  }, { root });
4942
5015
  const unknownHeartbeat = await unknownHeartbeatResponse.json();
4943
5016
  if (!unknownHeartbeatResponse.ok || unknownHeartbeat.data?.renewed !== 0) {
@@ -4953,7 +5026,7 @@ async function multiProjectWebWorkflow() {
4953
5026
  const graceHeartbeatResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/heartbeat`, {
4954
5027
  method: 'POST',
4955
5028
  headers: { 'Content-Type': 'application/json' },
4956
- body: JSON.stringify({ action_token: managedActionToken, renew_locks: true })
5029
+ body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, renew_locks: true })
4957
5030
  }, { root });
4958
5031
  const graceHeartbeat = await graceHeartbeatResponse.json();
4959
5032
  const graceHeartbeatState = withMeshDb((db) => ({
@@ -5033,7 +5106,7 @@ async function multiProjectWebWorkflow() {
5033
5106
  method: 'POST',
5034
5107
  headers: { 'Content-Type': 'application/json' },
5035
5108
  body: JSON.stringify({
5036
- action_token: managedActionToken,
5109
+ action_token: managedActionToken, epoch: managedActionChannel.epoch,
5037
5110
  id: evidenceTaskId,
5038
5111
  reason: 'owner process identity was reused',
5039
5112
  policy: 'stale',
@@ -5094,7 +5167,7 @@ async function multiProjectWebWorkflow() {
5094
5167
  const liveLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
5095
5168
  method: 'POST',
5096
5169
  headers: { 'Content-Type': 'application/json' },
5097
- body: JSON.stringify({ action_token: managedActionToken, resource: liveExpiredResource, ttl: 60 })
5170
+ body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: liveExpiredResource, ttl: 60 })
5098
5171
  }, { root });
5099
5172
  const liveLockResult = await liveLockResponse.json();
5100
5173
  if (liveLockResponse.ok || liveLockResult.error?.code !== 'LOCK_HELD') {
@@ -5122,7 +5195,7 @@ async function multiProjectWebWorkflow() {
5122
5195
  const deadLockResponse = await runtimeFetch(`/api/peers/${encodeURIComponent(managedActionPeer)}/actions/lock-acquire`, {
5123
5196
  method: 'POST',
5124
5197
  headers: { 'Content-Type': 'application/json' },
5125
- body: JSON.stringify({ action_token: managedActionToken, resource: deadLockResource, ttl: 60 })
5198
+ body: JSON.stringify({ action_token: managedActionToken, epoch: managedActionChannel.epoch, resource: deadLockResource, ttl: 60 })
5126
5199
  }, { root });
5127
5200
  const deadLockResult = await deadLockResponse.json();
5128
5201
  if (!deadLockResponse.ok || !deadLockResult.ok || deadLockResult.data?.lock?.owner !== managedActionPeer) {
@@ -5151,7 +5224,7 @@ async function multiProjectWebWorkflow() {
5151
5224
  method: 'POST',
5152
5225
  headers: { 'Content-Type': 'application/json' },
5153
5226
  body: JSON.stringify({
5154
- action_token: managedActionToken,
5227
+ action_token: managedActionToken, epoch: managedActionChannel.epoch,
5155
5228
  resource: 'web/identity-spoof-lock',
5156
5229
  task: Number(ownerTaskMatch[1])
5157
5230
  })
@@ -6467,10 +6540,36 @@ function identityEnforcementWorkflow() {
6467
6540
  db2.close();
6468
6541
  }
6469
6542
 
6470
- hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
6543
+ const readRunOwner = () => {
6544
+ const ownerDb = new DatabaseSync(dbPath);
6545
+ try {
6546
+ return {
6547
+ peer: ownerDb.prepare('SELECT * FROM peers WHERE id = ?').get('real-peer'),
6548
+ binding: ownerDb.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get('real-peer') || null,
6549
+ runEvents: ownerDb.prepare("SELECT * FROM events WHERE actor = 'real-peer' AND type LIKE 'run.session.%' ORDER BY id").all()
6550
+ };
6551
+ } finally {
6552
+ ownerDb.close();
6553
+ }
6554
+ };
6555
+ // Earlier identity commands registered real-peer with the live regression
6556
+ // parent as owner. Correcting --peer must preserve that occupied identity.
6557
+ const ownerBeforeRun = readRunOwner();
6558
+ const occupiedRun = hccFromMaybe(['--json', 'run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: identityEnv });
6559
+ let occupiedError;
6560
+ try { occupiedError = JSON.parse(occupiedRun.stderr).error; } catch {}
6561
+ if (occupiedRun.status === 0 || occupiedError?.code !== 'PEER_SESSION_EXISTS' || occupiedError?.peer !== 'real-peer') {
6562
+ fail(`run did not protect the existing system peer owner:\nstdout=${occupiedRun.stdout}\nstderr=${occupiedRun.stderr}`);
6563
+ }
6564
+ if (JSON.stringify(readRunOwner()) !== JSON.stringify(ownerBeforeRun)) {
6565
+ fail('rejected run changed the existing system peer, binding, or run events');
6566
+ }
6567
+
6568
+ const runnerEnv = { ...identityEnv, HCC_PEER: 'real-runner' };
6569
+ hccFrom(['run', '--peer', 'fake-runner', '--', process.execPath, '-e', 'process.exit(0)'], identityRoot, { env: runnerEnv });
6471
6570
  const db3 = new DatabaseSync(dbPath);
6472
6571
  try {
6473
- const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-peer');
6572
+ const realRunner = db3.prepare('SELECT id, status FROM peers WHERE id = ?').get('real-runner');
6474
6573
  const fakeRunner = db3.prepare('SELECT id FROM peers WHERE id = ?').get('fake-runner');
6475
6574
  if (!realRunner || realRunner.status !== 'exited' || fakeRunner) {
6476
6575
  fail(`run did not enforce system peer identity:\n${JSON.stringify({ realRunner, fakeRunner }, null, 2)}`);
@@ -6478,6 +6577,9 @@ function identityEnforcementWorkflow() {
6478
6577
  } finally {
6479
6578
  db3.close();
6480
6579
  }
6580
+ if (JSON.stringify(readRunOwner()) !== JSON.stringify(ownerBeforeRun)) {
6581
+ fail('fresh run changed the occupied system peer owner');
6582
+ }
6481
6583
 
6482
6584
  hccFrom(['peer', 'stop', 'fake-target'], identityRoot, { env: identityEnv });
6483
6585
  const stopRequested = eventPayloads('peer.stop.requested', 5, dbPath)
@@ -6749,21 +6851,28 @@ async function syntaxAndHelp() {
6749
6851
  'function detectedPeerCanStop(peer)',
6750
6852
  "if (['exited', 'detached'].includes(status)) return false;",
6751
6853
  'const canStop = detectedPeerCanStop(p);',
6752
- '${canStop ?',
6854
+ "${dshCoordinationPeer(p) ? '' : canStop ?",
6753
6855
  'function providerSessionKnown(session)',
6754
6856
  "tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'))",
6755
- 'sessionCardDetailText(s)',
6857
+ "sessionDetailsHtml('managed:' + s.id",
6858
+ "[tr('runtime'), sessionRuntimeTarget(s)]",
6859
+ "[tr('providerSession'), sessionProvider(s) + ':' + (sessionProviderSessionValue(s) || tr('unknown'))]",
6860
+ "[tr('command'), s.command]",
6861
+ "[tr('cwd'), path]",
6756
6862
  'const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));',
6757
6863
  'const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));',
6758
- "localStorage.setItem('hcc.showStaleDetected'",
6864
+ "hccUi.safeSet('hcc.showStaleDetected'",
6759
6865
  'id="toggleStaleDetected"',
6760
- "if (e.target.closest('[data-action]')) return;",
6866
+ "if (e.target.closest('details, [data-action]')) return;",
6761
6867
  'id="stopKillLabel" data-i18n="dialog.killTmux"',
6762
6868
  'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
6763
6869
  'id="stopConfirmBtn" type="button" data-i18n="stop"'
6764
6870
  ]) {
6765
6871
  if (!webUiTemplateSource.includes(expected)) fail(`web display regression guard missing: ${expected}`);
6766
6872
  }
6873
+ // Execute the shipped renderer as well: a helper name alone does not prove
6874
+ // session identities remain visible or disclosure/action clicks stay local.
6875
+ run(process.execPath, ['--test', path.join(repoRoot, 'test', 'web-ui-session-display.test.mjs')]);
6767
6876
  if (webUiTemplateSource.includes("p.status === 'running' ?")) {
6768
6877
  fail('detected peer action rendering still depends on status === running instead of liveness');
6769
6878
  }
@@ -9271,8 +9380,9 @@ async function syntaxAndHelp() {
9271
9380
  };
9272
9381
  const html = webUiTemplate.webIndexHtml({ nonce: 'regression-template-nonce' });
9273
9382
  if (!html.includes('<!doctype html>') ||
9274
- !html.includes('<div class="app">') ||
9275
- !html.includes('<script src="/assets/xterm.js"></script>')) {
9383
+ !html.includes('<div class="app" data-view="terminal">') ||
9384
+ !html.includes('<script src="/assets/xterm.js"></script>') ||
9385
+ !html.includes('<script src="/assets/addon-fit.js"></script>')) {
9276
9386
  fail('web UI template module did not render the expected shell HTML');
9277
9387
  }
9278
9388
  const jsonReq = Readable.from(['{"ok":true}']);
@@ -9520,12 +9630,15 @@ async function syntaxAndHelp() {
9520
9630
  expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
9521
9631
  expectEqual(webRuntime.localRuntimeUrl(localRuntime, null), 'http://127.0.0.1:8789/?token=tok', 'localRuntimeUrl no project');
9522
9632
  const mainHelp = run(process.execPath, [hccBin, '--help']);
9523
- if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
9633
+ if (/^\s*setup(?:\s|$)/m.test(mainHelp) || mainHelp.includes('--web-managed')) {
9524
9634
  fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
9525
9635
  }
9526
9636
  if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
9527
9637
  fail(`main help missing update command:\n${mainHelp}`);
9528
9638
  }
9639
+ if (!mainHelp.includes(' dsh <setup|status|web> Connect DeepSeek Harness through project-local hooks')) {
9640
+ fail(`main help missing dsh command:\n${mainHelp}`);
9641
+ }
9529
9642
  if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
9530
9643
  fail(`main help missing state command:\n${mainHelp}`);
9531
9644
  }
@@ -10708,17 +10821,18 @@ function manualGcRetentionContractWorkflow() {
10708
10821
  }
10709
10822
  }
10710
10823
 
10711
- // Name-authoritative re-adoption + dead-peer reaper (session leak hardening).
10824
+ // Intentional detach persists; unpaused orphans recover by name and dead peers
10825
+ // are reaped without treating terminal detachment as process death.
10712
10826
  async function sessionRecoveryWorkflow() {
10713
10827
  if (!tmuxAvailable()) {
10714
10828
  log('session recovery skipped (tmux not installed)');
10715
10829
  return;
10716
10830
  }
10717
- log('session recovery: re-adopt orphan tmux + reap dead peer');
10831
+ log('session recovery: persistent detach + explicit resume + orphan adoption + dead peer reaping');
10718
10832
  if (!fs.existsSync(path.join(root, '.hello-cc', 'runtime.json'))) startRuntime();
10719
10833
  await waitRuntime();
10720
10834
 
10721
- // ── Default Stop (no kill) must not strand a live managed session ──
10835
+ // Default Stop pauses Web adoption while leaving the local terminal alive.
10722
10836
  const readoptPeer = 'readopt-shell';
10723
10837
  const pane = parsePane(hcc(['peer', 'start', readoptPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
10724
10838
  const sessionName = tmuxManagedSession(root, readoptPeer);
@@ -10750,22 +10864,57 @@ async function sessionRecoveryWorkflow() {
10750
10864
  detachedTakeover.status === 0) {
10751
10865
  fail(`live detached tmux pane did not block takeover: ${JSON.stringify(detachedRow)}\n${detachedTakeover.stdout}\n${detachedTakeover.stderr}`);
10752
10866
  }
10753
- // Within a few poll cycles the name-sweep must re-adopt the still-live session.
10867
+ const pauseEvent = eventPayloads('web.adoption.paused')
10868
+ .find((event) => event.payload?.peer === readoptPeer && event.payload?.pane === pane);
10869
+ if (!pauseEvent) fail('default detach did not persist its adoption pause');
10870
+ // A genuinely orphaned binding has no intentional pause. Simulate runtime
10871
+ // state loss while keeping the managed tmux name and process identity.
10872
+ const orphanPeer = 'unpaused-orphan-shell';
10873
+ const orphanPane = parsePane(hcc(['peer', 'start', orphanPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
10874
+ const orphanSessionName = tmuxManagedSession(root, orphanPeer);
10875
+ await stopRuntime();
10876
+ withMeshDb((db) => {
10877
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL WHERE peer = ?').run(orphanPeer);
10878
+ db.prepare("UPDATE peers SET status = 'detached' WHERE id = ?").run(orphanPeer);
10879
+ });
10880
+ startRuntime();
10881
+ await waitRuntime();
10754
10882
  await waitFor(() => {
10755
- const row = peerBindingRow(readoptPeer);
10756
- return Boolean(row && row.runtime_target === pane);
10757
- }, 'orphan managed tmux session re-adoption', 20000);
10883
+ const row = peerBindingRow(orphanPeer);
10884
+ return Boolean(row && row.runtime_target === orphanPane);
10885
+ }, 'unpaused orphan managed tmux session re-adoption', 20000);
10886
+ // Cover another auto-attach cycle after restart: it must not override a pause.
10887
+ await sleep(5500);
10888
+ binding = peerBindingRow(readoptPeer);
10889
+ if (!binding || binding.runtime_target !== null ||
10890
+ runMaybe('tmux', ['has-session', '-t', sessionName]).status !== 0) {
10891
+ fail(`runtime restart or polling overrode an intentional live-terminal detach:\n${JSON.stringify(binding, null, 2)}`);
10892
+ }
10893
+ const pausedSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
10894
+ if ((pausedSessions.sessions || []).some((session) => session.id === readoptPeer && session.status === 'running')) {
10895
+ fail(`intentionally detached session was automatically re-adopted:\n${JSON.stringify(pausedSessions, null, 2)}`);
10896
+ }
10897
+ const resumeResponse = await runtimeFetch('/api/sessions/attach', {
10898
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
10899
+ body: JSON.stringify({ id: readoptPeer, kind: 'shell', pane, force: true })
10900
+ }, { root });
10901
+ const resumed = await resumeResponse.json();
10902
+ if (!resumeResponse.ok || resumed.session?.pane !== pane) {
10903
+ fail(`explicit attach did not resume the same detached terminal:\n${JSON.stringify(resumed, null, 2)}`);
10904
+ }
10758
10905
  const restoredStatus = withMeshDb((db) => db.prepare('SELECT status FROM peers WHERE id = ?').get(readoptPeer)?.status);
10759
- if (restoredStatus !== 'running') fail(`re-adopted peer not marked running (status=${restoredStatus})`);
10760
- // Re-enterability: the runtime now exposes the session again.
10906
+ if (restoredStatus !== 'running') fail(`explicitly resumed peer not marked running (status=${restoredStatus})`);
10907
+ // Both explicit resume and automatic orphan recovery expose a live session.
10761
10908
  const sessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
10762
10909
  if (!(sessions.sessions || []).some((s) => s.id === readoptPeer && s.status === 'running')) {
10763
- fail(`re-adopted session not exposed by /api/sessions:\n${JSON.stringify(sessions, null, 2)}`);
10910
+ fail(`explicitly resumed session not exposed by /api/sessions:\n${JSON.stringify(sessions, null, 2)}`);
10911
+ }
10912
+ if (!(sessions.sessions || []).some((session) => session.id === orphanPeer && session.pane === orphanPane && session.status === 'running')) {
10913
+ fail(`unpaused orphan session not exposed by /api/sessions:\n${JSON.stringify(sessions, null, 2)}`);
10764
10914
  }
10765
10915
  hcc(['task', 'takeover', '--peer', 'human', '--id', detachedTaskId, '--reason', 'detached evidence cleanup', '--force']);
10766
10916
  hcc(['task', 'update', '--peer', 'human', '--id', detachedTaskId, '--status', 'abandoned', '--summary', 'detached evidence cleanup']);
10767
- // Destroying the live session is the real teardown path (CLI peer stop has
10768
- // no kill flag; the web API's kill_tmux is the equivalent). Kill it directly
10917
+ // Destroying the live session is the real teardown path. Kill it directly
10769
10918
  // so it does not leak into the final tmux-session leak assertion.
10770
10919
  runMaybe('tmux', ['kill-session', '-t', sessionName]);
10771
10920
  if (runMaybe('tmux', ['has-session', '-t', sessionName]).status === 0) {
@@ -10775,6 +10924,11 @@ async function sessionRecoveryWorkflow() {
10775
10924
  db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(readoptPeer);
10776
10925
  db.prepare('DELETE FROM peers WHERE id = ?').run(readoptPeer);
10777
10926
  });
10927
+ runMaybe('tmux', ['kill-session', '-t', orphanSessionName]);
10928
+ withMeshDb((db) => {
10929
+ db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(orphanPeer);
10930
+ db.prepare('DELETE FROM peers WHERE id = ?').run(orphanPeer);
10931
+ });
10778
10932
 
10779
10933
  // ── A peer whose process is gone must be reaped, not stuck at 'running' ──
10780
10934
  const reaped = spawnSync('sleep', ['10'], { stdio: 'ignore' });