@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.
Files changed (108) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +26 -4
  3. package/README.zh-CN.md +22 -3
  4. package/bin/hcc.mjs +3 -5622
  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 +16 -5
  10. package/docs/commands.zh-CN.md +16 -5
  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 +53 -5
  14. package/docs/guide.zh-CN.md +46 -3
  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 +15 -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 +139 -15
  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 +61 -8
  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 +232 -29
  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 +143 -7
  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 +5646 -262
package/lib/db/schema.mjs CHANGED
@@ -1,6 +1,7 @@
1
+ import { aggregateCleanupFailure } from '../shared/cleanup-error.mjs';
1
2
  import { CliError } from '../shared/errors.mjs';
2
3
 
3
- export const DB_SCHEMA_VERSION = 5;
4
+ export const DB_SCHEMA_VERSION = 7;
4
5
 
5
6
  const sleepBuffer = new SharedArrayBuffer(4);
6
7
  const sleepView = new Int32Array(sleepBuffer);
@@ -21,6 +22,18 @@ function isSqliteBusy(err) {
21
22
  /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database is busy/i.test(text);
22
23
  }
23
24
 
25
+ function failedRollbackMetadata(db, context) {
26
+ let transactionActive = null;
27
+ try {
28
+ transactionActive = Boolean(db.isTransaction);
29
+ } catch {}
30
+ return {
31
+ context,
32
+ transactionActive,
33
+ connectionUsable: false
34
+ };
35
+ }
36
+
24
37
  export function execWithBusyRetry(db, sql, { attempts = 30, delayMs = 100, ignoreBusy = false } = {}) {
25
38
  for (let attempt = 0; attempt < attempts; attempt += 1) {
26
39
  try {
@@ -47,14 +60,49 @@ export function tx(db, fn) {
47
60
  } catch (err) {
48
61
  try {
49
62
  db.exec('ROLLBACK;');
50
- } catch {
51
- // Ignore rollback failures; the original error is more useful.
63
+ } catch (cleanupError) {
64
+ throw aggregateCleanupFailure(
65
+ err,
66
+ cleanupError,
67
+ 'Write transaction failed and rollback also failed',
68
+ failedRollbackMetadata(db, 'Write transaction rollback')
69
+ );
52
70
  }
53
71
  throw err;
54
72
  }
55
73
  }
56
74
 
57
- export function initSchema(db, { beforePostMigrationIndexes = null } = {}) {
75
+ // Deferred read transaction: gives a consistent snapshot for multi-table reads
76
+ // without taking a write lock (unlike tx's BEGIN IMMEDIATE). Use for read-only
77
+ // snapshot assembly so peers/tasks/locks counts cannot drift mid-read (conc-06).
78
+ export function readTx(db, fn, options = {}) {
79
+ db.exec('BEGIN;');
80
+ try {
81
+ const result = fn();
82
+ db.exec('COMMIT;');
83
+ return result;
84
+ } catch (err) {
85
+ try {
86
+ db.exec('ROLLBACK;');
87
+ } catch (cleanupError) {
88
+ throw aggregateCleanupFailure(
89
+ err,
90
+ cleanupError,
91
+ `${options.cleanupContext || 'Read transaction'} failed and rollback also failed`,
92
+ failedRollbackMetadata(
93
+ db,
94
+ `${options.cleanupContext || 'Read transaction'} rollback`
95
+ )
96
+ );
97
+ }
98
+ throw err;
99
+ }
100
+ }
101
+
102
+ export function initSchema(db, {
103
+ beforeMigration = null,
104
+ beforePostMigrationIndexes = null
105
+ } = {}) {
58
106
  const existingVersion = readSchemaVersion(db);
59
107
  if (existingVersion > DB_SCHEMA_VERSION) {
60
108
  throw new CliError('DB_SCHEMA_TOO_NEW', `Database schema version ${existingVersion} is newer than this hcc (${DB_SCHEMA_VERSION})`, {
@@ -62,6 +110,17 @@ export function initSchema(db, { beforePostMigrationIndexes = null } = {}) {
62
110
  supported_schema_version: DB_SCHEMA_VERSION
63
111
  });
64
112
  }
113
+ const hasExistingSchema = Boolean(db.prepare(`
114
+ SELECT 1
115
+ FROM sqlite_master
116
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
117
+ LIMIT 1
118
+ `).get());
119
+ if (existingVersion < DB_SCHEMA_VERSION &&
120
+ (existingVersion > 0 || hasExistingSchema) &&
121
+ typeof beforeMigration === 'function') {
122
+ beforeMigration({ fromVersion: existingVersion, toVersion: DB_SCHEMA_VERSION });
123
+ }
65
124
  createBaseSchema(db);
66
125
  runSchemaMigrations(db);
67
126
  if (typeof beforePostMigrationIndexes === 'function') beforePostMigrationIndexes(db);
@@ -88,6 +147,8 @@ function createBaseSchema(db) {
88
147
  worktree TEXT,
89
148
  branch TEXT,
90
149
  pid INTEGER,
150
+ pid_start_token TEXT,
151
+ pid_command_hash TEXT,
91
152
  status TEXT NOT NULL DEFAULT 'idle',
92
153
  capabilities TEXT,
93
154
  created_at INTEGER NOT NULL,
@@ -155,7 +216,8 @@ function createBaseSchema(db) {
155
216
  task_id INTEGER,
156
217
  reason TEXT,
157
218
  expires_at INTEGER NOT NULL,
158
- created_at INTEGER NOT NULL
219
+ created_at INTEGER NOT NULL,
220
+ ttl_sec INTEGER NOT NULL DEFAULT 900
159
221
  );
160
222
 
161
223
  CREATE TABLE IF NOT EXISTS handoffs (
@@ -294,6 +356,35 @@ function runSchemaMigrations(db) {
294
356
  CREATE INDEX IF NOT EXISTS idx_locks_base_scope ON locks(base_resource, scope);
295
357
  `);
296
358
  }
359
+ },
360
+ {
361
+ version: 6,
362
+ name: 'persist lock ttl',
363
+ up(database) {
364
+ const hadTtlColumn = tableColumns(database, 'locks').has('ttl_sec');
365
+ addColumnIfMissing(database, 'locks', 'ttl_sec', 'ttl_sec INTEGER NOT NULL DEFAULT 900');
366
+ database.exec(hadTtlColumn
367
+ ? `
368
+ UPDATE locks
369
+ SET ttl_sec = 900
370
+ WHERE ttl_sec IS NULL OR ttl_sec <= 0;
371
+ `
372
+ : `
373
+ UPDATE locks
374
+ SET ttl_sec = CASE
375
+ WHEN expires_at > created_at THEN MIN(expires_at - created_at, 3600)
376
+ ELSE 900
377
+ END;
378
+ `);
379
+ }
380
+ },
381
+ {
382
+ version: 7,
383
+ name: 'peer process identity fingerprints',
384
+ up(database) {
385
+ addColumnIfMissing(database, 'peers', 'pid_start_token', 'pid_start_token TEXT');
386
+ addColumnIfMissing(database, 'peers', 'pid_command_hash', 'pid_command_hash TEXT');
387
+ }
297
388
  }
298
389
  ];
299
390
  let version = readSchemaVersion(db);
@@ -304,6 +395,12 @@ function runSchemaMigrations(db) {
304
395
  });
305
396
  }
306
397
  const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
398
+ // Already fully migrated: skip the write transaction entirely. Read-only
399
+ // commands (hcc status/peers/task list) then open the DB without taking a
400
+ // RESERVED write lock or appending WAL frames on every connect (conc-01).
401
+ if (version === DB_SCHEMA_VERSION && migrations.every((m) => applied.has(m.version))) {
402
+ return;
403
+ }
307
404
  tx(db, () => {
308
405
  for (const migration of migrations) {
309
406
  if (version >= migration.version && applied.has(migration.version)) continue;
@@ -7,6 +7,7 @@ import {
7
7
  mergePeerBinding,
8
8
  mergeRuntimeBinding
9
9
  } from '../../core/peers/bindings.mjs';
10
+ import { providerSessionPeerId } from '../../core/peers/session.mjs';
10
11
 
11
12
  function defaultNow() {
12
13
  return Math.floor(Date.now() / 1000);
@@ -14,6 +15,23 @@ function defaultNow() {
14
15
 
15
16
  function noopEvent() {}
16
17
 
18
+ function crossesV1ProviderIdentityBoundary(binding, conflict) {
19
+ const providerSession = bindingProviderSessionValue(binding);
20
+ if (!providerSession || !binding?.provider) return false;
21
+ const v1Peer = providerSessionPeerId(binding.provider, providerSession);
22
+ return binding.peer === v1Peer && conflict?.peer !== v1Peer;
23
+ }
24
+
25
+ function withoutProviderSession(binding) {
26
+ return {
27
+ ...binding,
28
+ provider_session_id: null,
29
+ provider_session_name: null,
30
+ resume_mode: 'unknown',
31
+ resume_arg: null
32
+ };
33
+ }
34
+
17
35
  export function createPeerBindingStore(deps = {}) {
18
36
  const now = typeof deps.now === 'function' ? deps.now : defaultNow;
19
37
  const addEvent = typeof deps.addEvent === 'function' ? deps.addEvent : noopEvent;
@@ -125,6 +143,22 @@ export function createPeerBindingStore(deps = {}) {
125
143
  const conflictRuntime = bindingHasRuntime(conflict);
126
144
  const providerSession = bindingProviderSessionValue(next);
127
145
  const override = Boolean(options.override);
146
+ const preV1Conflict = crossesV1ProviderIdentityBoundary(next, conflict);
147
+ const passiveObservation = !incomingRuntime || next.resume_mode === 'detected';
148
+
149
+ if (preV1Conflict && passiveObservation) {
150
+ next = withoutProviderSession(next);
151
+ return { peer: next.peer, binding: next, merged_from: null };
152
+ }
153
+
154
+ if (preV1Conflict && incomingRuntime && !override) {
155
+ throw new CliError('PROVIDER_SESSION_IN_USE', `${next.provider} session ${providerSession} is already bound to ${conflict.peer}`, {
156
+ peer: conflict.peer,
157
+ provider: conflict.provider,
158
+ provider_session: providerSession,
159
+ runtime_target: conflict.runtime_target || null
160
+ });
161
+ }
128
162
 
129
163
  if (override && incomingRuntime) {
130
164
  db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(conflict.peer);
@@ -84,28 +84,74 @@ function detectCliKindFromProcess(args, env) {
84
84
  return null;
85
85
  }
86
86
 
87
- export function readAncestorCliInfo() {
88
- if (ancestorCliInfoCache !== undefined) return ancestorCliInfoCache;
89
- if (process.platform !== 'linux') {
90
- ancestorCliInfoCache = null;
91
- return ancestorCliInfoCache;
87
+ export function findLinuxAncestorCliInfo(startPid, options = {}) {
88
+ const read = typeof options.read === 'function'
89
+ ? options.read
90
+ : (pid) => ({
91
+ args: readProcCmdline(pid),
92
+ env: readProcEnv(pid),
93
+ parent: readProcParentPid(pid)
94
+ });
95
+ let pid = Number(startPid);
96
+ const seen = new Set();
97
+ for (let depth = 0; Number.isInteger(pid) && pid > 1 && depth < 12 && !seen.has(pid); depth += 1) {
98
+ seen.add(pid);
99
+ const row = read(pid);
100
+ if (!row) return null;
101
+ const args = Array.isArray(row.args) ? row.args : [];
102
+ const env = row.env && typeof row.env === 'object' && !Array.isArray(row.env) ? row.env : {};
103
+ const kind = detectCliKindFromProcess(args, env);
104
+ if (kind) return { pid, kind, args, env };
105
+ pid = Number(row.parent);
92
106
  }
107
+ return null;
108
+ }
93
109
 
94
- let pid = process.ppid;
110
+ export function parseMacAncestorLine(text) {
111
+ const line = String(text ?? '').trimEnd();
112
+ if (!line || line.includes('\n') || line.includes('\r')) return null;
113
+ const match = line.match(/^\s*([0-9]+)\s+(.+?)\s*$/);
114
+ if (!match) return null;
115
+ const ppid = Number(match[1]);
116
+ const command = match[2];
117
+ if (!Number.isSafeInteger(ppid) || ppid < 0 || !command) return null;
118
+ return { ppid, command };
119
+ }
120
+
121
+ function inspectMacAncestor(pid) {
122
+ const result = spawnSync('ps', ['-ww', '-o', 'ppid=', '-o', 'command=', '-p', String(pid)], {
123
+ encoding: 'utf8',
124
+ stdio: ['ignore', 'pipe', 'pipe'],
125
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' }
126
+ });
127
+ if (result.error || result.status !== 0) return null;
128
+ return result.stdout || '';
129
+ }
130
+
131
+ export function findMacAncestorCliInfo(startPid, options = {}) {
132
+ const inspect = typeof options.inspect === 'function' ? options.inspect : inspectMacAncestor;
133
+ let pid = Number(startPid);
95
134
  const seen = new Set();
96
- for (let depth = 0; pid && pid > 1 && depth < 12 && !seen.has(pid); depth += 1) {
135
+ for (let depth = 0; Number.isInteger(pid) && pid > 1 && depth < 12 && !seen.has(pid); depth += 1) {
97
136
  seen.add(pid);
98
- const args = readProcCmdline(pid);
99
- const env = readProcEnv(pid);
100
- const kind = detectCliKindFromProcess(args, env);
101
- if (kind) {
102
- ancestorCliInfoCache = { pid, kind, args, env };
103
- return ancestorCliInfoCache;
104
- }
105
- pid = readProcParentPid(pid);
137
+ const parsed = parseMacAncestorLine(inspect(pid));
138
+ if (!parsed) return null;
139
+ const kind = detectCliKindFromProcess([parsed.command], {});
140
+ // `ps command` is sufficient for provider-kind detection but is not a
141
+ // lossless argv encoding. Do not feed it to resume-ID parsing.
142
+ if (kind) return { pid, kind, args: [], env: {}, command: parsed.command };
143
+ pid = parsed.ppid;
106
144
  }
145
+ return null;
146
+ }
107
147
 
108
- ancestorCliInfoCache = null;
148
+ export function readAncestorCliInfo() {
149
+ if (ancestorCliInfoCache !== undefined) return ancestorCliInfoCache;
150
+ ancestorCliInfoCache = process.platform === 'linux'
151
+ ? findLinuxAncestorCliInfo(process.ppid)
152
+ : process.platform === 'darwin'
153
+ ? findMacAncestorCliInfo(process.ppid)
154
+ : null;
109
155
  return ancestorCliInfoCache;
110
156
  }
111
157
 
@@ -57,8 +57,59 @@ if [ -z "$HCC_ROOT" ]; then
57
57
  exec "$REAL_BIN" "$@"
58
58
  fi
59
59
 
60
- sanitize_peer_part() {
61
- printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g; s/^-*//; s/-*$//' | cut -c 1-32
60
+ # sess-04: sha1 hash of the FULL provider id/name, first 8 hex chars — must
61
+ # match the JS shortHash() exactly (lib/core/peers/format.mjs). Name-based ids
62
+ # that share a prefix used to collide into the same peer id.
63
+ is_sha1_hex() {
64
+ [ "\${#1}" -eq 40 ] || return 1
65
+ case "$1" in
66
+ *[!0-9a-f]*) return 1 ;;
67
+ esac
68
+ return 0
69
+ }
70
+
71
+ peer_hash() {
72
+ local HASH=""
73
+ local OUTPUT=""
74
+
75
+ if command -v openssl >/dev/null 2>&1; then
76
+ OUTPUT=$(printf '%s' "$1" | openssl dgst -sha1 2>/dev/null || true)
77
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $NF }' | tr '[:upper:]' '[:lower:]')
78
+ is_sha1_hex "$HASH" || HASH=""
79
+ fi
80
+
81
+ if [ -z "$HASH" ] && command -v sha1sum >/dev/null 2>&1; then
82
+ OUTPUT=$(printf '%s' "$1" | sha1sum 2>/dev/null || true)
83
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $1 }' | tr '[:upper:]' '[:lower:]')
84
+ is_sha1_hex "$HASH" || HASH=""
85
+ fi
86
+
87
+ if [ -z "$HASH" ] && command -v shasum >/dev/null 2>&1; then
88
+ OUTPUT=$(printf '%s' "$1" | shasum 2>/dev/null || true)
89
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $1 }' | tr '[:upper:]' '[:lower:]')
90
+ is_sha1_hex "$HASH" || HASH=""
91
+ fi
92
+
93
+ # A hello-cc environment always has node on PATH (hcc itself is a node CLI),
94
+ # so this fallback guarantees registration even on minimal systems without
95
+ # openssl/sha1sum/shasum. Digest must match the JS shortHash exactly.
96
+ if [ -z "$HASH" ] && command -v node >/dev/null 2>&1; then
97
+ HASH=$(printf '%s' "$1" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write(require("node:crypto").createHash("sha1").update(d).digest("hex")))' 2>/dev/null || true)
98
+ is_sha1_hex "$HASH" || HASH=""
99
+ fi
100
+
101
+ if ! is_sha1_hex "$HASH"; then
102
+ printf '%s\n' 'hello-cc: unable to derive peer id: no SHA-1 command produced a valid digest' >&2
103
+ return 1
104
+ fi
105
+
106
+ printf '%.8s' "$HASH"
107
+ }
108
+
109
+ provider_peer_id() {
110
+ local HASH_PART=""
111
+ HASH_PART=$(peer_hash "$1") || return 1
112
+ printf '%s-%s' '${tool.kind}' "$HASH_PART"
62
113
  }
63
114
 
64
115
  tty_peer_part() {
@@ -96,13 +147,13 @@ done
96
147
  if [ -n "$HCC_PEER" ]; then
97
148
  PEER_ID="$HCC_PEER"
98
149
  elif [ -n "$SESSION_ID" ]; then
99
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "\${SESSION_ID:0:8}")"
150
+ PEER_ID=$(provider_peer_id "$SESSION_ID") || exec "$REAL_BIN" "$@"
100
151
  elif [ -n "$RESUME_ID" ] && [ "$IS_FORK" != "1" ]; then
101
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "\${RESUME_ID:0:8}")"
152
+ PEER_ID=$(provider_peer_id "$RESUME_ID") || exec "$REAL_BIN" "$@"
102
153
  elif [ -n "$CLAUDE_CODE_SESSION_ID" ]; then
103
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "\${CLAUDE_CODE_SESSION_ID:0:8}")"
154
+ PEER_ID=$(provider_peer_id "$CLAUDE_CODE_SESSION_ID") || exec "$REAL_BIN" "$@"
104
155
  elif [ -n "$SESSION_NAME" ] && [ "$IS_FORK" != "1" ] && [ "$IS_CONTINUE" != "1" ]; then
105
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "$SESSION_NAME")"
156
+ PEER_ID=$(provider_peer_id "$SESSION_NAME") || exec "$REAL_BIN" "$@"
106
157
  else
107
158
  PEER_ID="${tool.kind}-\$(tty_peer_part)"
108
159
  fi
@@ -139,14 +190,22 @@ detect_current_hcc_pane() {
139
190
  return 1
140
191
  }
141
192
 
193
+ hcc_runtime_unavailable_start_failure() {
194
+ case "$1" in
195
+ *"No running web runtime found"*|*"No running hello-cc web runtime found"*|*"Runtime is not reachable"*|*"RUNTIME_NOT_RUNNING"*|*"RUNTIME_UNREACHABLE"*|*"RUNTIME_START_FAILED"*|*"RUNTIME_START_TIMEOUT"*)
196
+ return 0
197
+ ;;
198
+ esac
199
+ return 1
200
+ }
201
+
142
202
  if detect_current_hcc_pane; then
143
203
  SESSION_PEER_ID=$(printf '%s' "$CURRENT_HCC_SESSION" | sed 's/^hcc-[^-]*-//')
144
204
  if [ -n "$SESSION_PEER_ID" ] && [ "$SESSION_PEER_ID" != "$CURRENT_HCC_SESSION" ]; then
145
205
  PEER_ID="$SESSION_PEER_ID"
146
206
  fi
147
207
  export HCC_PEER="$PEER_ID"
148
- HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true
149
- "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
208
+ HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
150
209
  exec "$REAL_BIN" "$@"
151
210
  fi
152
211
 
@@ -155,12 +214,15 @@ HCC_PROVIDER_LAUNCH_SCRIPT='REAL_BIN="$1"; shift; HCC_TMUX_BIN="\${HCC_TMUX_BIN:
155
214
  set +e
156
215
  # Wrap in shell so pane_pid stays as bash; Ctrl+C drops back to bash
157
216
  START_OUT_FILE=$(mktemp "\${TMPDIR:-/tmp}/hcc-shim-start.XXXXXX")
158
- (HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true; "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
217
+ (HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
159
218
  START_STATUS=$?
160
219
  START_OUT=$(cat "$START_OUT_FILE" 2>/dev/null || true)
161
220
  rm -f "$START_OUT_FILE"
162
221
  set -e
163
222
  if [ "$START_STATUS" -ne 0 ]; then
223
+ if hcc_runtime_unavailable_start_failure "$START_OUT"; then
224
+ exec "$REAL_BIN" "$@"
225
+ fi
164
226
  printf '%s\\n' "$START_OUT" >&2
165
227
  exit "$START_STATUS"
166
228
  fi
@@ -278,8 +340,59 @@ if [ -z "$HCC_ROOT" ]; then
278
340
  exec "$REAL_BIN" "$@"
279
341
  fi
280
342
 
281
- sanitize_peer_part() {
282
- printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g; s/^-*//; s/-*$//' | cut -c 1-32
343
+ # sess-04: sha1 hash of the FULL provider id/name, first 8 hex chars — must
344
+ # match the JS shortHash() exactly (lib/core/peers/format.mjs). Name-based ids
345
+ # that share a prefix used to collide into the same peer id.
346
+ is_sha1_hex() {
347
+ [ "\${#1}" -eq 40 ] || return 1
348
+ case "$1" in
349
+ *[!0-9a-f]*) return 1 ;;
350
+ esac
351
+ return 0
352
+ }
353
+
354
+ peer_hash() {
355
+ local HASH=""
356
+ local OUTPUT=""
357
+
358
+ if command -v openssl >/dev/null 2>&1; then
359
+ OUTPUT=$(printf '%s' "$1" | openssl dgst -sha1 2>/dev/null || true)
360
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $NF }' | tr '[:upper:]' '[:lower:]')
361
+ is_sha1_hex "$HASH" || HASH=""
362
+ fi
363
+
364
+ if [ -z "$HASH" ] && command -v sha1sum >/dev/null 2>&1; then
365
+ OUTPUT=$(printf '%s' "$1" | sha1sum 2>/dev/null || true)
366
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $1 }' | tr '[:upper:]' '[:lower:]')
367
+ is_sha1_hex "$HASH" || HASH=""
368
+ fi
369
+
370
+ if [ -z "$HASH" ] && command -v shasum >/dev/null 2>&1; then
371
+ OUTPUT=$(printf '%s' "$1" | shasum 2>/dev/null || true)
372
+ HASH=$(printf '%s\n' "$OUTPUT" | awk 'NR == 1 { print $1 }' | tr '[:upper:]' '[:lower:]')
373
+ is_sha1_hex "$HASH" || HASH=""
374
+ fi
375
+
376
+ # A hello-cc environment always has node on PATH (hcc itself is a node CLI),
377
+ # so this fallback guarantees registration even on minimal systems without
378
+ # openssl/sha1sum/shasum. Digest must match the JS shortHash exactly.
379
+ if [ -z "$HASH" ] && command -v node >/dev/null 2>&1; then
380
+ HASH=$(printf '%s' "$1" | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>process.stdout.write(require("node:crypto").createHash("sha1").update(d).digest("hex")))' 2>/dev/null || true)
381
+ is_sha1_hex "$HASH" || HASH=""
382
+ fi
383
+
384
+ if ! is_sha1_hex "$HASH"; then
385
+ printf '%s\n' 'hello-cc: unable to derive peer id: no SHA-1 command produced a valid digest' >&2
386
+ return 1
387
+ fi
388
+
389
+ printf '%.8s' "$HASH"
390
+ }
391
+
392
+ provider_peer_id() {
393
+ local HASH_PART=""
394
+ HASH_PART=$(peer_hash "$1") || return 1
395
+ printf '%s-%s' '${tool.kind}' "$HASH_PART"
283
396
  }
284
397
 
285
398
  tty_peer_part() {
@@ -315,7 +428,7 @@ done
315
428
  if [ -n "$HCC_PEER" ]; then
316
429
  PEER_ID="$HCC_PEER"
317
430
  elif [ "$SUBCMD" = "resume" ] && [ -n "$RESUME_ID" ]; then
318
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "\${RESUME_ID:0:8}")"
431
+ PEER_ID=$(provider_peer_id "$RESUME_ID") || exec "$REAL_BIN" "$@"
319
432
  else
320
433
  PEER_ID="${tool.kind}-\$(tty_peer_part)"
321
434
  fi
@@ -352,14 +465,22 @@ detect_current_hcc_pane() {
352
465
  return 1
353
466
  }
354
467
 
468
+ hcc_runtime_unavailable_start_failure() {
469
+ case "$1" in
470
+ *"No running web runtime found"*|*"No running hello-cc web runtime found"*|*"Runtime is not reachable"*|*"RUNTIME_NOT_RUNNING"*|*"RUNTIME_UNREACHABLE"*|*"RUNTIME_START_FAILED"*|*"RUNTIME_START_TIMEOUT"*)
471
+ return 0
472
+ ;;
473
+ esac
474
+ return 1
475
+ }
476
+
355
477
  if detect_current_hcc_pane; then
356
478
  SESSION_PEER_ID=$(printf '%s' "$CURRENT_HCC_SESSION" | sed 's/^hcc-[^-]*-//')
357
479
  if [ -n "$SESSION_PEER_ID" ] && [ "$SESSION_PEER_ID" != "$CURRENT_HCC_SESSION" ]; then
358
480
  PEER_ID="$SESSION_PEER_ID"
359
481
  fi
360
482
  export HCC_PEER="$PEER_ID"
361
- HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true
362
- "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
483
+ HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer attach "$PEER_ID" --kind "${tool.kind}" --pane "$CURRENT_HCC_PANE" --cwd "$PWD" --force >/dev/null 2>&1 || true
363
484
  exec "$REAL_BIN" "$@"
364
485
  fi
365
486
 
@@ -368,12 +489,15 @@ HCC_PROVIDER_LAUNCH_SCRIPT='REAL_BIN="$1"; shift; HCC_TMUX_BIN="\${HCC_TMUX_BIN:
368
489
  set +e
369
490
  # Wrap in shell so pane_pid stays as bash; Ctrl+C drops back to bash
370
491
  START_OUT_FILE=$(mktemp "\${TMPDIR:-/tmp}/hcc-shim-start.XXXXXX")
371
- (HCC_SKIP_SHIM_INSTALL=1 "$HCC_BIN" web >/dev/null 2>&1 || true; "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
492
+ (HCC_RUNTIME_LOCAL_ONLY=1 "$HCC_BIN" peer start "$PEER_ID" --kind "${tool.kind}" --restart-env -- bash -lc "$HCC_PROVIDER_LAUNCH_SCRIPT" _ "$REAL_BIN" "$@") >"$START_OUT_FILE" 2>&1
372
493
  START_STATUS=$?
373
494
  START_OUT=$(cat "$START_OUT_FILE" 2>/dev/null || true)
374
495
  rm -f "$START_OUT_FILE"
375
496
  set -e
376
497
  if [ "$START_STATUS" -ne 0 ]; then
498
+ if hcc_runtime_unavailable_start_failure "$START_OUT"; then
499
+ exec "$REAL_BIN" "$@"
500
+ fi
377
501
  printf '%s\\n' "$START_OUT" >&2
378
502
  exit "$START_STATUS"
379
503
  fi