@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.
- package/CHANGELOG.md +117 -0
- package/README.md +26 -4
- package/README.zh-CN.md +22 -3
- package/bin/hcc.mjs +3 -5622
- 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 +16 -5
- package/docs/commands.zh-CN.md +16 -5
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +53 -5
- package/docs/guide.zh-CN.md +46 -3
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -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 +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -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 +15 -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 +139 -15
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -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 +61 -8
- 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 +232 -29
- 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 +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- 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 +143 -7
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -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 +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5646 -262
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) {
|
|
@@ -119,6 +127,16 @@ export function tmuxKillSession(sessionName) {
|
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
export function tmuxListSessionNames() {
|
|
131
|
+
const result = spawnSync('tmux', ['list-sessions', '-F', '#{session_name}'], {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
134
|
+
});
|
|
135
|
+
// No tmux server (status 1, "no server running") or no sessions → empty list.
|
|
136
|
+
if (result.status !== 0) return [];
|
|
137
|
+
return (result.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
|
|
122
140
|
export function tmuxSessionEnvironmentValue(sessionName, key) {
|
|
123
141
|
const result = spawnSync('tmux', ['show-environment', '-t', sessionName, key], {
|
|
124
142
|
encoding: 'utf8',
|
|
@@ -139,11 +157,57 @@ export function tmuxProviderState(sessionName) {
|
|
|
139
157
|
return tmuxSessionEnvironmentValue(sessionName, PROVIDER_STATE_ENV);
|
|
140
158
|
}
|
|
141
159
|
|
|
160
|
+
function managedProjectRoot(root) {
|
|
161
|
+
try {
|
|
162
|
+
return fs.realpathSync(root);
|
|
163
|
+
} catch {
|
|
164
|
+
return path.resolve(root);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function legacyManagedProjectRoot(root) {
|
|
169
|
+
return path.resolve(root);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function tmuxManagedSessionPrefix(ctx) {
|
|
173
|
+
return `hcc-${shortHash(managedProjectRoot(ctx.root))}-`;
|
|
174
|
+
}
|
|
175
|
+
|
|
142
176
|
export function tmuxManagedSessionName(ctx, peerId) {
|
|
143
|
-
const name =
|
|
177
|
+
const name = `${tmuxManagedSessionPrefix(ctx)}${sanitizePeerPart(peerId, 'peer')}`;
|
|
144
178
|
return name.slice(0, 80);
|
|
145
179
|
}
|
|
146
180
|
|
|
181
|
+
function legacyTmuxManagedSessionPrefix(ctx) {
|
|
182
|
+
return `hcc-${shortHash(legacyManagedProjectRoot(ctx.root))}-`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function legacyTmuxManagedSessionName(ctx, peerId) {
|
|
186
|
+
return `${legacyTmuxManagedSessionPrefix(ctx)}${sanitizePeerPart(peerId, 'peer')}`.slice(0, 80);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function rootsIdentifySameProject(left, right) {
|
|
190
|
+
try {
|
|
191
|
+
return fs.realpathSync(left) === fs.realpathSync(right);
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function tmuxManagedSessionPrefixMatches(ctx, sessionName, sessionRoot = null) {
|
|
198
|
+
if (!sessionName) return false;
|
|
199
|
+
if (sessionName.startsWith(tmuxManagedSessionPrefix(ctx))) return true;
|
|
200
|
+
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
201
|
+
sessionName.startsWith(legacyTmuxManagedSessionPrefix({ root: sessionRoot }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function tmuxManagedSessionNameMatches(ctx, sessionName, peerId, sessionRoot = null) {
|
|
205
|
+
if (!sessionName) return false;
|
|
206
|
+
if (sessionName === tmuxManagedSessionName(ctx, peerId)) return true;
|
|
207
|
+
return Boolean(sessionRoot) && rootsIdentifySameProject(ctx.root, sessionRoot) &&
|
|
208
|
+
sessionName === legacyTmuxManagedSessionName({ root: sessionRoot }, peerId);
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
export function tmuxEnvironmentArgs(env) {
|
|
148
212
|
const args = [];
|
|
149
213
|
for (const [key, value] of Object.entries(env)) {
|
|
@@ -158,9 +222,19 @@ export function tmuxEnvironmentArgs(env) {
|
|
|
158
222
|
export function tmuxPaneInfo(targetPane = null) {
|
|
159
223
|
const pane = targetPane || process.env.TMUX_PANE;
|
|
160
224
|
if (!pane) throw new CliError('BAD_ARGS', 'Missing --pane and current terminal is not inside tmux');
|
|
161
|
-
|
|
225
|
+
// NOTE: do not use tab characters as field separators — older tmux (e.g. 3.3a
|
|
226
|
+
// on Debian) replaces whitespace in display-message/list-panes -F output with
|
|
227
|
+
// '_', which silently corrupts parsing and produces invalid pane targets. '|'
|
|
228
|
+
// is preserved across tmux versions and never appears in pane_id/pid/command/
|
|
229
|
+
// dead; the path is placed last and rejoined so a '|' inside a path is safe.
|
|
230
|
+
const format = '#{pane_id}|#{pane_dead}|#{pane_pid}|#{pane_current_command}|#{pane_current_path}';
|
|
162
231
|
const out = runTmux(['display-message', '-p', '-t', pane, format]).trimEnd();
|
|
163
|
-
const
|
|
232
|
+
const parts = out.split('|');
|
|
233
|
+
const paneId = parts[0];
|
|
234
|
+
const paneDead = parts[1];
|
|
235
|
+
const pid = parts[2];
|
|
236
|
+
const currentCommand = parts[3];
|
|
237
|
+
const currentPath = parts.slice(4).join('|');
|
|
164
238
|
if (!paneId) throw new CliError('TMUX_ERROR', `Cannot resolve tmux pane: ${pane}`);
|
|
165
239
|
return {
|
|
166
240
|
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
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Runtime-side automatic buffer GC, extracted from lib/web/runtime-main.mjs.
|
|
2
|
+
// Plans and applies buffer-file GC with runtime arbitration: live-session
|
|
3
|
+
// path snapshots, cross-project evidence collection, fenced plan tokens,
|
|
4
|
+
// and the periodic auto-GC sweep.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { CliError } from '../shared/errors.mjs';
|
|
8
|
+
import { clockSafetyUnavailable } from '../core/coordination/clock-safety.mjs';
|
|
9
|
+
import { collectBufferEvidence } from '../runtime/buffer-evidence.mjs';
|
|
10
|
+
import { applyClockSafeBufferPlan, createBufferGcPlanStore } from '../runtime/buffer-gc-protocol.mjs';
|
|
11
|
+
import { bufferPlanGcCutoffs, planBufferFiles } from '../runtime/buffer-gc.mjs';
|
|
12
|
+
|
|
13
|
+
export function createBufferGcRuntime(deps) {
|
|
14
|
+
const {
|
|
15
|
+
sessions,
|
|
16
|
+
connectWebProject, runtimeProjectContexts,
|
|
17
|
+
now, runGc, bufferDirectory,
|
|
18
|
+
canonicalRoot, observePeerEvidence, redactedLogText, sameResolvedPath
|
|
19
|
+
} = deps;
|
|
20
|
+
|
|
21
|
+
const bufferGcPlanStore = createBufferGcPlanStore();
|
|
22
|
+
const bufferUnknownTracker = new Map();
|
|
23
|
+
const bufferDirectoriesByProject = new Map();
|
|
24
|
+
|
|
25
|
+
// ── Automatic GC ──────────────────────────────────────────────────────────
|
|
26
|
+
// Bound events/WAL growth without manual `hcc gc` (conc-05). Auto scope only
|
|
27
|
+
// prunes high-volume/ephemeral items (events >14d, expired locks, stale bufs),
|
|
28
|
+
// never peers/tasks/messages/handoffs. Runs once at startup then every 6h.
|
|
29
|
+
let autoGcInFlight = false;
|
|
30
|
+
function runningBufferPathSnapshot(projectCtx) {
|
|
31
|
+
const protectedPaths = new Set();
|
|
32
|
+
const projectKey = canonicalRoot(projectCtx.root) || path.resolve(projectCtx.root);
|
|
33
|
+
const projectDirectories = bufferDirectoriesByProject.get(projectKey) || new Set();
|
|
34
|
+
for (const session of sessions.values()) {
|
|
35
|
+
for (const file of [session.outFile, session.inFile, session.resizeFile, session.pipeFile, session.metaFile]) {
|
|
36
|
+
if (!file) continue;
|
|
37
|
+
const resolved = path.resolve(file);
|
|
38
|
+
if (sameResolvedPath(session.root, projectCtx.root)) projectDirectories.add(path.dirname(resolved));
|
|
39
|
+
if (session.status === 'running') protectedPaths.add(resolved);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
bufferDirectoriesByProject.set(projectKey, projectDirectories);
|
|
43
|
+
return { protectedPaths, projectDirectories };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function collectRuntimeBufferEvidence(directories, candidatePaths = null) {
|
|
47
|
+
const projectDbs = [];
|
|
48
|
+
const opened = [];
|
|
49
|
+
try {
|
|
50
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
51
|
+
try {
|
|
52
|
+
const db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
|
|
53
|
+
opened.push(db);
|
|
54
|
+
projectDbs.push({ ctx: projectCtx, db });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
projectDbs.push({
|
|
57
|
+
ctx: projectCtx,
|
|
58
|
+
db: { prepare() { throw error; } }
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return collectBufferEvidence({
|
|
63
|
+
directories,
|
|
64
|
+
projectDbs,
|
|
65
|
+
sessions: [...sessions.values()],
|
|
66
|
+
observePeer: observePeerEvidence,
|
|
67
|
+
unknownTracker: bufferUnknownTracker,
|
|
68
|
+
candidatePaths
|
|
69
|
+
});
|
|
70
|
+
} finally {
|
|
71
|
+
for (const db of opened) {
|
|
72
|
+
try { db.close(); } catch {}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function prepareRuntimeBufferGc(projectCtx, { dryRun, retentionSec }) {
|
|
78
|
+
const observedAt = now();
|
|
79
|
+
const cutoffMs = observedAt * 1000 - retentionSec * 1000;
|
|
80
|
+
const { projectDirectories } = runningBufferPathSnapshot(projectCtx);
|
|
81
|
+
const directories = new Set([bufferDirectory(projectCtx), ...projectDirectories]);
|
|
82
|
+
const { protectedPaths, unknownPaths, gcCutoffs } = collectRuntimeBufferEvidence(directories);
|
|
83
|
+
const plan = planBufferFiles({
|
|
84
|
+
directories,
|
|
85
|
+
cutoffMs,
|
|
86
|
+
protectedPaths,
|
|
87
|
+
unknownPaths,
|
|
88
|
+
evidenceGcCutoffs: gcCutoffs
|
|
89
|
+
});
|
|
90
|
+
const prepared = {
|
|
91
|
+
observedAt,
|
|
92
|
+
cutoffMs,
|
|
93
|
+
retentionSec,
|
|
94
|
+
deleted: plan.deleteEntries.length,
|
|
95
|
+
protected: plan.protectedEntries.length,
|
|
96
|
+
deferred: plan.unknownEntries.length,
|
|
97
|
+
gcCutoffs: bufferPlanGcCutoffs(plan, retentionSec)
|
|
98
|
+
};
|
|
99
|
+
if (!dryRun) {
|
|
100
|
+
prepared.token = bufferGcPlanStore.prepare({
|
|
101
|
+
root: projectCtx.root,
|
|
102
|
+
dbPath: projectCtx.dbPath,
|
|
103
|
+
observedAt,
|
|
104
|
+
retentionSec,
|
|
105
|
+
plan
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return prepared;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function applyPreparedRuntimeBufferGc(projectCtx, token) {
|
|
112
|
+
const prepared = bufferGcPlanStore.take({
|
|
113
|
+
token,
|
|
114
|
+
root: projectCtx.root,
|
|
115
|
+
dbPath: projectCtx.dbPath
|
|
116
|
+
});
|
|
117
|
+
let db = null;
|
|
118
|
+
try {
|
|
119
|
+
db = connectWebProject(projectCtx);
|
|
120
|
+
return applyClockSafeBufferPlan({
|
|
121
|
+
db,
|
|
122
|
+
plan: prepared.plan,
|
|
123
|
+
retentionSec: prepared.retentionSec,
|
|
124
|
+
nowSec: now,
|
|
125
|
+
collectEvidence: ({ entries }) => collectRuntimeBufferEvidence(
|
|
126
|
+
new Set(entries.map((entry) => entry.directory.path)),
|
|
127
|
+
entries.map((entry) => entry.path)
|
|
128
|
+
)
|
|
129
|
+
});
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (error instanceof CliError) throw error;
|
|
132
|
+
throw clockSafetyUnavailable(error);
|
|
133
|
+
} finally {
|
|
134
|
+
try { db?.close(); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function runAutoGc() {
|
|
139
|
+
if (autoGcInFlight) return;
|
|
140
|
+
autoGcInFlight = true;
|
|
141
|
+
try {
|
|
142
|
+
for (const projectCtx of runtimeProjectContexts()) {
|
|
143
|
+
let db = null;
|
|
144
|
+
try {
|
|
145
|
+
db = connectWebProject(projectCtx, { migrateRegistered: false, create: false });
|
|
146
|
+
const result = runGc(projectCtx, db, {
|
|
147
|
+
olderThanDays: 14,
|
|
148
|
+
dryRun: false,
|
|
149
|
+
scope: 'auto',
|
|
150
|
+
collectBufferEvidenceNow: () => collectRuntimeBufferEvidence([
|
|
151
|
+
bufferDirectory(projectCtx)
|
|
152
|
+
])
|
|
153
|
+
});
|
|
154
|
+
const appliedDatabaseRows = result.expired_locks + result.old_events;
|
|
155
|
+
if (appliedDatabaseRows > 0) {
|
|
156
|
+
try { db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get(); } catch {}
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(redactedLogText(`[${new Date().toISOString()}] auto-gc failed for ${projectCtx.root}: ${err?.message || err}`));
|
|
160
|
+
} finally {
|
|
161
|
+
try { db?.close(); } catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
autoGcInFlight = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const gcPoller = setInterval(runAutoGc, 6 * 60 * 60 * 1000);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
gcPoller, runAutoGc,
|
|
172
|
+
collectRuntimeBufferEvidence, prepareRuntimeBufferGc, applyPreparedRuntimeBufferGc,
|
|
173
|
+
runningBufferPathSnapshot, bufferGcPlanStore
|
|
174
|
+
};
|
|
175
|
+
}
|