@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
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
@@ -289,8 +340,59 @@ if [ -z "$HCC_ROOT" ]; then
289
340
  exec "$REAL_BIN" "$@"
290
341
  fi
291
342
 
292
- sanitize_peer_part() {
293
- 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"
294
396
  }
295
397
 
296
398
  tty_peer_part() {
@@ -326,7 +428,7 @@ done
326
428
  if [ -n "$HCC_PEER" ]; then
327
429
  PEER_ID="$HCC_PEER"
328
430
  elif [ "$SUBCMD" = "resume" ] && [ -n "$RESUME_ID" ]; then
329
- PEER_ID="${tool.kind}-\$(sanitize_peer_part "\${RESUME_ID:0:8}")"
431
+ PEER_ID=$(provider_peer_id "$RESUME_ID") || exec "$REAL_BIN" "$@"
330
432
  else
331
433
  PEER_ID="${tool.kind}-\$(tty_peer_part)"
332
434
  fi
@@ -0,0 +1,222 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+
5
+ function hashCommand(command) {
6
+ return createHash('sha256').update(String(command ?? '').trim()).digest('hex');
7
+ }
8
+
9
+ function parseLinuxStat(text) {
10
+ const stat = String(text ?? '');
11
+ const prefix = stat.match(/^\s*\d+\s+\(/);
12
+ if (!prefix) return null;
13
+
14
+ const close = stat.lastIndexOf(')');
15
+ if (close < prefix[0].length - 1 || !/^\s/.test(stat.slice(close + 1))) return null;
16
+
17
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
18
+ const state = fields[0];
19
+ const startTicks = fields[19];
20
+ if (fields.length < 20 || !/^[RSDZTWXxKtPI]$/.test(state) ||
21
+ !/^\d+$/.test(startTicks) || BigInt(startTicks) <= 0n) return null;
22
+ return { state, startTicks };
23
+ }
24
+
25
+ export function parseLinuxStatStartTicks(text) {
26
+ return parseLinuxStat(text)?.startTicks || null;
27
+ }
28
+
29
+ export function parsePsStartIdentity(text) {
30
+ const line = String(text ?? '').trimEnd();
31
+ if (line.includes('\n') || line.includes('\r')) return null;
32
+
33
+ const tab = line.indexOf('\t');
34
+ if (tab < 0) return null;
35
+
36
+ const startToken = line.slice(0, tab).trim();
37
+ const command = line.slice(tab + 1).trim();
38
+ return startToken && command ? { startToken, command } : null;
39
+ }
40
+
41
+ function probeProcess(pid) {
42
+ try {
43
+ process.kill(pid, 0);
44
+ return 'present';
45
+ } catch (error) {
46
+ if (error?.code === 'ESRCH') return 'dead';
47
+ if (error?.code === 'EPERM') return 'present';
48
+ return 'unknown';
49
+ }
50
+ }
51
+
52
+ function linuxObservation(pid) {
53
+ const first = parseLinuxStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
54
+ if (!first) return { state: 'unknown', identity: null };
55
+ if (['Z', 'X', 'x'].includes(first.state)) return { state: 'dead', identity: null };
56
+ const bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
57
+ const command = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
58
+ .split('\0')
59
+ .filter(Boolean)
60
+ .join(' ');
61
+ const confirmed = parseLinuxStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
62
+
63
+ if (!confirmed) return { state: 'unknown', identity: null };
64
+ if (['Z', 'X', 'x'].includes(confirmed.state)) return { state: 'dead', identity: null };
65
+ if (!bootId || !command.trim() || confirmed.startTicks !== first.startTicks) {
66
+ return { state: 'unknown', identity: null };
67
+ }
68
+ return {
69
+ state: 'live',
70
+ identity: {
71
+ pid,
72
+ startToken: `${bootId}:${first.startTicks}`,
73
+ commandHash: hashCommand(command)
74
+ }
75
+ };
76
+ }
77
+
78
+ function run(command, args, environment) {
79
+ const options = {
80
+ encoding: 'utf8',
81
+ stdio: ['ignore', 'pipe', 'pipe']
82
+ };
83
+ if (environment) options.env = environment;
84
+ return spawnSync(command, args, options);
85
+ }
86
+
87
+ function parseMacBootToken(text) {
88
+ const match = String(text ?? '').match(
89
+ /^\s*\{\s*sec\s*=\s*(\d+)\s*,\s*usec\s*=\s*(\d+)\s*\}/
90
+ );
91
+ if (!match) return null;
92
+
93
+ const seconds = BigInt(match[1]);
94
+ const microseconds = Number(match[2]);
95
+ if (seconds <= 0n || !Number.isInteger(microseconds) ||
96
+ microseconds < 0 || microseconds > 999999) return null;
97
+ return `${seconds}:${microseconds}`;
98
+ }
99
+
100
+ function macIdentity(pid) {
101
+ const boot = run('sysctl', ['-n', 'kern.boottime']);
102
+ if (boot.error || boot.status !== 0) return null;
103
+ const bootToken = parseMacBootToken(boot.stdout);
104
+ if (!bootToken) return null;
105
+
106
+ const psEnvironment = { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' };
107
+ const started = run('ps', ['-p', String(pid), '-o', 'lstart='], psEnvironment);
108
+ const command = run('ps', ['-p', String(pid), '-o', 'command='], psEnvironment);
109
+ const confirmedStarted = run('ps', ['-p', String(pid), '-o', 'lstart='], psEnvironment);
110
+ if (started.error || command.error || confirmedStarted.error) return null;
111
+ if (started.status !== 0 ||
112
+ command.status !== 0 || confirmedStarted.status !== 0) return null;
113
+
114
+ const commandOutput = String(command.stdout ?? '').trim();
115
+ const parsed = parsePsStartIdentity(
116
+ `${String(started.stdout ?? '').trim()}\t${commandOutput}\n`
117
+ );
118
+ const confirmed = parsePsStartIdentity(
119
+ `${String(confirmedStarted.stdout ?? '').trim()}\t${commandOutput}\n`
120
+ );
121
+ if (!parsed || !confirmed || confirmed.startToken !== parsed.startToken) return null;
122
+
123
+ return {
124
+ pid,
125
+ startToken: `${bootToken}:${parsed.startToken}`,
126
+ commandHash: hashCommand(parsed.command)
127
+ };
128
+ }
129
+
130
+ function isCompleteIdentity(value) {
131
+ return Number.isInteger(value?.pid) && value.pid > 0 &&
132
+ typeof value.startToken === 'string' && value.startToken.trim().length > 0 &&
133
+ typeof value.commandHash === 'string' && /^[a-f0-9]{64}$/.test(value.commandHash);
134
+ }
135
+
136
+ export async function waitForLiveProcessIdentity(value, options = {}) {
137
+ const inspect = options.inspect || inspectProcessIdentity;
138
+ const monotonicNow = options.monotonicNow || (() => performance.now());
139
+ const wait = options.sleep || ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
140
+ const timeoutMs = Math.max(0, Number(options.timeoutMs ?? 250));
141
+ const intervalMs = Math.max(1, Number(options.intervalMs ?? 5));
142
+ const deadline = monotonicNow() + timeoutMs;
143
+
144
+ while (true) {
145
+ const observed = inspect(value);
146
+ if (observed?.state === 'dead') return { state: 'dead', identity: null };
147
+ if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) return observed;
148
+
149
+ const remainingMs = deadline - monotonicNow();
150
+ if (remainingMs <= 0) return { state: 'unknown', identity: null };
151
+ await wait(Math.min(intervalMs, remainingMs));
152
+ }
153
+ }
154
+
155
+ export async function waitForProcessIdentityExit(value, options = {}) {
156
+ const inspect = options.inspect || inspectProcessIdentity;
157
+ const monotonicNow = options.monotonicNow || (() => performance.now());
158
+ const wait = options.sleep || ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
159
+ const timeoutMs = Math.max(0, Number(options.timeoutMs ?? 5_000));
160
+ const intervalMs = Math.max(1, Number(options.intervalMs ?? 25));
161
+ const storedIdentity = isCompleteIdentity(value) ? value : null;
162
+ const pid = Number(storedIdentity?.pid ?? value);
163
+ if (!Number.isInteger(pid) || pid <= 0) return { state: 'unknown', identity: null };
164
+
165
+ const deadline = monotonicNow() + timeoutMs;
166
+ let lastObservation = { state: 'unknown', identity: null };
167
+ while (true) {
168
+ const observed = inspect(pid);
169
+ if (observed?.state === 'dead') return { state: 'dead', identity: null };
170
+ if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) {
171
+ if (storedIdentity && compareProcessIdentity(storedIdentity, observed.identity) === 'dead') {
172
+ return { state: 'dead', identity: null };
173
+ }
174
+ lastObservation = observed;
175
+ } else {
176
+ lastObservation = { state: 'unknown', identity: null };
177
+ }
178
+
179
+ const remainingMs = deadline - monotonicNow();
180
+ if (remainingMs <= 0) return lastObservation;
181
+ await wait(Math.min(intervalMs, remainingMs));
182
+ }
183
+ }
184
+
185
+ export function inspectProcessIdentity(value) {
186
+ let pid;
187
+ try {
188
+ pid = Number(value);
189
+ } catch {
190
+ return { state: 'unknown', identity: null };
191
+ }
192
+ if (!Number.isInteger(pid) || pid <= 0) return { state: 'unknown', identity: null };
193
+
194
+ const initialState = probeProcess(pid);
195
+ if (initialState === 'dead') return { state: 'dead', identity: null };
196
+ if (initialState === 'unknown') return { state: 'unknown', identity: null };
197
+
198
+ let identity = null;
199
+ try {
200
+ if (process.platform === 'linux') {
201
+ const observed = linuxObservation(pid);
202
+ if (observed.state === 'dead') return observed;
203
+ identity = observed.identity;
204
+ }
205
+ else if (process.platform === 'darwin') identity = macIdentity(pid);
206
+ } catch {
207
+ identity = null;
208
+ }
209
+
210
+ const finalState = probeProcess(pid);
211
+ if (finalState === 'dead') return { state: 'dead', identity: null };
212
+ if (finalState === 'unknown' || !isCompleteIdentity(identity)) {
213
+ return { state: 'unknown', identity: null };
214
+ }
215
+ return { state: 'live', identity };
216
+ }
217
+
218
+ export function compareProcessIdentity(stored, current) {
219
+ if (!isCompleteIdentity(stored) || !isCompleteIdentity(current)) return 'unknown';
220
+ return stored.pid === current.pid &&
221
+ stored.startToken === current.startToken ? 'live' : 'dead';
222
+ }