@logicseek/hello-cc 0.1.9 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/db/stores/peers.mjs
CHANGED
|
@@ -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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
const kind = detectCliKindFromProcess(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
150
|
+
PEER_ID=$(provider_peer_id "$SESSION_ID") || exec "$REAL_BIN" "$@"
|
|
100
151
|
elif [ -n "$RESUME_ID" ] && [ "$IS_FORK" != "1" ]; then
|
|
101
|
-
PEER_ID
|
|
152
|
+
PEER_ID=$(provider_peer_id "$RESUME_ID") || exec "$REAL_BIN" "$@"
|
|
102
153
|
elif [ -n "$CLAUDE_CODE_SESSION_ID" ]; then
|
|
103
|
-
PEER_ID
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
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,192 @@
|
|
|
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 function inspectProcessIdentity(value) {
|
|
156
|
+
let pid;
|
|
157
|
+
try {
|
|
158
|
+
pid = Number(value);
|
|
159
|
+
} catch {
|
|
160
|
+
return { state: 'unknown', identity: null };
|
|
161
|
+
}
|
|
162
|
+
if (!Number.isInteger(pid) || pid <= 0) return { state: 'unknown', identity: null };
|
|
163
|
+
|
|
164
|
+
const initialState = probeProcess(pid);
|
|
165
|
+
if (initialState === 'dead') return { state: 'dead', identity: null };
|
|
166
|
+
if (initialState === 'unknown') return { state: 'unknown', identity: null };
|
|
167
|
+
|
|
168
|
+
let identity = null;
|
|
169
|
+
try {
|
|
170
|
+
if (process.platform === 'linux') {
|
|
171
|
+
const observed = linuxObservation(pid);
|
|
172
|
+
if (observed.state === 'dead') return observed;
|
|
173
|
+
identity = observed.identity;
|
|
174
|
+
}
|
|
175
|
+
else if (process.platform === 'darwin') identity = macIdentity(pid);
|
|
176
|
+
} catch {
|
|
177
|
+
identity = null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const finalState = probeProcess(pid);
|
|
181
|
+
if (finalState === 'dead') return { state: 'dead', identity: null };
|
|
182
|
+
if (finalState === 'unknown' || !isCompleteIdentity(identity)) {
|
|
183
|
+
return { state: 'unknown', identity: null };
|
|
184
|
+
}
|
|
185
|
+
return { state: 'live', identity };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function compareProcessIdentity(stored, current) {
|
|
189
|
+
if (!isCompleteIdentity(stored) || !isCompleteIdentity(current)) return 'unknown';
|
|
190
|
+
return stored.pid === current.pid &&
|
|
191
|
+
stored.startToken === current.startToken ? 'live' : 'dead';
|
|
192
|
+
}
|