@logicseek/hello-cc 0.1.9 → 1.0.1
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.
- package/CHANGELOG.md +136 -0
- package/README.md +50 -3
- package/README.zh-CN.md +48 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/guide.md +97 -3
- package/docs/guide.zh-CN.md +89 -2
- package/lib/cli/app.mjs +644 -0
- package/lib/cli/commands/coordination.mjs +249 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +1002 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +76 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +249 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +222 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +48 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +63 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +377 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +250 -0
- package/lib/terminal/tmux.mjs +131 -27
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +183 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +717 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +335 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +6 -3
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5404 -273
- package/docs/architecture-split-review.md +0 -217
- package/docs/layout-split-stack-audit.md +0 -298
|
@@ -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
|
+
}
|
package/lib/web/ui-template.mjs
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
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
|
|
173
|
-
max-width:
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
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
|
-
|
|
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') {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
-
"
|
|
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": {
|
|
@@ -53,7 +56,7 @@
|
|
|
53
56
|
"dependencies": {
|
|
54
57
|
"@xterm/addon-fit": "^0.11.0",
|
|
55
58
|
"@xterm/xterm": "^6.0.0",
|
|
56
|
-
"node-pty": "
|
|
59
|
+
"node-pty": "1.2.0-beta.15",
|
|
57
60
|
"ws": "^8.21.0"
|
|
58
61
|
},
|
|
59
62
|
"license": "Apache-2.0"
|