@logicseek/hello-cc 0.1.8 → 1.0.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 (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +15 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +139 -15
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +61 -8
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +232 -29
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +143 -7
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5646 -262
@@ -0,0 +1,166 @@
1
+ // tmux terminal streaming/snapshot helpers extracted from cmdWeb.
2
+ // These manage the raw pipe-pane FIFO → WebSocket data path and the
3
+ // capture-pane fallback for tmux-backed sessions.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { runTmux, tmuxCapturePane, tmuxCursorInfo, tmuxCursorPayload } from '../terminal/tmux.mjs';
9
+ import { CliError } from '../shared/errors.mjs';
10
+
11
+ export function createTmuxStream({
12
+ broadcast,
13
+ now,
14
+ refreshPeerIoHeartbeat,
15
+ bufferDirectory,
16
+ withBufferDirectoryLease,
17
+ shellQuoteArg,
18
+ ctx
19
+ }) {
20
+ function cursorEscape(payload) {
21
+ if (!payload) return '';
22
+ return '[' + (payload.row + 1) + ';' + (payload.col + 1) + 'H' +
23
+ (payload.visible ? '[?25h' : '[?25l');
24
+ }
25
+
26
+ function tmuxSnapshot(session) {
27
+ const captured = tmuxCapturePane(session.pane);
28
+ return captured + cursorEscape(tmuxCursorPayload(captured, tmuxCursorInfo(session.pane)));
29
+ }
30
+
31
+ function refreshTmuxSnapshot(session) {
32
+ if (session.type !== 'tmux' || !session.pane) return session.buffer || '';
33
+ try {
34
+ session.buffer = tmuxSnapshot(session);
35
+ } catch {
36
+ // Keep the previous buffer if the pane disappears during capture.
37
+ }
38
+ return session.buffer || '';
39
+ }
40
+
41
+ function scheduleTmuxReplace(session) {
42
+ if (session.type !== 'tmux' || !session.pane) return;
43
+ if (session.replaceTimer) clearTimeout(session.replaceTimer);
44
+ session.replaceTimer = setTimeout(() => {
45
+ session.replaceTimer = null;
46
+ session.lastBroadcastTime = Date.now();
47
+ broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
48
+ }, 80);
49
+ }
50
+
51
+ function startTmuxReplacePoller(session, warning = null) {
52
+ if (session.replacePoller) clearInterval(session.replacePoller);
53
+ if (warning) {
54
+ session.warning = {
55
+ code: 'TMUX_STREAM_FALLBACK',
56
+ message: `Raw tmux streaming unavailable; using capture polling: ${warning}`
57
+ };
58
+ }
59
+ session.lastBroadcastTime = Date.now();
60
+ session.replacePoller = setInterval(() => {
61
+ if (session.status !== 'running') return;
62
+ if (Date.now() - (session.lastBroadcastTime || 0) > 4000) {
63
+ session.lastBroadcastTime = Date.now();
64
+ broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });
65
+ }
66
+ }, 1600);
67
+ }
68
+
69
+ function startTmuxStream(session) {
70
+ const safePane = String(session.pane).replace(/[^A-Za-z0-9_-]/g, '');
71
+ const safeId = String(session.id).replace(/[^A-Za-z0-9_.-]/g, '_');
72
+ const streamDirectory = bufferDirectory(session.ctx || ctx);
73
+ fs.mkdirSync(streamDirectory, { recursive: true });
74
+ const pipeFile = path.join(streamDirectory, `tmux-${safePane}-${safeId}.pipe`);
75
+ session.pipeFile = pipeFile;
76
+ try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
77
+ try {
78
+ session.buffer = tmuxSnapshot(session);
79
+ } catch {}
80
+
81
+ try {
82
+ withBufferDirectoryLease(path.dirname(pipeFile), () => {
83
+ fs.rmSync(pipeFile, { force: true });
84
+ const mkfifo = spawnSync('mkfifo', [pipeFile], {
85
+ encoding: 'utf8',
86
+ stdio: ['ignore', 'pipe', 'pipe']
87
+ });
88
+ if (mkfifo.status !== 0) {
89
+ const message = (mkfifo.stderr || mkfifo.stdout || '').trim() || 'mkfifo failed';
90
+ throw new CliError('TMUX_STREAM_ERROR', message);
91
+ }
92
+ try { fs.chmodSync(pipeFile, 0o600); } catch {}
93
+ session.streamFd = fs.openSync(pipeFile, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
94
+ });
95
+ } catch (err) {
96
+ const message = err?.message || String(err);
97
+ stopTmuxStream(session);
98
+ startTmuxReplacePoller(session, message);
99
+ return 'poll';
100
+ }
101
+
102
+ try {
103
+ runTmux(['pipe-pane', '-t', session.pane, `cat > ${shellQuoteArg(pipeFile)}`]);
104
+ } catch (err) {
105
+ stopTmuxStream(session);
106
+ startTmuxReplacePoller(session, err?.message || String(err));
107
+ return 'poll';
108
+ }
109
+ session.streamPoller = setInterval(() => {
110
+ try {
111
+ if (session.streamFd === null || session.streamFd === undefined) return;
112
+ const chunks = [];
113
+ for (;;) {
114
+ const buf = Buffer.alloc(65536);
115
+ let bytes = 0;
116
+ try {
117
+ bytes = fs.readSync(session.streamFd, buf, 0, buf.length, null);
118
+ } catch (err) {
119
+ if (['EAGAIN', 'EWOULDBLOCK'].includes(err?.code)) break;
120
+ throw err;
121
+ }
122
+ if (bytes <= 0) break;
123
+ chunks.push(buf.subarray(0, bytes));
124
+ if (bytes < buf.length) break;
125
+ }
126
+ if (!chunks.length) return;
127
+ const data = Buffer.concat(chunks).toString();
128
+ session.buffer += data;
129
+ if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
130
+ session.lastBroadcastTime = Date.now();
131
+ broadcast(session, { type: 'data', data });
132
+ refreshPeerIoHeartbeat(session);
133
+ } catch {
134
+ if (session.streamFd !== null && session.streamFd !== undefined) {
135
+ try { fs.closeSync(session.streamFd); } catch {}
136
+ session.streamFd = null;
137
+ }
138
+ }
139
+ }, 40);
140
+
141
+ startTmuxReplacePoller(session);
142
+ return 'stream';
143
+ }
144
+
145
+ function stopTmuxStream(session) {
146
+ if (session.streamPoller) { clearInterval(session.streamPoller); session.streamPoller = null; }
147
+ if (session.replacePoller) { clearInterval(session.replacePoller); session.replacePoller = null; }
148
+ if (session.replaceTimer) { clearTimeout(session.replaceTimer); session.replaceTimer = null; }
149
+ if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }
150
+ try { runTmux(['pipe-pane', '-t', session.pane]); } catch {}
151
+ if (session.streamFd !== null && session.streamFd !== undefined) {
152
+ try { fs.closeSync(session.streamFd); } catch {}
153
+ session.streamFd = null;
154
+ }
155
+ if (session.pipeFile) {
156
+ try {
157
+ withBufferDirectoryLease(path.dirname(session.pipeFile), () => {
158
+ fs.unlinkSync(session.pipeFile);
159
+ });
160
+ } catch {}
161
+ session.pipeFile = null;
162
+ }
163
+ }
164
+
165
+ return { cursorEscape, tmuxSnapshot, refreshTmuxSnapshot, scheduleTmuxReplace, startTmuxReplacePoller, startTmuxStream, stopTmuxStream };
166
+ }
@@ -1,4 +1,15 @@
1
- export function webIndexHtml() {
1
+ import { API_VERSION } from './api-version.mjs';
2
+
3
+ function nonceAttribute(nonce) {
4
+ const value = String(nonce || '');
5
+ if (!/^[A-Za-z0-9_-]{16,}$/.test(value)) {
6
+ throw new Error('A valid CSP nonce is required');
7
+ }
8
+ return ` nonce="${value}"`;
9
+ }
10
+
11
+ export function webIndexHtml({ nonce } = {}) {
12
+ const inlineScriptNonce = nonceAttribute(nonce);
2
13
  return `<!doctype html>
3
14
  <html lang="en">
4
15
  <head>
@@ -169,10 +180,12 @@ export function webIndexHtml() {
169
180
  white-space: nowrap;
170
181
  }
171
182
  #connState {
172
- flex: 0 0 92px;
173
- max-width: 92px;
183
+ flex: 0 1 72px;
184
+ max-width: 72px;
174
185
  text-align: right;
175
186
  }
187
+ .brand-actions { display: flex; align-items: center; gap: 4px; min-width: 0; }
188
+ .brand-actions button { width: 26px; height: 26px; padding: 0; font-size: 14px; }
176
189
  .form {
177
190
  padding: 12px;
178
191
  display: grid;
@@ -534,7 +547,10 @@ export function webIndexHtml() {
534
547
  <strong>hello-cc</strong>
535
548
  <div class="path" id="rootPath"></div>
536
549
  </div>
537
- <span id="connState" data-i18n="conn.offline">offline</span>
550
+ <div class="brand-actions">
551
+ <span id="connState" data-i18n="conn.offline">offline</span>
552
+ <button id="logoutBtn" type="button" data-i18n-title="logout" data-i18n-aria="logout" title="Log out" aria-label="Log out">↪</button>
553
+ </div>
538
554
  </div>
539
555
  <div class="form" style="padding-top:10px;padding-bottom:10px">
540
556
  <div class="grid2">
@@ -624,16 +640,25 @@ export function webIndexHtml() {
624
640
  </div>
625
641
 
626
642
  <script src="/assets/xterm.js"></script>
627
- <script>
643
+ <script${inlineScriptNonce}>
628
644
  const initialParams = new URLSearchParams(location.search);
645
+ sessionStorage.removeItem('hcc_logged_out');
629
646
  const token = initialParams.get('token') || '';
630
- const headers = token ? { Authorization: 'Bearer ' + token } : {};
647
+ const runtimeApiVersion = ${API_VERSION};
648
+ const headers = {
649
+ 'X-HCC-API-Version': String(runtimeApiVersion),
650
+ ...(token ? { Authorization: 'Bearer ' + token } : {})
651
+ };
631
652
  let currentProject = initialParams.get('project') || initialParams.get('root') || '';
632
653
  let projects = [];
633
654
  let sessionKindFilter = initialParams.get('kind') || 'all';
634
655
  let sessions = []; // managed (PTY) sessions
635
656
  let detected = []; // coordination-only peers (from hooks/watcher)
636
657
  let resumableCache = []; // provider sessions available to resume (from /api/resumable)
658
+ // The current token belongs to one terminal WebSocket connection and is
659
+ // replaced on every reconnect.
660
+ const sessionActionTokens = new Map();
661
+ const pendingTerminalInput = new Map();
637
662
  let active = null; // active managed session id
638
663
  let activeDetected = null; // active detected peer id
639
664
  let activeType = 'managed'; // 'managed' | 'detected'
@@ -643,6 +668,9 @@ export function webIndexHtml() {
643
668
  let lastStateRoot = '';
644
669
  let ws = null;
645
670
  let wsReconnectTimer = null;
671
+ // ui-4: after logout (or repeated failed reconnects) the WS must not loop
672
+ // forever every 2s against a server that now rejects the upgrade.
673
+ let wsReconnectFailures = 0;
646
674
  let terminalHasContent = false;
647
675
  let terminalLastDataAt = 0;
648
676
  let terminalLastResizeAt = 0;
@@ -681,6 +709,7 @@ export function webIndexHtml() {
681
709
  'action.renewHeartbeat': 'renew heartbeat',
682
710
  'action.reregister': 're-register peer',
683
711
  'action.runStatusTerminal': 'run status in terminal',
712
+ logout: 'log out',
684
713
  actionResult: 'Action Result',
685
714
  stop: 'stop',
686
715
  projectState: 'Project State',
@@ -804,6 +833,7 @@ export function webIndexHtml() {
804
833
  'action.renewHeartbeat': '续期心跳',
805
834
  'action.reregister': '重新注册协作方',
806
835
  'action.runStatusTerminal': '在终端运行状态命令',
836
+ logout: '退出登录',
807
837
  actionResult: '操作结果',
808
838
  stop: '停止',
809
839
  projectState: '项目状态',
@@ -998,20 +1028,39 @@ export function webIndexHtml() {
998
1028
  const cols = Math.max(60, Math.floor((el.clientWidth - 16) / w));
999
1029
  const rows = Math.max(10, Math.floor((el.clientHeight - 16) / h));
1000
1030
  term.resize(cols, rows);
1001
- if (ws && ws.readyState === WebSocket.OPEN) {
1031
+ const actionToken = sessionActionTokens.get(active) || '';
1032
+ if (ws && ws.readyState === WebSocket.OPEN && actionToken) {
1002
1033
  terminalLastResizeAt = Date.now();
1003
- ws.send(JSON.stringify({ type: 'resize', cols, rows }));
1034
+ ws.send(JSON.stringify({ type: 'resize', cols, rows, action_token: actionToken }));
1004
1035
  }
1005
1036
  }
1006
1037
  window.addEventListener('resize', resizeTerm);
1007
1038
  setTimeout(resizeTerm, 80);
1008
1039
 
1009
1040
  term.onData((data) => {
1010
- if (ws && ws.readyState === WebSocket.OPEN) {
1011
- ws.send(JSON.stringify({ type: 'input', data }));
1012
- }
1041
+ sendTerminalInput(data);
1013
1042
  });
1014
1043
 
1044
+ function sendTerminalInput(data) {
1045
+ if (!active || !ws) return;
1046
+ const actionToken = sessionActionTokens.get(active) || '';
1047
+ if (ws.readyState === WebSocket.OPEN && actionToken) {
1048
+ ws.send(JSON.stringify({ type: 'input', data, action_token: actionToken }));
1049
+ return;
1050
+ }
1051
+ if (ws.readyState !== WebSocket.CONNECTING && ws.readyState !== WebSocket.OPEN) return;
1052
+ const queued = (pendingTerminalInput.get(active) || '') + String(data || '');
1053
+ pendingTerminalInput.set(active, queued.slice(-65536));
1054
+ }
1055
+
1056
+ function flushPendingTerminalInput(id, socket) {
1057
+ const data = pendingTerminalInput.get(id) || '';
1058
+ const actionToken = sessionActionTokens.get(id) || '';
1059
+ if (!data || active !== id || ws !== socket || socket.readyState !== WebSocket.OPEN || !actionToken) return;
1060
+ pendingTerminalInput.delete(id);
1061
+ socket.send(JSON.stringify({ type: 'input', data, action_token: actionToken }));
1062
+ }
1063
+
1015
1064
  async function api(path, options = {}) {
1016
1065
  const res = await fetch(path + (path.includes('?') ? '&' : '?') + requestQuery().slice(1), {
1017
1066
  ...options,
@@ -1612,18 +1661,25 @@ export function webIndexHtml() {
1612
1661
 
1613
1662
  function openWs(id) {
1614
1663
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1615
- ws = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery());
1616
- ws.onopen = () => {
1617
- resizeTerm();
1664
+ sessionActionTokens.delete(id);
1665
+ const socket = new WebSocket(proto + '://' + location.host + '/ws/terminal/' + encodeURIComponent(id) + requestQuery({ api_version: runtimeApiVersion }));
1666
+ ws = socket;
1667
+ socket.onopen = () => {
1668
+ wsReconnectFailures = 0;
1618
1669
  connText('attached');
1619
1670
  };
1620
- ws.onmessage = (event) => {
1671
+ socket.onmessage = (event) => {
1621
1672
  const msg = JSON.parse(event.data);
1622
1673
  const pinned = terminalPinned();
1623
1674
  // The server streams the tmux pane's raw output, so xterm renders
1624
1675
  // incrementally (no reset/redraw → no flicker) and the program's own
1625
1676
  // escape sequences carry the cursor.
1626
- if (msg.type === 'snapshot') { writeTerminalSnapshot(msg.data || '', true); }
1677
+ if (msg.type === 'snapshot') {
1678
+ if (msg.action_token) sessionActionTokens.set(id, msg.action_token);
1679
+ writeTerminalSnapshot(msg.data || '', true);
1680
+ resizeTerm();
1681
+ flushPendingTerminalInput(id, socket);
1682
+ }
1627
1683
  if (msg.type === 'data') {
1628
1684
  terminalHasContent = true;
1629
1685
  terminalLastDataAt = Date.now();
@@ -1634,7 +1690,21 @@ export function webIndexHtml() {
1634
1690
  }
1635
1691
  if (msg.type === 'exit') { refreshSessions().catch(console.error); }
1636
1692
  };
1637
- ws.onclose = () => {
1693
+ socket.onclose = (event) => {
1694
+ sessionActionTokens.delete(id);
1695
+ if (ws !== socket) return;
1696
+ // ui-4: no reconnect after logout, and stop after repeated failures
1697
+ // (e.g. the session cookie was revoked in another tab) instead of
1698
+ // looping every 2s forever.
1699
+ if (event.code === 4001 || sessionStorage.getItem('hcc_logged_out') === '1') {
1700
+ connText('signed out');
1701
+ return;
1702
+ }
1703
+ wsReconnectFailures += 1;
1704
+ if (wsReconnectFailures > 5) {
1705
+ connText('offline');
1706
+ return;
1707
+ }
1638
1708
  connText('reconnecting');
1639
1709
  // Auto-reconnect if session is still in the list and running
1640
1710
  wsReconnectTimer = setTimeout(() => {
@@ -1702,8 +1772,7 @@ export function webIndexHtml() {
1702
1772
 
1703
1773
  // ── Helpers ───────────────────────────────────────────────────────────
1704
1774
  function sendLine(text) {
1705
- if (!active || !ws || ws.readyState !== WebSocket.OPEN) return;
1706
- ws.send(JSON.stringify({ type: 'input', data: text + '\\r' }));
1775
+ sendTerminalInput(text + '\\r');
1707
1776
  }
1708
1777
 
1709
1778
  // ── Start session form ────────────────────────────────────────────────
@@ -1734,7 +1803,9 @@ export function webIndexHtml() {
1734
1803
  return {
1735
1804
  session,
1736
1805
  peerId: sessionPeerId(session) || active,
1737
- actionToken: session.action_token || ''
1806
+ // The action token is delivered via the terminal WS snapshot frame, not
1807
+ // the session list (net-05); read it from the per-session store.
1808
+ actionToken: sessionActionTokens.get(active) || ''
1738
1809
  };
1739
1810
  }
1740
1811
 
@@ -1838,6 +1909,15 @@ export function webIndexHtml() {
1838
1909
  closeActionsMenu();
1839
1910
  });
1840
1911
  });
1912
+ document.getElementById('logoutBtn').addEventListener('click', async () => {
1913
+ closeActionsMenu();
1914
+ const res = await fetch('/logout', { method: 'POST', headers });
1915
+ if (!res.ok) throw new Error('logout failed');
1916
+ // Stop reconnect only after the server confirms revocation. A failed
1917
+ // logout must not poison a later authenticated page in this tab.
1918
+ sessionStorage.setItem('hcc_logged_out', '1');
1919
+ location.replace('/');
1920
+ });
1841
1921
  document.getElementById('actionResultClose').addEventListener('click', () => {
1842
1922
  document.getElementById('actionResult').hidden = true;
1843
1923
  });
@@ -2055,3 +2135,55 @@ export function webIndexHtml() {
2055
2135
  </body>
2056
2136
  </html>`;
2057
2137
  }
2138
+
2139
+ // Minimal fallback page shown when a browser visits the bare URL with no valid
2140
+ // session cookie (e.g. a bookmarked URL after the runtime restarted). The main
2141
+ // flow is the token-in-URL exchange performed by the server; this form lets the
2142
+ // user re-enter the token once to get a fresh cookie.
2143
+ export function webLoginPage({ nonce } = {}) {
2144
+ const inlineScriptNonce = nonceAttribute(nonce);
2145
+ return `<!doctype html>
2146
+ <html lang="en">
2147
+ <head>
2148
+ <meta charset="utf-8">
2149
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2150
+ <title>hello-cc — sign in</title>
2151
+ <style>
2152
+ body { font-family: system-ui, sans-serif; background: #101418; color: #e8eaed; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
2153
+ .card { background: #1a2027; border: 1px solid #2c343d; border-radius: 10px; padding: 28px; width: 320px; }
2154
+ h1 { font-size: 18px; margin: 0 0 16px; }
2155
+ label { display: block; font-size: 13px; margin-bottom: 6px; color: #aab3bd; }
2156
+ input { width: 100%; box-sizing: border-box; padding: 9px 10px; border-radius: 6px; border: 1px solid #39424c; background: #101418; color: #e8eaed; font-size: 14px; }
2157
+ button { margin-top: 16px; width: 100%; padding: 9px; border-radius: 6px; border: 0; background: #40c4aa; color: #06211b; font-weight: 600; font-size: 14px; cursor: pointer; }
2158
+ button:hover { background: #57d4bc; }
2159
+ .err { color: #ff8b8b; font-size: 13px; margin-top: 10px; min-height: 18px; }
2160
+ </style>
2161
+ </head>
2162
+ <body>
2163
+ <div class="card">
2164
+ <h1>hello-cc</h1>
2165
+ <label for="tok">Access token</label>
2166
+ <input id="tok" type="password" autocomplete="off" placeholder="paste the ?token= value" autofocus>
2167
+ <button id="go">Sign in</button>
2168
+ <div class="err" id="err"></div>
2169
+ </div>
2170
+ <script${inlineScriptNonce}>
2171
+ const input = document.getElementById('tok');
2172
+ const err = document.getElementById('err');
2173
+ async function submit() {
2174
+ const token = input.value.trim();
2175
+ if (!token) return;
2176
+ err.textContent = '';
2177
+ const res = await fetch('/login', {
2178
+ method: 'POST',
2179
+ headers: { 'Content-Type': 'application/json' },
2180
+ body: JSON.stringify({ token })
2181
+ });
2182
+ if (res.ok) { location.href = '/'; } else { err.textContent = 'Invalid token.'; }
2183
+ }
2184
+ document.getElementById('go').addEventListener('click', submit);
2185
+ input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
2186
+ </script>
2187
+ </body>
2188
+ </html>`;
2189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "0.1.8",
3
+ "version": "1.0.0",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,10 @@
27
27
  "release:github": "node ./scripts/github-release.mjs",
28
28
  "release:github:dry-run": "node ./scripts/github-release.mjs --dry-run",
29
29
  "smoke": "node ./bin/hcc.mjs --help",
30
- "test": "node ./scripts/regression.mjs",
30
+ "pretest": "npm run test:audit && npm run test:unit",
31
+ "test": "npm run test:regression",
32
+ "test:audit": "node ./scripts/check-free-identifiers.mjs",
33
+ "test:unit": "node --test test/*.test.mjs",
31
34
  "test:regression": "node ./scripts/regression.mjs"
32
35
  },
33
36
  "repository": {