@logicseek/hello-cc 0.1.9 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +17 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/current-state-review-2026-08.md +341 -0
  12. package/docs/defect-review-2026-07.md +382 -0
  13. package/docs/guide.md +39 -3
  14. package/docs/guide.zh-CN.md +34 -2
  15. package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
  16. package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
  17. package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
  18. package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
  19. package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
  20. package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
  21. package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
  22. package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
  23. package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
  24. package/lib/cli/app.mjs +642 -0
  25. package/lib/cli/commands/coordination.mjs +247 -0
  26. package/lib/cli/commands/doctor.mjs +81 -0
  27. package/lib/cli/commands/gc.mjs +852 -0
  28. package/lib/cli/commands/hook.mjs +205 -0
  29. package/lib/cli/commands/install.mjs +294 -0
  30. package/lib/cli/commands/lifecycle.mjs +101 -0
  31. package/lib/cli/commands/lock.mjs +197 -0
  32. package/lib/cli/commands/misc.mjs +61 -0
  33. package/lib/cli/commands/msg.mjs +152 -0
  34. package/lib/cli/commands/peer.mjs +233 -0
  35. package/lib/cli/commands/query.mjs +96 -0
  36. package/lib/cli/commands/run.mjs +463 -0
  37. package/lib/cli/commands/task.mjs +405 -0
  38. package/lib/cli/commands/team.mjs +131 -0
  39. package/lib/cli/commands/tmux.mjs +838 -0
  40. package/lib/cli/commands/up.mjs +135 -0
  41. package/lib/cli-args.mjs +16 -2
  42. package/lib/coordination-state.mjs +112 -51
  43. package/lib/core/coordination/automation.mjs +5 -8
  44. package/lib/core/coordination/clock-safety.mjs +323 -0
  45. package/lib/core/coordination/event-retention.mjs +17 -0
  46. package/lib/core/coordination/gc-plan.mjs +614 -0
  47. package/lib/core/coordination/lease-renewal.mjs +102 -0
  48. package/lib/core/coordination/lock-evidence.mjs +77 -0
  49. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  50. package/lib/core/coordination/tasks.mjs +156 -36
  51. package/lib/core/peers/evidence-runtime.mjs +312 -0
  52. package/lib/core/peers/evidence.mjs +103 -0
  53. package/lib/core/peers/hook-owner.mjs +32 -0
  54. package/lib/core/peers/liveness.mjs +44 -4
  55. package/lib/core/peers/peer-helpers.mjs +121 -0
  56. package/lib/core/peers/session.mjs +8 -2
  57. package/lib/core/peers/tmux-safety.mjs +290 -0
  58. package/lib/core/sessions/launch.mjs +13 -1
  59. package/lib/db/connection.mjs +104 -0
  60. package/lib/db/events.mjs +34 -0
  61. package/lib/db/migration-backup.mjs +225 -0
  62. package/lib/db/schema.mjs +102 -5
  63. package/lib/db/stores/peers.mjs +34 -0
  64. package/lib/integrations/peers/identity.mjs +62 -16
  65. package/lib/integrations/shims/script.mjs +111 -9
  66. package/lib/process/identity.mjs +192 -0
  67. package/lib/process/pty-lifecycle.mjs +149 -0
  68. package/lib/runtime/buffer-directory-lease.mjs +47 -0
  69. package/lib/runtime/buffer-evidence.mjs +336 -0
  70. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  71. package/lib/runtime/buffer-gc.mjs +270 -0
  72. package/lib/runtime/client.mjs +60 -7
  73. package/lib/runtime/fatal-shutdown.mjs +54 -0
  74. package/lib/runtime/project-path.mjs +139 -0
  75. package/lib/runtime/projects.mjs +320 -40
  76. package/lib/runtime/state.mjs +227 -27
  77. package/lib/shared/cleanup-error.mjs +54 -0
  78. package/lib/shared/clock-grace.mjs +106 -0
  79. package/lib/shared/errors.mjs +9 -0
  80. package/lib/shared/file-lock.mjs +370 -0
  81. package/lib/shared/json-file.mjs +6 -2
  82. package/lib/shared/redact.mjs +60 -0
  83. package/lib/shared/socket-lock-worker.mjs +201 -0
  84. package/lib/terminal/tmux.mjs +78 -4
  85. package/lib/tmux.mjs +4 -0
  86. package/lib/ui/help.mjs +53 -15
  87. package/lib/web/api-version.mjs +42 -0
  88. package/lib/web/auto-attach.mjs +185 -0
  89. package/lib/web/buffer-gc-runtime.mjs +175 -0
  90. package/lib/web/cookie-auth.mjs +119 -0
  91. package/lib/web/external-sessions.mjs +254 -0
  92. package/lib/web/http-routes.mjs +542 -0
  93. package/lib/web/http.mjs +135 -6
  94. package/lib/web/liveness-reaper.mjs +145 -0
  95. package/lib/web/peer-actions.mjs +132 -23
  96. package/lib/web/project-contexts.mjs +178 -0
  97. package/lib/web/pty-sessions.mjs +203 -0
  98. package/lib/web/runtime-main.mjs +714 -0
  99. package/lib/web/runtime.mjs +91 -36
  100. package/lib/web/session-serialize.mjs +0 -0
  101. package/lib/web/startup.mjs +333 -0
  102. package/lib/web/tls.mjs +365 -0
  103. package/lib/web/tmux-sessions.mjs +1361 -0
  104. package/lib/web/tmux-stream.mjs +166 -0
  105. package/lib/web/ui-template.mjs +152 -20
  106. package/package.json +5 -2
  107. package/scripts/check-free-identifiers.mjs +290 -0
  108. package/scripts/regression.mjs +5397 -263
@@ -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 = `hcc-${shortHash(ctx.root)}-${sanitizePeerPart(peerId, 'peer')}`;
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
- const format = '#{pane_id}\t#{pane_current_path}\t#{pane_pid}\t#{pane_current_command}\t#{pane_dead}';
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 [paneId, currentPath, pid, currentCommand, paneDead] = out.split('\t');
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 up stale peers, events, tasks, and buf files
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 Replace and save the stable web access 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
- Use --policy blocked, stale, or blocked-or-stale to require an auditable
87
- precondition before takeover. The default policy is any for backward
88
- compatibility.
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
- Clean stale coordination database records and old buffer files for the current
285
- project. By default this is a dry-run; deletion requires --yes.
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 uses a saved token,
443
- generating one on first use. Use HCC_WEB_TOKEN or --token to replace the saved
444
- token, --local to bind only to 127.0.0.1, or --no-token only for a trusted
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
+ }