@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
package/lib/terminal/tmux.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
import { spawnSync } from 'node:child_process';
|
|
4
5
|
import { CliError } from '../shared/errors.mjs';
|
|
@@ -16,9 +17,16 @@ export function runTmux(args, opts = {}) {
|
|
|
16
17
|
const result = spawnSync('tmux', args, {
|
|
17
18
|
encoding: 'utf8',
|
|
18
19
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
19
|
-
input: opts.input || ''
|
|
20
|
+
input: opts.input || '',
|
|
21
|
+
// xx-02: a hung tmux (D-state pane, wedged pty, stuck filesystem) must not
|
|
22
|
+
// freeze the shared runtime event loop forever. Normal tmux commands finish
|
|
23
|
+
// in milliseconds; a 5s bound only trips when tmux is genuinely stuck.
|
|
24
|
+
timeout: opts.timeout ?? 5000
|
|
20
25
|
});
|
|
21
26
|
if (result.error) {
|
|
27
|
+
if (result.error.code === 'ETIMEDOUT' || result.killed) {
|
|
28
|
+
throw new CliError('TMUX_ERROR', `tmux timed out after ${opts.timeout ?? 5000}ms: tmux ${args.join(' ')}`);
|
|
29
|
+
}
|
|
22
30
|
throw new CliError('TMUX_ERROR', `tmux failed: ${result.error.message}`);
|
|
23
31
|
}
|
|
24
32
|
if (result.status !== 0) {
|
|
@@ -28,14 +36,20 @@ export function runTmux(args, opts = {}) {
|
|
|
28
36
|
return result.stdout || '';
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
function tmuxInstallHint(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
return 'Install tmux with
|
|
39
|
+
export function tmuxInstallHint({
|
|
40
|
+
platform = process.platform,
|
|
41
|
+
pathExists = fs.existsSync
|
|
42
|
+
} = {}) {
|
|
43
|
+
if (platform === 'darwin') return 'Install tmux with: brew install tmux';
|
|
44
|
+
if (platform === 'linux') {
|
|
45
|
+
if (pathExists('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
|
|
46
|
+
if (pathExists('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
|
|
47
|
+
if (pathExists('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
|
|
48
|
+
if (pathExists('/etc/fedora-release') || pathExists('/etc/redhat-release')) {
|
|
49
|
+
return 'Install tmux with: sudo dnf install -y tmux (or sudo yum install -y tmux on older systems)';
|
|
50
|
+
}
|
|
51
|
+
if (pathExists('/etc/SuSE-release')) return 'Install tmux with: sudo zypper --non-interactive install tmux';
|
|
52
|
+
return 'Install tmux with your Linux system package manager (apt/apt-get, dnf/yum, apk, pacman, or zypper).';
|
|
39
53
|
}
|
|
40
54
|
return 'Install tmux and make sure it is on PATH.';
|
|
41
55
|
}
|
|
@@ -46,6 +60,36 @@ function commandExists(name) {
|
|
|
46
60
|
}).status === 0;
|
|
47
61
|
}
|
|
48
62
|
|
|
63
|
+
export function tmuxInstallPlan({
|
|
64
|
+
platform = process.platform,
|
|
65
|
+
commandAvailable = commandExists,
|
|
66
|
+
isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (platform === 'darwin') {
|
|
69
|
+
return commandAvailable('brew')
|
|
70
|
+
? { manager: 'brew', commands: ['brew install tmux'] }
|
|
71
|
+
: null;
|
|
72
|
+
}
|
|
73
|
+
if (platform !== 'linux') return null;
|
|
74
|
+
|
|
75
|
+
if (!isRoot && !commandAvailable('sudo')) return null;
|
|
76
|
+
const sudo = isRoot ? '' : 'sudo ';
|
|
77
|
+
if (commandAvailable('apt-get')) {
|
|
78
|
+
return {
|
|
79
|
+
manager: 'apt-get',
|
|
80
|
+
commands: [`${sudo}apt-get update`, `${sudo}apt-get install -y tmux`]
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (commandAvailable('dnf')) return { manager: 'dnf', commands: [`${sudo}dnf install -y tmux`] };
|
|
84
|
+
if (commandAvailable('yum')) return { manager: 'yum', commands: [`${sudo}yum install -y tmux`] };
|
|
85
|
+
if (commandAvailable('apk')) return { manager: 'apk', commands: [`${sudo}apk add tmux`] };
|
|
86
|
+
if (commandAvailable('pacman')) return { manager: 'pacman', commands: [`${sudo}pacman -S --noconfirm tmux`] };
|
|
87
|
+
if (commandAvailable('zypper')) {
|
|
88
|
+
return { manager: 'zypper', commands: [`${sudo}zypper --non-interactive install tmux`] };
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
49
93
|
function runInstallCommand(command) {
|
|
50
94
|
const result = spawnSync('sh', ['-lc', command], {
|
|
51
95
|
encoding: 'utf8',
|
|
@@ -62,22 +106,16 @@ export function tryInstallTmux() {
|
|
|
62
106
|
if (process.env.HCC_NO_AUTO_INSTALL_TMUX === '1') {
|
|
63
107
|
return { ok: false, output: 'automatic tmux installation disabled by HCC_NO_AUTO_INSTALL_TMUX=1' };
|
|
64
108
|
}
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return runInstallCommand(`${sudo}apt-get install -y tmux`);
|
|
74
|
-
}
|
|
75
|
-
if (commandExists('dnf')) return runInstallCommand(`${sudo}dnf install -y tmux`);
|
|
76
|
-
if (commandExists('yum')) return runInstallCommand(`${sudo}yum install -y tmux`);
|
|
77
|
-
if (commandExists('apk')) return runInstallCommand(`${sudo}apk add tmux`);
|
|
78
|
-
if (commandExists('pacman')) return runInstallCommand(`${sudo}pacman -S --noconfirm tmux`);
|
|
109
|
+
const plan = tmuxInstallPlan();
|
|
110
|
+
if (!plan) return { ok: false, output: 'no supported package manager with sufficient privileges found' };
|
|
111
|
+
|
|
112
|
+
const output = [];
|
|
113
|
+
for (const command of plan.commands) {
|
|
114
|
+
const result = runInstallCommand(command);
|
|
115
|
+
if (result.output) output.push(result.output);
|
|
116
|
+
if (!result.ok) return { ok: false, output: output.join('\n') };
|
|
79
117
|
}
|
|
80
|
-
return { ok:
|
|
118
|
+
return { ok: true, output: output.join('\n') };
|
|
81
119
|
}
|
|
82
120
|
|
|
83
121
|
export function ensureTmuxAvailable({ autoInstall = true } = {}) {
|
|
@@ -119,6 +157,16 @@ export function tmuxKillSession(sessionName) {
|
|
|
119
157
|
}
|
|
120
158
|
}
|
|
121
159
|
|
|
160
|
+
export function tmuxListSessionNames() {
|
|
161
|
+
const result = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
164
|
+
});
|
|
165
|
+
// No tmux server (status 1, "no server running") or no sessions → empty list.
|
|
166
|
+
if (result.status !== 0) return [];
|
|
167
|
+
return (result.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
168
|
+
}
|
|
169
|
+
|
|
122
170
|
export function tmuxSessionEnvironmentValue(sessionName, key) {
|
|
123
171
|
const result = spawnSync('tmux', ['show-environment', '-t', sessionName, key], {
|
|
124
172
|
encoding: 'utf8',
|
|
@@ -139,11 +187,57 @@ export function tmuxProviderState(sessionName) {
|
|
|
139
187
|
return tmuxSessionEnvironmentValue(sessionName, PROVIDER_STATE_ENV);
|
|
140
188
|
}
|
|
141
189
|
|
|
190
|
+
function managedProjectRoot(root) {
|
|
191
|
+
try {
|
|
192
|
+
return fs.realpathSync(root);
|
|
193
|
+
} catch {
|
|
194
|
+
return path.resolve(root);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function legacyManagedProjectRoot(root) {
|
|
199
|
+
return path.resolve(root);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function tmuxManagedSessionPrefix(ctx) {
|
|
203
|
+
return `hcc-${shortHash(managedProjectRoot(ctx.root))}-`;
|
|
204
|
+
}
|
|
205
|
+
|
|
142
206
|
export function tmuxManagedSessionName(ctx, peerId) {
|
|
143
|
-
const name =
|
|
207
|
+
const name = `${tmuxManagedSessionPrefix(ctx)}${sanitizePeerPart(peerId, 'peer')}`;
|
|
144
208
|
return name.slice(0, 80);
|
|
145
209
|
}
|
|
146
210
|
|
|
211
|
+
function legacyTmuxManagedSessionPrefix(ctx) {
|
|
212
|
+
return `hcc-${shortHash(legacyManagedProjectRoot(ctx.root))}-`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function legacyTmuxManagedSessionName(ctx, peerId) {
|
|
216
|
+
return `${legacyTmuxManagedSessionPrefix(ctx)}${sanitizePeerPart(peerId, 'peer')}`.slice(0, 80);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rootsIdentifySameProject(left, right) {
|
|
220
|
+
try {
|
|
221
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot = null) {
|
|
228
|
+
if (!sessionName) return false;
|
|
229
|
+
if (sessionName.startsWith(tmuxManagedSessionPrefix(ctx))) return true;
|
|
230
|
+
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
231
|
+
sessionName.startsWith(legacyTmuxManagedSessionPrefix({ root: sessionRoot }));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function tmuxManagedSessionNameMatches(ctx, sessionName, peerId, sessionRoot = null) {
|
|
235
|
+
if (!sessionName) return false;
|
|
236
|
+
if (sessionName === tmuxManagedSessionName(ctx, peerId)) return true;
|
|
237
|
+
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
238
|
+
sessionName === legacyTmuxManagedSessionName({ root: sessionRoot }, peerId);
|
|
239
|
+
}
|
|
240
|
+
|
|
147
241
|
export function tmuxEnvironmentArgs(env) {
|
|
148
242
|
const args = [];
|
|
149
243
|
for (const [key, value] of Object.entries(env)) {
|
|
@@ -158,9 +252,19 @@ export function tmuxEnvironmentArgs(env) {
|
|
|
158
252
|
export function tmuxPaneInfo(targetPane = null) {
|
|
159
253
|
const pane = targetPane || process.env.TMUX_PANE;
|
|
160
254
|
if (!pane) throw new CliError('BAD_ARGS', 'Missing --pane and current terminal is not inside tmux');
|
|
161
|
-
|
|
255
|
+
// NOTE: do not use tab characters as field separators — older tmux (e.g. 3.3a
|
|
256
|
+
// on Debian) replaces whitespace in display-message/list-panes -F output with
|
|
257
|
+
// '_', which silently corrupts parsing and produces invalid pane targets. '|'
|
|
258
|
+
// is preserved across tmux versions and never appears in pane_id/pid/command/
|
|
259
|
+
// dead; the path is placed last and rejoined so a '|' inside a path is safe.
|
|
260
|
+
const format = '#{pane_id}|#{pane_dead}|#{pane_pid}|#{pane_current_command}|#{pane_current_path}';
|
|
162
261
|
const out = runTmux(['display-message', '-p', '-t', pane, format]).trimEnd();
|
|
163
|
-
const
|
|
262
|
+
const parts = out.split('|');
|
|
263
|
+
const paneId = parts[0];
|
|
264
|
+
const paneDead = parts[1];
|
|
265
|
+
const pid = parts[2];
|
|
266
|
+
const currentCommand = parts[3];
|
|
267
|
+
const currentPath = parts.slice(4).join('|');
|
|
164
268
|
if (!paneId) throw new CliError('TMUX_ERROR', `Cannot resolve tmux pane: ${pane}`);
|
|
165
269
|
return {
|
|
166
270
|
pane: paneId,
|
package/lib/tmux.mjs
CHANGED
|
@@ -8,7 +8,11 @@ export {
|
|
|
8
8
|
tmuxHasSession,
|
|
9
9
|
tmuxKillSession,
|
|
10
10
|
tmuxLaunchFingerprint,
|
|
11
|
+
tmuxListSessionNames,
|
|
11
12
|
tmuxManagedSessionName,
|
|
13
|
+
tmuxManagedSessionNameMatches,
|
|
14
|
+
tmuxManagedSessionPrefix,
|
|
15
|
+
tmuxManagedSessionPrefixMatches,
|
|
12
16
|
tmuxPaneInfo,
|
|
13
17
|
tmuxProviderState,
|
|
14
18
|
tmuxSendLiteral,
|
package/lib/ui/help.mjs
CHANGED
|
@@ -41,7 +41,8 @@ Commands:
|
|
|
41
41
|
lock <subcommand> Acquire, renew, release, and list advisory locks
|
|
42
42
|
handoff <subcommand> Create and list handoffs
|
|
43
43
|
event tail Show recent coordination events
|
|
44
|
-
gc [--older-than DAYS] [--yes] Clean
|
|
44
|
+
gc [--older-than DAYS] [--history] [--yes] Clean technical state; opt in to history cleanup
|
|
45
|
+
doctor Run a read-only database health check (integrity, sizes, row counts)
|
|
45
46
|
Internal:
|
|
46
47
|
hook Hook entrypoint used by Claude/Codex
|
|
47
48
|
find-root Shim helper
|
|
@@ -51,7 +52,8 @@ Environment:
|
|
|
51
52
|
HCC_ROOT Override project root
|
|
52
53
|
HCC_DB Override database path
|
|
53
54
|
HCC_PEER Default peer id; inferred automatically when absent
|
|
54
|
-
HCC_WEB_TOKEN
|
|
55
|
+
HCC_WEB_TOKEN Set an explicit web access token
|
|
56
|
+
HCC_RUNTIME_CA CA file for HCC_RUNTIME_URL=https endpoints
|
|
55
57
|
`);
|
|
56
58
|
},
|
|
57
59
|
|
|
@@ -65,9 +67,10 @@ Usage:
|
|
|
65
67
|
${cliName} task create --title TEXT --parent N [--team-role ROLE]
|
|
66
68
|
${cliName} task list [--status pending|claimed|running|review|blocked|done|abandoned] [--peer ID] [--all]
|
|
67
69
|
${cliName} task claim [--peer ID] --id N[,N] [--id N] [--ids N,N] [--force]
|
|
68
|
-
${cliName} task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--stale-after SECONDS]
|
|
70
|
+
${cliName} task takeover [--peer ID] --id N --reason TEXT [--policy any|blocked|stale|blocked-or-stale] [--force] [--stale-after SECONDS]
|
|
69
71
|
${cliName} task next [--peer ID] [--force] [--count N]
|
|
70
72
|
${cliName} task update [--peer ID] --id N --status STATUS [--summary TEXT] [--body TEXT] [--to ID]
|
|
73
|
+
${cliName} task running|review|blocked|abandoned [--peer ID] --id N [--summary TEXT] [--body TEXT] [--to ID]
|
|
71
74
|
${cliName} task done [--peer ID] --id N --summary TEXT
|
|
72
75
|
|
|
73
76
|
Default task list shows all project tasks that are not done or abandoned.
|
|
@@ -83,9 +86,12 @@ dispatch; use --force only when intentionally injecting while the target already
|
|
|
83
86
|
owns another active task.
|
|
84
87
|
Use task takeover when explicitly taking over a non-complete task from another
|
|
85
88
|
owner; it records the previous owner, requires a reason, and notifies them.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
The default policy is blocked-or-stale: takeover is allowed only when the task
|
|
90
|
+
is blocked or its owner is stale/exited, so an active owner's task is not taken
|
|
91
|
+
silently. Use --force (or --policy any) to take over an active owner's task
|
|
92
|
+
explicitly; use --policy blocked or --stale-after to narrow the precondition.
|
|
93
|
+
task running, task review, task blocked, and task abandoned are shortcuts for
|
|
94
|
+
task update --status STATUS.
|
|
89
95
|
|
|
90
96
|
If --peer/--from and HCC_PEER are absent, hcc auto-joins the current terminal
|
|
91
97
|
as a stable project-local peer.
|
|
@@ -275,14 +281,21 @@ a dry-run; deletion requires --yes.
|
|
|
275
281
|
console.log(`${cliName} gc
|
|
276
282
|
|
|
277
283
|
Usage:
|
|
278
|
-
${cliName} gc [--older-than DAYS] [--yes]
|
|
284
|
+
${cliName} gc [--older-than DAYS] [--history] [--yes]
|
|
279
285
|
|
|
280
286
|
Examples:
|
|
281
287
|
${cliName} gc
|
|
282
288
|
${cliName} gc --older-than 14 --yes
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
${cliName} gc --older-than 14 --history --yes
|
|
290
|
+
|
|
291
|
+
Clean stale technical state for the current project: dead peers, expired locks,
|
|
292
|
+
and old buffer files. Events, completed tasks, messages, and eligible handoffs
|
|
293
|
+
are retained unless --history is supplied.
|
|
294
|
+
Handoffs linked to open tasks are always preserved. By default this is a dry-run.
|
|
295
|
+
Technical deletion requires --yes, and
|
|
296
|
+
history deletion requires both --history and --yes.
|
|
297
|
+
--older-than must be zero or greater. During clock grace,
|
|
298
|
+
every age-based category is deferred.
|
|
286
299
|
`);
|
|
287
300
|
},
|
|
288
301
|
|
|
@@ -427,7 +440,7 @@ automatically when provider CLIs are available on PATH.
|
|
|
427
440
|
console.log(`${cliName} web
|
|
428
441
|
|
|
429
442
|
Usage:
|
|
430
|
-
${cliName} web [--host HOST] [--port N] [--token TEXT] [--local] [--no-token] [--no-discover] [--no-guidance]
|
|
443
|
+
${cliName} web [--host HOST] [--port N] [--token TEXT] [--local] [--tls] [--trust-proxy --proxy-origin ORIGIN] [--no-token] [--no-discover] [--no-guidance]
|
|
431
444
|
|
|
432
445
|
Examples:
|
|
433
446
|
${cliName} web
|
|
@@ -439,10 +452,35 @@ installs Claude/Codex hooks and shims, ensures tmux is available, starts the
|
|
|
439
452
|
browser terminal console as a background runtime, prints the URL, PID, runtime
|
|
440
453
|
file, and log file, then returns the terminal to you.
|
|
441
454
|
|
|
442
|
-
By default, the web runtime listens on 0.0.0.0 and
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
local/test environment.
|
|
455
|
+
By default, the web runtime listens on 0.0.0.0 and generates a token scoped to
|
|
456
|
+
that runtime. Use HCC_WEB_TOKEN or --token to set an explicit token, --local to
|
|
457
|
+
bind only to 127.0.0.1, or --no-token only for a trusted
|
|
458
|
+
local/test environment. The web console exposes a writable terminal, so binding
|
|
459
|
+
a non-loopback address prints an exposure warning, and --no-token is refused on
|
|
460
|
+
any non-loopback bind (use --local with it). Prefer --local plus an SSH tunnel
|
|
461
|
+
(ssh -L 8787:127.0.0.1:8787) or a TLS reverse proxy for remote access. Use --tls
|
|
462
|
+
to serve over HTTPS/WSS with an auto-generated self-signed certificate (stored
|
|
463
|
+
under ~/.hello-cc/tls, reused across restarts); this encrypts the token and
|
|
464
|
+
terminal stream against passive LAN sniffing. The browser will warn about the
|
|
465
|
+
self-signed certificate until you trust it manually. For an HTTPS reverse proxy
|
|
466
|
+
terminating TLS on the same machine, use --trust-proxy together with a fixed
|
|
467
|
+
--proxy-origin https://host[:port]. Forwarded scheme and host must match that
|
|
468
|
+
origin and arrive from loopback. For HCC_RUNTIME_URL=https endpoints with a
|
|
469
|
+
private CA, set HCC_RUNTIME_CA to the CA file.
|
|
470
|
+
|
|
471
|
+
The printed ?token= URL is the one-time entry: the browser exchanges it for an
|
|
472
|
+
HttpOnly session cookie and is redirected to a token-free URL. After that,
|
|
473
|
+
subsequent requests and the address bar no longer carry the token (the initial
|
|
474
|
+
URL remains in browser history as the access link). CLI API requests use the
|
|
475
|
+
Authorization header; query tokens are accepted only by the browser exchange.
|
|
476
|
+
|
|
477
|
+
Version 1.0.0 uses schema v7 with a verified pre-migration backup and no
|
|
478
|
+
downgrade support, full-session provider peer IDs without legacy remapping, and
|
|
479
|
+
Runtime API v2. Liveness follows process evidence; only unknown evidence gets a
|
|
480
|
+
120-second grace. hcc gc keeps history unless --history is explicit.
|
|
481
|
+
|
|
482
|
+
Accepted risks: the default 0.0.0.0 listener is plaintext HTTP for trusted LANs,
|
|
483
|
+
and an authenticated browser may select any existing server directory.
|
|
446
484
|
|
|
447
485
|
After hcc web, plain claude/codex commands started from a new shell are wrapped
|
|
448
486
|
as local tmux-backed terminals. Existing ordinary terminals can communicate
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const API_VERSION = 2;
|
|
2
|
+
|
|
3
|
+
function versionResult(value) {
|
|
4
|
+
if (Array.isArray(value)) return { ok: false, version: null };
|
|
5
|
+
const text = String(value ?? '').trim();
|
|
6
|
+
if (!/^\d+$/.test(text)) return { ok: false, version: null };
|
|
7
|
+
const version = Number(text);
|
|
8
|
+
if (!Number.isSafeInteger(version)) return { ok: false, version: null };
|
|
9
|
+
return { ok: version === API_VERSION, version };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function readHttpApiVersion(req) {
|
|
13
|
+
return versionResult(req?.headers?.['x-hcc-api-version']);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readWebSocketApiVersion(url) {
|
|
17
|
+
return versionResult(url?.searchParams?.get('api_version'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function apiVersionUnsupportedBody() {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: {
|
|
24
|
+
code: 'API_VERSION_UNSUPPORTED',
|
|
25
|
+
message: `Runtime API version ${API_VERSION} is required`,
|
|
26
|
+
supported_version: API_VERSION
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function withRuntimeApiVersionHeader(headers = {}) {
|
|
32
|
+
const versioned = {};
|
|
33
|
+
const entries = typeof headers?.entries === 'function'
|
|
34
|
+
? headers.entries()
|
|
35
|
+
: Object.entries(headers || {});
|
|
36
|
+
for (const [name, value] of entries) {
|
|
37
|
+
if (String(name).toLowerCase() === 'x-hcc-api-version') continue;
|
|
38
|
+
versioned[name] = value;
|
|
39
|
+
}
|
|
40
|
+
versioned['X-HCC-API-Version'] = String(API_VERSION);
|
|
41
|
+
return versioned;
|
|
42
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Auto-attach of detected provider peers, extracted from lib/web/runtime-main.mjs.
|
|
2
|
+
// Scans tmux panes once per tick, reconciles DB bindings against live panes,
|
|
3
|
+
// and attaches hcc-detected provider sessions to the web runtime.
|
|
4
|
+
|
|
5
|
+
import { inferPeerKind } from '../integrations/providers.mjs';
|
|
6
|
+
import { runTmux } from '../tmux.mjs';
|
|
7
|
+
import { inspectProviderProcess } from '../integrations/peers/processes.mjs';
|
|
8
|
+
|
|
9
|
+
export function createAutoAttach(deps) {
|
|
10
|
+
const {
|
|
11
|
+
ctx, sessions,
|
|
12
|
+
connectWebProject, now, addEvent,
|
|
13
|
+
reconcileRunningPeerBindings,
|
|
14
|
+
ACTIVE_PEER_TTL, redactedLogText, sessionsForProject,
|
|
15
|
+
attachTmuxSession,
|
|
16
|
+
latestHookProviderSession,
|
|
17
|
+
resolveSessionPeerId,
|
|
18
|
+
reAdoptOrphanManagedTmuxSessions, reapDeadPeersForProject
|
|
19
|
+
} = deps;
|
|
20
|
+
|
|
21
|
+
function listTmuxPanesOnce() {
|
|
22
|
+
// Use '|' not '\t' as the field separator: older tmux (3.3a) replaces
|
|
23
|
+
// whitespace in -F output with '_'. Path is last and rejoined so a '|'
|
|
24
|
+
// inside a path is safe. tmux-8: session_name is NOT included here — a
|
|
25
|
+
// user-created session name could contain '|', corrupting the parse. Callers
|
|
26
|
+
// that need the session name use tmuxSessionNameForPane(pane.pane) which
|
|
27
|
+
// queries a single field with no separator issue.
|
|
28
|
+
const result = runTmux([
|
|
29
|
+
'list-panes',
|
|
30
|
+
'-a',
|
|
31
|
+
'-F',
|
|
32
|
+
'#{pane_id}|#{pane_pid}|#{pane_current_command}|#{pane_current_path}'
|
|
33
|
+
]);
|
|
34
|
+
return result.trim().split('\n')
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((line) => {
|
|
37
|
+
const parts = line.split('|');
|
|
38
|
+
return {
|
|
39
|
+
pane: parts[0],
|
|
40
|
+
pid: Number.parseInt(parts[1] || '0', 10) || null,
|
|
41
|
+
command: parts[2] || '',
|
|
42
|
+
sessionName: '',
|
|
43
|
+
cwd: parts.slice(3).join('|') || ''
|
|
44
|
+
};
|
|
45
|
+
})
|
|
46
|
+
.filter((pane) => pane.pane && pane.pid);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function attachedTmuxState(projectCtx, db) {
|
|
50
|
+
const peers = new Set();
|
|
51
|
+
const panes = new Set();
|
|
52
|
+
for (const session of sessionsForProject(projectCtx)) {
|
|
53
|
+
if (session.status !== 'running') continue;
|
|
54
|
+
peers.add(session.id);
|
|
55
|
+
if (session.peerId) peers.add(session.peerId);
|
|
56
|
+
const resolved = resolveSessionPeerId(db, session);
|
|
57
|
+
if (resolved) peers.add(resolved);
|
|
58
|
+
if (session.type === 'tmux' && session.pane) panes.add(session.pane);
|
|
59
|
+
}
|
|
60
|
+
return { peers, panes };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function reconcileRunningBindings(projectCtx = ctx, panes = null) {
|
|
64
|
+
const db = connectWebProject(projectCtx);
|
|
65
|
+
try {
|
|
66
|
+
const tmuxPanes = Array.isArray(panes) ? panes : listTmuxPanesOnce();
|
|
67
|
+
return reconcileRunningPeerBindings(db, projectCtx, {
|
|
68
|
+
panes: tmuxPanes,
|
|
69
|
+
inspectProcess: inspectProviderProcess,
|
|
70
|
+
latestProviderSessionForPeer: (peer) => latestHookProviderSession(db, peer),
|
|
71
|
+
addEvent,
|
|
72
|
+
now
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
} finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let autoAttachScanInFlight = false;
|
|
82
|
+
|
|
83
|
+
function scanAndAttachDetectedPeers() {
|
|
84
|
+
if (autoAttachScanInFlight) return;
|
|
85
|
+
autoAttachScanInFlight = true;
|
|
86
|
+
let db = null;
|
|
87
|
+
try {
|
|
88
|
+
db = connectWebProject(ctx);
|
|
89
|
+
|
|
90
|
+
// Always-on, TTL-independent hygiene: recover managed tmux sessions left
|
|
91
|
+
// alive (default Stop = detach-without-kill, or across a restart) and reap
|
|
92
|
+
// peers whose process is gone. Sweep first so its live set protects
|
|
93
|
+
// still-alive sessions from being reaped.
|
|
94
|
+
const liveManaged = reAdoptOrphanManagedTmuxSessions(ctx);
|
|
95
|
+
reapDeadPeersForProject(ctx, liveManaged, db);
|
|
96
|
+
|
|
97
|
+
const rows = db.prepare(`
|
|
98
|
+
SELECT p.id, p.kind, p.role, p.worktree, p.pid,
|
|
99
|
+
b.peer AS binding_peer, b.provider, b.provider_session_id,
|
|
100
|
+
b.provider_session_name, b.resume_mode, b.resume_arg,
|
|
101
|
+
b.command AS binding_command, b.runtime_session_id,
|
|
102
|
+
b.runtime_target
|
|
103
|
+
FROM peers p
|
|
104
|
+
LEFT JOIN peer_bindings b ON b.peer = p.id
|
|
105
|
+
WHERE p.status IN ('running', 'working', 'busy')
|
|
106
|
+
AND p.pid IS NOT NULL
|
|
107
|
+
AND p.last_seen_at >= ? - ?
|
|
108
|
+
ORDER BY p.last_seen_at DESC
|
|
109
|
+
`).all(now(), ACTIVE_PEER_TTL);
|
|
110
|
+
if (!rows.length) return;
|
|
111
|
+
|
|
112
|
+
let panes;
|
|
113
|
+
try {
|
|
114
|
+
panes = listTmuxPanesOnce();
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!panes.length) return;
|
|
119
|
+
const paneByPid = new Map();
|
|
120
|
+
for (const pane of panes) {
|
|
121
|
+
if (!paneByPid.has(pane.pid)) paneByPid.set(pane.pid, pane);
|
|
122
|
+
}
|
|
123
|
+
reconcileRunningPeerBindings(db, ctx, {
|
|
124
|
+
panes,
|
|
125
|
+
inspectProcess: inspectProviderProcess,
|
|
126
|
+
latestProviderSessionForPeer: (peer) => latestHookProviderSession(db, peer),
|
|
127
|
+
addEvent,
|
|
128
|
+
now
|
|
129
|
+
});
|
|
130
|
+
const attached = attachedTmuxState(ctx, db);
|
|
131
|
+
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
if (attached.peers.has(row.id)) continue;
|
|
134
|
+
if (row.binding_peer && attached.peers.has(row.binding_peer)) continue;
|
|
135
|
+
if (row.runtime_session_id && attached.peers.has(row.runtime_session_id)) continue;
|
|
136
|
+
|
|
137
|
+
const pane = paneByPid.get(Number(row.pid));
|
|
138
|
+
if (!pane || attached.panes.has(pane.pane)) continue;
|
|
139
|
+
|
|
140
|
+
const binding = row.binding_peer ? {
|
|
141
|
+
provider: row.provider,
|
|
142
|
+
provider_session_id: row.provider_session_id,
|
|
143
|
+
provider_session_name: row.provider_session_name,
|
|
144
|
+
resume_mode: row.resume_mode,
|
|
145
|
+
resume_arg: row.resume_arg,
|
|
146
|
+
command: row.binding_command,
|
|
147
|
+
runtime_session_id: row.runtime_session_id || row.id,
|
|
148
|
+
runtime_target: pane.pane
|
|
149
|
+
} : null;
|
|
150
|
+
try {
|
|
151
|
+
const session = attachTmuxSession({
|
|
152
|
+
id: row.id,
|
|
153
|
+
pane: pane.pane,
|
|
154
|
+
kind: row.kind || inferPeerKind(row.id, null, pane.command),
|
|
155
|
+
role: row.role || 'peer',
|
|
156
|
+
cwd: pane.cwd || row.worktree || ctx.root,
|
|
157
|
+
command: row.binding_command || null,
|
|
158
|
+
force: false,
|
|
159
|
+
projectCtx: ctx,
|
|
160
|
+
binding,
|
|
161
|
+
autoAttach: true,
|
|
162
|
+
auditActorPeer: 'web-runtime',
|
|
163
|
+
auditSource: 'runtime'
|
|
164
|
+
});
|
|
165
|
+
attached.peers.add(row.id);
|
|
166
|
+
if (session.peerId) attached.peers.add(session.peerId);
|
|
167
|
+
attached.panes.add(pane.pane);
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] auto-attach scan failed: ${err?.message || err}`));
|
|
172
|
+
} finally {
|
|
173
|
+
try { db?.close(); } catch {}
|
|
174
|
+
autoAttachScanInFlight = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
scanAndAttachDetectedPeers();
|
|
179
|
+
const autoAttachPoller = setInterval(scanAndAttachDetectedPeers, 5000);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
autoAttachPoller, listTmuxPanesOnce, attachedTmuxState,
|
|
183
|
+
reconcileRunningBindings, scanAndAttachDetectedPeers
|
|
184
|
+
};
|
|
185
|
+
}
|