@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -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/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -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
- if (process.platform === 'darwin') return 'Install tmux with: brew install tmux';
33
- if (process.platform === 'linux') {
34
- if (fs.existsSync('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
35
- if (fs.existsSync('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
36
- if (fs.existsSync('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
37
- if (fs.existsSync('/etc/fedora-release')) return 'Install tmux with: sudo dnf install -y tmux';
38
- return 'Install tmux with your system package manager.';
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 sudo = typeof process.getuid === 'function' && process.getuid() === 0 ? '' : (commandExists('sudo') ? 'sudo ' : '');
66
- if (process.platform === 'darwin' && commandExists('brew')) {
67
- return runInstallCommand('brew install tmux');
68
- }
69
- if (process.platform === 'linux') {
70
- if (commandExists('apt-get')) {
71
- const update = runInstallCommand(`${sudo}apt-get update`);
72
- if (!update.ok) return update;
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: false, output: 'no supported package manager found' };
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 = `hcc-${shortHash(ctx.root)}-${sanitizePeerPart(peerId, 'peer')}`;
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
- const format = '#{pane_id}\t#{pane_current_path}\t#{pane_pid}\t#{pane_current_command}\t#{pane_dead}';
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 [paneId, currentPath, pid, currentCommand, paneDead] = out.split('\t');
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 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
+ }