@logicseek/hello-cc 1.1.0-rc.1 → 1.1.0-rc.3
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 +190 -108
- package/README.md +1 -10
- package/README.zh-CN.md +1 -8
- package/docs/codex-account.zh-CN.md +38 -0
- package/docs/codex-protocol.md +59 -0
- package/docs/commands.md +13 -28
- package/docs/commands.zh-CN.md +11 -20
- package/docs/dsh.md +5 -3
- package/docs/dsh.zh-CN.md +6 -4
- package/docs/macos-process-identity.md +15 -0
- package/docs/native.md +95 -4
- package/docs/native.zh-CN.md +70 -1
- package/docs/web-handoff.zh-CN.md +107 -5
- package/lib/cli/app.mjs +9 -6
- package/lib/cli/commands/coordination.mjs +7 -2
- package/lib/cli/commands/gc.mjs +14 -12
- package/lib/cli/commands/hook.mjs +16 -3
- package/lib/cli/commands/install.mjs +37 -9
- package/lib/cli/commands/lock.mjs +3 -1
- package/lib/cli/commands/misc.mjs +105 -13
- package/lib/cli/commands/native.mjs +3 -44
- package/lib/cli/commands/peer.mjs +3 -0
- package/lib/cli/commands/run.mjs +13 -6
- package/lib/cli/commands/tmux.mjs +1 -2
- package/lib/cli-runtime.mjs +19 -2
- package/lib/core/coordination/gc-plan.mjs +5 -0
- package/lib/core/coordination/locks.mjs +3 -1
- package/lib/core/peers/evidence.mjs +21 -1
- package/lib/core/peers/tmux-safety.mjs +4 -0
- package/lib/core/sessions/hook-root-identity.mjs +47 -0
- package/lib/core/sessions/launch.mjs +7 -1
- package/lib/db/connection.mjs +67 -21
- package/lib/diagnostics/codex-protocol.mjs +105 -0
- package/lib/guidance.mjs +20 -36
- package/lib/integrations/codex-account.mjs +143 -0
- package/lib/integrations/dsh-collaboration.mjs +3 -4
- package/lib/integrations/dsh.mjs +4 -2
- package/lib/integrations/native/claude.mjs +24 -6
- package/lib/integrations/native/codex.mjs +38 -7
- package/lib/integrations/native/dsh-acp.mjs +67 -24
- package/lib/integrations/native/index.mjs +4 -1
- package/lib/integrations/native/jsonrpc.mjs +37 -5
- package/lib/integrations/native/telemetry.mjs +30 -0
- package/lib/process/identity.mjs +67 -28
- package/lib/process/pinned-cwd.mjs +302 -0
- package/lib/process/pinned-guidance.mjs +138 -0
- package/lib/process/pinned-project-state.mjs +105 -0
- package/lib/process/selected-cwd-identity.mjs +95 -0
- package/lib/runtime/buffer-evidence.mjs +7 -1
- package/lib/runtime/client.mjs +18 -1
- package/lib/runtime/native/client.mjs +4 -1
- package/lib/runtime/native/history.mjs +140 -0
- package/lib/runtime/native/launcher.mjs +106 -0
- package/lib/runtime/native/service.mjs +160 -27
- package/lib/runtime/native/store.mjs +51 -21
- package/lib/runtime/project-path.mjs +24 -5
- package/lib/runtime/projects.mjs +3 -0
- package/lib/runtime/state.mjs +61 -26
- package/lib/shared/file-lock.mjs +62 -17
- package/lib/shared/redact.mjs +2 -1
- package/lib/shared/socket-lock-worker.mjs +15 -6
- package/lib/web/agent-defaults.mjs +110 -0
- package/lib/web/auto-attach.mjs +3 -2
- package/lib/web/browser/contracts.d.ts +10 -0
- package/lib/web/browser/core.mjs +1997 -0
- package/lib/web/browser/project-data.mjs +158 -0
- package/lib/web/browser/session-sync.mjs +82 -0
- package/lib/web/browser-assets.mjs +19 -0
- package/lib/web/buffer-gc-runtime.mjs +2 -2
- package/lib/web/codex-app-server.mjs +104 -33
- package/lib/web/codex-sessions.mjs +60 -11
- package/lib/web/context-files.mjs +82 -0
- package/lib/web/cookie-auth.mjs +51 -3
- package/lib/web/external-sessions.mjs +22 -4
- package/lib/web/http-routes.mjs +236 -23
- package/lib/web/http.mjs +50 -4
- package/lib/web/liveness-reaper.mjs +2 -2
- package/lib/web/native-sessions.mjs +322 -13
- package/lib/web/project-contexts.mjs +235 -42
- package/lib/web/project-file-write.mjs +249 -0
- package/lib/web/project-files.mjs +268 -0
- package/lib/web/pty-sessions.mjs +11 -3
- package/lib/web/runtime-main.mjs +134 -82
- package/lib/web/session-serialize.mjs +87 -8
- package/lib/web/session-state-sync.mjs +98 -0
- package/lib/web/shutdown-diagnostics.mjs +38 -0
- package/lib/web/shutdown-response.mjs +21 -0
- package/lib/web/shutdown-state.mjs +91 -0
- package/lib/web/startup.mjs +76 -27
- package/lib/web/tmux-client-snapshot.mjs +85 -0
- package/lib/web/tmux-sessions.mjs +71 -7
- package/lib/web/tmux-stream.mjs +36 -8
- package/lib/web/ui-agent-defaults.mjs +149 -0
- package/lib/web/ui-agent-start.mjs +330 -0
- package/lib/web/ui-codex-account.mjs +81 -0
- package/lib/web/ui-codex.mjs +81 -73
- package/lib/web/ui-command-palette.mjs +21 -5
- package/lib/web/ui-file-writes.mjs +220 -0
- package/lib/web/ui-files.mjs +231 -0
- package/lib/web/ui-handoff.mjs +10 -2
- package/lib/web/ui-history.mjs +175 -105
- package/lib/web/ui-i18n.mjs +280 -8
- package/lib/web/ui-interactions.mjs +6 -4
- package/lib/web/ui-native-timeline.mjs +326 -0
- package/lib/web/ui-native.mjs +93 -49
- package/lib/web/ui-review.mjs +5 -3
- package/lib/web/ui-safe-markdown.mjs +79 -0
- package/lib/web/ui-session-tools.mjs +224 -0
- package/lib/web/ui-template.mjs +27 -1967
- package/lib/web/ui-terminal-find.mjs +5 -3
- package/lib/web/ui-workbench.mjs +5 -3
- package/lib/web/ui-workspace.mjs +3 -0
- package/package.json +8 -2
- package/scripts/benchmark-session-sync.mjs +53 -0
- package/scripts/codex-protocol.mjs +88 -0
- package/scripts/dsh-approval-layers-acceptance.mjs +172 -0
- package/scripts/dsh-package-provenance.mjs +118 -0
- package/scripts/native-bounded-live-acceptance.mjs +193 -0
- package/scripts/native-fixture-root.mjs +49 -0
- package/scripts/native-installed-acceptance.mjs +204 -6
- package/scripts/native-stability-checks.mjs +40 -0
- package/scripts/regression-cookie-expiry.mjs +70 -0
- package/scripts/regression-http-transport.mjs +9 -0
- package/scripts/regression-terminal-marker.mjs +95 -0
- package/scripts/regression.mjs +637 -186
- package/scripts/shim-reentry-probe.mjs +105 -0
- package/scripts/web-acceptance-diagnostics.mjs +90 -0
- package/scripts/web-codex-account-acceptance.mjs +178 -0
- package/scripts/web-file-preview-checks.mjs +302 -0
- package/scripts/web-mcp-form-acceptance.mjs +22 -7
- package/scripts/web-mcp-form-live-acceptance.mjs +1 -1
- package/scripts/web-native-handoff-checks.mjs +128 -0
- package/scripts/web-native-interaction-acceptance.mjs +152 -18
- package/scripts/web-native-interaction-diagnostics.mjs +80 -0
- package/scripts/web-native-model-acceptance.mjs +22 -7
- package/scripts/web-session-tools-checks.mjs +112 -0
- package/scripts/web-workbench-acceptance.mjs +435 -0
- package/scripts/web-workbench-installed-acceptance.mjs +122 -0
|
@@ -5,17 +5,19 @@ import { secureExistingProjectStateDirectory } from '../../runtime/project-path.
|
|
|
5
5
|
import { legacyProjectStateDir, projectStateDir } from '../../runtime/paths.mjs';
|
|
6
6
|
import { privateProjectStateDir, inspectPrivateProjectAuthority,
|
|
7
7
|
purgePrivateProjectState } from '../../runtime/private-state.mjs';
|
|
8
|
+
import { captureSelectedCwdIdentity } from '../../process/selected-cwd-identity.mjs';
|
|
9
|
+
import { purgePinnedLegacyProjectState } from '../../process/pinned-project-state.mjs';
|
|
8
10
|
|
|
9
11
|
export function createInstallCommands(deps) {
|
|
10
12
|
const {
|
|
11
13
|
path, fs, process, CliError, parseOpts, validateOpts, wantsHelp,
|
|
12
14
|
printResult, commandPath, connect, readRuntime, runtimeRequest,
|
|
13
|
-
|
|
15
|
+
globalRuntimePath, PRODUCT_NAME, VERSION, NPM_PACKAGE_NAME,
|
|
14
16
|
helpInstallHooks, helpShim, helpUninstall, helpUpdate,
|
|
15
17
|
loadSetup, shellCommand,
|
|
16
18
|
|
|
17
19
|
|
|
18
|
-
sameResolvedPath, writeGuidance, removeGuidanceBlocks,
|
|
20
|
+
sameResolvedPath, writeGuidance, removeGuidanceBlocks, spawnPinnedStateProcess,
|
|
19
21
|
|
|
20
22
|
} = deps;
|
|
21
23
|
|
|
@@ -240,13 +242,23 @@ async function cmdUninstall(ctx, args) {
|
|
|
240
242
|
if (opts.purge && !opts.yes) {
|
|
241
243
|
throw new CliError('CONFIRM_REQUIRED', 'Refusing to purge project data without --yes');
|
|
242
244
|
}
|
|
245
|
+
const selectedRoot = opts.purge ? captureSelectedCwdIdentity(ctx.root) : null;
|
|
246
|
+
let selectedLegacyState = null;
|
|
247
|
+
try {
|
|
243
248
|
// Resolve before changing hooks, guidance, or runtime state. An unsafe root
|
|
244
249
|
// with unmigrated legacy data must not be treated as an empty private store.
|
|
245
250
|
let purgeStateDir = null;
|
|
251
|
+
let purgeIsLegacy = false;
|
|
246
252
|
let missingPrivateState = false;
|
|
247
253
|
if (opts.purge) {
|
|
248
254
|
try {
|
|
249
|
-
|
|
255
|
+
const resolved = secureExistingProjectStateDirectory(ctx.root);
|
|
256
|
+
purgeStateDir = resolved.stateDir;
|
|
257
|
+
purgeIsLegacy = path.resolve(purgeStateDir) === path.join(selectedRoot.canonical, '.hello-cc');
|
|
258
|
+
if (purgeIsLegacy && resolved.exists) {
|
|
259
|
+
selectedLegacyState = captureSelectedCwdIdentity(purgeStateDir);
|
|
260
|
+
selectedRoot.assertUnchanged();
|
|
261
|
+
}
|
|
250
262
|
} catch (error) {
|
|
251
263
|
if (projectStateDir(ctx.root) !== privateProjectStateDir(ctx.root)) throw error;
|
|
252
264
|
const recoverablePending = error?.code === 'STATE_MIGRATION_REQUIRED' &&
|
|
@@ -261,7 +273,12 @@ async function cmdUninstall(ctx, args) {
|
|
|
261
273
|
const lines = [];
|
|
262
274
|
|
|
263
275
|
let runtime = null;
|
|
276
|
+
let runtimePointer = null;
|
|
264
277
|
if (!missingPrivateState) {
|
|
278
|
+
// Keep the validated state route fixed across the asynchronous stop probe.
|
|
279
|
+
// Re-deriving it afterwards could remove another project's pointer if a
|
|
280
|
+
// user-supplied root alias is retargeted in the meantime.
|
|
281
|
+
runtimePointer = path.join(secureExistingProjectStateDirectory(ctx.root).stateDir, 'runtime.json');
|
|
265
282
|
try {
|
|
266
283
|
runtime = readRuntime(ctx);
|
|
267
284
|
} catch (err) {
|
|
@@ -274,7 +291,7 @@ async function cmdUninstall(ctx, args) {
|
|
|
274
291
|
lines.push('runtime stopped');
|
|
275
292
|
} catch (err) {
|
|
276
293
|
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
277
|
-
try { fs.rmSync(
|
|
294
|
+
try { fs.rmSync(runtimePointer, { force: true }); } catch {}
|
|
278
295
|
lines.push('stale runtime file removed');
|
|
279
296
|
}
|
|
280
297
|
} else {
|
|
@@ -282,6 +299,7 @@ async function cmdUninstall(ctx, args) {
|
|
|
282
299
|
}
|
|
283
300
|
|
|
284
301
|
const { uninstallClaudeHooks, uninstallCodexHooks, uninstallShims, uninstallPathEntry } = await loadSetup();
|
|
302
|
+
selectedRoot?.assertUnchanged();
|
|
285
303
|
const claude = uninstallClaudeHooks();
|
|
286
304
|
const codex = uninstallCodexHooks();
|
|
287
305
|
const shims = uninstallShims();
|
|
@@ -294,20 +312,26 @@ async function cmdUninstall(ctx, args) {
|
|
|
294
312
|
let guidance = [];
|
|
295
313
|
let purged = false;
|
|
296
314
|
if (opts.purge) {
|
|
297
|
-
guidance = removeGuidanceBlocks(ctx);
|
|
315
|
+
guidance = removeGuidanceBlocks(ctx, { expectedBinding: selectedRoot });
|
|
298
316
|
try {
|
|
299
|
-
|
|
317
|
+
selectedRoot.assertUnchanged();
|
|
318
|
+
if (!purgeIsLegacy) {
|
|
300
319
|
// Revalidate and delete under the same transition lock used by init
|
|
301
320
|
// and migration. An interrupted deletion remains marked purging.
|
|
302
321
|
purgeStateDir = purgePrivateProjectState(ctx.root);
|
|
303
322
|
} else {
|
|
304
323
|
// Async shutdown and hook removal happened after the initial lookup.
|
|
305
324
|
// Refuse a stale legacy target if project-state routing changed.
|
|
306
|
-
const current = secureExistingProjectStateDirectory(ctx.root)
|
|
307
|
-
if (path.resolve(current) !== path.resolve(purgeStateDir)) {
|
|
325
|
+
const current = secureExistingProjectStateDirectory(ctx.root);
|
|
326
|
+
if (path.resolve(current.stateDir) !== path.resolve(purgeStateDir)) {
|
|
308
327
|
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state route changed during uninstall');
|
|
309
328
|
}
|
|
310
|
-
|
|
329
|
+
if (selectedLegacyState) {
|
|
330
|
+
purgePinnedLegacyProjectState(ctx.root, purgeStateDir, selectedRoot, selectedLegacyState,
|
|
331
|
+
{ spawnProcess: spawnPinnedStateProcess });
|
|
332
|
+
} else if (current.exists) {
|
|
333
|
+
throw new CliError('PROJECT_PATH_FORBIDDEN', 'Project state appeared during uninstall');
|
|
334
|
+
}
|
|
311
335
|
}
|
|
312
336
|
purged = true;
|
|
313
337
|
lines.push(`project data removed: ${purgeStateDir}`);
|
|
@@ -327,6 +351,10 @@ async function cmdUninstall(ctx, args) {
|
|
|
327
351
|
}
|
|
328
352
|
|
|
329
353
|
printResult(ctx, { runtime: Boolean(runtime), claude, codex, shims, path_entry: pathEntry, purge: purged, guidance }, () => lines.join('\n'));
|
|
354
|
+
} finally {
|
|
355
|
+
selectedLegacyState?.release();
|
|
356
|
+
selectedRoot?.release();
|
|
357
|
+
}
|
|
330
358
|
}
|
|
331
359
|
|
|
332
360
|
// ─── hcc scan ────────────────────────────────────────────────────────────────
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Factory pattern with injected deps.
|
|
3
3
|
|
|
4
4
|
import { readTx } from '../../db/schema.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../../core/peers/evidence.mjs';
|
|
5
6
|
import {
|
|
6
7
|
findRequestedLock,
|
|
7
8
|
upsertRequestedLock,
|
|
@@ -101,7 +102,8 @@ export function createLockCommands(deps) {
|
|
|
101
102
|
!(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
|
|
102
103
|
: subject.locks.filter((row) =>
|
|
103
104
|
Number(row.expires_at) > t ||
|
|
104
|
-
evidenceByOwner.get(row.owner)?.state === 'live'
|
|
105
|
+
evidenceByOwner.get(row.owner)?.state === 'live' ||
|
|
106
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(row.owner))
|
|
105
107
|
);
|
|
106
108
|
const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
|
|
107
109
|
if (conflict) {
|
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
// Small utility commands extracted from bin/hcc.mjs: down, find-root, which-real.
|
|
2
|
+
import { performance } from 'node:perf_hooks';
|
|
3
|
+
import { resolveProjectDatabase } from '../../runtime/project-path.mjs';
|
|
2
4
|
|
|
3
5
|
export function createMiscCommands(deps) {
|
|
4
6
|
const {
|
|
5
|
-
path, process, CliError, parseOpts, printResult,
|
|
7
|
+
path, fs, process, CliError, parseOpts, printResult,
|
|
6
8
|
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
9
|
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
-
waitForProcessIdentityExit,
|
|
10
|
+
waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
|
|
9
11
|
PRODUCT_NAME
|
|
10
12
|
} = deps;
|
|
11
13
|
|
|
14
|
+
function sameStopPointer(left, right) {
|
|
15
|
+
return left.raw === right.raw && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']
|
|
16
|
+
.every((key) => left.stat[key] === right.stat[key]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readStopPointer(file) {
|
|
20
|
+
const stat = fs.lstatSync(file);
|
|
21
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('invalid runtime pointer');
|
|
22
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
23
|
+
const current = fs.lstatSync(file);
|
|
24
|
+
if (current.isSymbolicLink() || !current.isFile() ||
|
|
25
|
+
!sameStopPointer({ raw, stat }, { raw, stat: current })) {
|
|
26
|
+
throw new Error('runtime pointer changed');
|
|
27
|
+
}
|
|
28
|
+
return { raw, stat };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stopOwnerEvidence(runtime, pointerFiles) {
|
|
32
|
+
try {
|
|
33
|
+
if (runtime.source === 'env') return null;
|
|
34
|
+
const source = pointerFiles.find((file) => path.resolve(file) === path.resolve(runtime.source || ''));
|
|
35
|
+
const url = new URL(runtime.base_url);
|
|
36
|
+
if (!source || !['http:', 'https:'].includes(url.protocol) ||
|
|
37
|
+
!['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) return null;
|
|
38
|
+
const owner = runtime.process_identity || runtime.processIdentity;
|
|
39
|
+
if (Object.hasOwn(runtime, 'process_identity') && Object.hasOwn(runtime, 'processIdentity')) return null;
|
|
40
|
+
if (owner?.pid !== Number(runtime.pid) || compareProcessIdentity(owner, owner) !== 'live') return null;
|
|
41
|
+
const observed = inspectProcessIdentity(owner.pid);
|
|
42
|
+
if (observed.state !== 'live' || compareProcessIdentity(owner, observed.identity) !== 'live' ||
|
|
43
|
+
owner.commandHash !== observed.identity.commandHash) return null;
|
|
44
|
+
const pointer = readStopPointer(source);
|
|
45
|
+
const recorded = JSON.parse(pointer.raw);
|
|
46
|
+
if (Object.hasOwn(recorded, 'process_identity') && Object.hasOwn(recorded, 'processIdentity')) return null;
|
|
47
|
+
const recordedOwner = recorded.process_identity || recorded.processIdentity;
|
|
48
|
+
if (Number(recorded.pid) !== owner.pid ||
|
|
49
|
+
compareProcessIdentity(owner, recordedOwner) !== 'live' || owner.commandHash !== recordedOwner.commandHash ||
|
|
50
|
+
['base_url', 'token', 'tls_ca_file'].some((key) => recorded[key] !== runtime[key])) return null;
|
|
51
|
+
return { source, owner, pointer };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
12
57
|
async function cmdDown(ctx, args) {
|
|
13
58
|
if (args[0] === '--help' || args[0] === '-h') return helpDown();
|
|
14
|
-
const
|
|
59
|
+
const stopStartedAt = performance.now();
|
|
60
|
+
const elapsedSince = (startedAt) => Math.max(0, Math.round(performance.now() - startedAt));
|
|
61
|
+
function stopFailure(error, phase, state = 'unknown', confirmationMs = null) {
|
|
62
|
+
// Retain the original error and transport context. Only fixed phase/state
|
|
63
|
+
// vocabulary and monotonic durations describe the local confirmation.
|
|
64
|
+
error.extra = {
|
|
65
|
+
...error.extra,
|
|
66
|
+
stopPhase: phase,
|
|
67
|
+
state: ['live', 'dead', 'unknown'].includes(state) ? state : 'unknown',
|
|
68
|
+
stopElapsedMs: elapsedSince(stopStartedAt),
|
|
69
|
+
...(confirmationMs === null ? {} : { confirmationMs })
|
|
70
|
+
};
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
const localPointer = ctx.root
|
|
74
|
+
? path.join(resolveProjectDatabase({ root: ctx.root, createStateDir: false }).stateDir, 'runtime.json')
|
|
75
|
+
: runtimePath(ctx);
|
|
76
|
+
const pointerFiles = [localPointer, globalRuntimePath()];
|
|
15
77
|
let runtime;
|
|
16
78
|
try {
|
|
17
79
|
runtime = readRuntime(ctx);
|
|
@@ -23,31 +85,61 @@ export function createMiscCommands(deps) {
|
|
|
23
85
|
`${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
|
|
24
86
|
return;
|
|
25
87
|
}
|
|
88
|
+
const stopEvidence = stopOwnerEvidence(runtime, pointerFiles);
|
|
26
89
|
try {
|
|
27
90
|
await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
|
|
28
91
|
} catch (err) {
|
|
29
92
|
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
30
|
-
if (
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
93
|
+
if (!stopEvidence) throw stopFailure(err, 'evidence_unavailable');
|
|
94
|
+
const confirmationStartedAt = performance.now();
|
|
95
|
+
const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
|
|
96
|
+
timeoutMs: 5_000,
|
|
97
|
+
intervalMs: 25
|
|
98
|
+
});
|
|
99
|
+
const confirmationMs = elapsedSince(confirmationStartedAt);
|
|
100
|
+
if (stopped.state !== 'dead') throw stopFailure(err, 'exit_unconfirmed', stopped.state, confirmationMs);
|
|
101
|
+
let cleanup;
|
|
102
|
+
let cleanupPhase = 'pointer_cleanup_failed';
|
|
103
|
+
try {
|
|
104
|
+
cleanup = withFileLock(stopEvidence.source, (file) => {
|
|
105
|
+
let current;
|
|
106
|
+
try { current = readStopPointer(file); }
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
if (!sameStopPointer(stopEvidence.pointer, current)) {
|
|
112
|
+
cleanupPhase = 'pointer_changed';
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
// Hold the pointer's existing lock across comparison and reclamation.
|
|
116
|
+
// A replacement owner must never be removed by this stop request.
|
|
117
|
+
return reclaimRuntimePointerFiles([file], {
|
|
118
|
+
reclaimUnknown: false,
|
|
119
|
+
withLock: (_file, operation) => operation(file)
|
|
120
|
+
});
|
|
121
|
+
}, { createParent: false });
|
|
122
|
+
} catch { throw stopFailure(err, cleanupPhase, 'dead', confirmationMs); }
|
|
123
|
+
if (cleanup.blocked || cleanup.reclaimed > 1) throw stopFailure(err, 'pointer_cleanup_failed', 'dead', confirmationMs);
|
|
124
|
+
printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
|
|
125
|
+
? `${PRODUCT_NAME} stale runtime pointer removed`
|
|
126
|
+
: `${PRODUCT_NAME} runtime stopped`);
|
|
37
127
|
return;
|
|
38
128
|
}
|
|
39
129
|
if (runtime.source !== 'env') {
|
|
40
130
|
const processIdentity = runtime.process_identity || runtime.processIdentity || runtime.pid;
|
|
131
|
+
const confirmationStartedAt = performance.now();
|
|
41
132
|
const stopped = await waitForProcessIdentityExit(processIdentity, {
|
|
42
133
|
timeoutMs: 5_000,
|
|
43
134
|
intervalMs: 25
|
|
44
135
|
});
|
|
136
|
+
const confirmationMs = elapsedSince(confirmationStartedAt);
|
|
45
137
|
if (stopped.state !== 'dead') {
|
|
46
|
-
throw new CliError(
|
|
138
|
+
throw stopFailure(new CliError(
|
|
47
139
|
'RUNTIME_STOP_TIMEOUT',
|
|
48
140
|
`Runtime accepted the stop request, but process ${runtime.pid || 'unknown'} did not exit within 5s.`,
|
|
49
|
-
{ pid: runtime.pid || null
|
|
50
|
-
);
|
|
141
|
+
{ pid: runtime.pid || null }
|
|
142
|
+
), 'exit_unconfirmed', stopped.state, confirmationMs);
|
|
51
143
|
}
|
|
52
144
|
}
|
|
53
145
|
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { spawn } from 'node:child_process';
|
|
4
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
3
|
import { CliError } from '../../shared/errors.mjs';
|
|
6
4
|
import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
|
|
7
5
|
import { printResult } from '../../format.mjs';
|
|
8
|
-
import {
|
|
9
|
-
import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
|
|
10
|
-
import { openPrivateAppendFile } from '../../runtime/private-file.mjs';
|
|
6
|
+
import { ensureNativeRuntime } from '../../runtime/native/launcher.mjs';
|
|
11
7
|
import { nativeRequest } from '../../runtime/native/client.mjs';
|
|
12
8
|
import { startNativeService } from '../../runtime/native/service.mjs';
|
|
13
9
|
|
|
@@ -30,43 +26,6 @@ dsh workers use the ACP profile, not its Web-service terminal.`);
|
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
export function createNativeCommands(deps) {
|
|
33
|
-
async function up(ctx) {
|
|
34
|
-
if (readNativePointer(ctx)) {
|
|
35
|
-
try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 2000 }); }
|
|
36
|
-
catch (error) {
|
|
37
|
-
if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const paths = nativePaths(ctx, { create: true });
|
|
41
|
-
let log;
|
|
42
|
-
let child;
|
|
43
|
-
try {
|
|
44
|
-
log = openPrivateAppendFile(paths.log);
|
|
45
|
-
child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
|
|
46
|
-
cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
|
|
47
|
-
});
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (error?.code === 'PROJECT_PATH_FORBIDDEN') {
|
|
50
|
-
throw new CliError('NATIVE_STATE_UNSAFE', error.message);
|
|
51
|
-
}
|
|
52
|
-
throw error;
|
|
53
|
-
} finally {
|
|
54
|
-
if (log !== undefined) fs.closeSync(log);
|
|
55
|
-
}
|
|
56
|
-
let failed;
|
|
57
|
-
child.on('error', (error) => { failed = error; });
|
|
58
|
-
child.unref();
|
|
59
|
-
const deadline = Date.now() + 10000;
|
|
60
|
-
while (Date.now() < deadline) {
|
|
61
|
-
if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
|
|
62
|
-
if (readNativePointer(ctx)) {
|
|
63
|
-
try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
|
|
64
|
-
}
|
|
65
|
-
await delay(100);
|
|
66
|
-
}
|
|
67
|
-
throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
29
|
async function cmdNative(ctx, args) {
|
|
71
30
|
const command = args[0];
|
|
72
31
|
if (!command || wantsHelp(args)) return nativeHelp();
|
|
@@ -86,7 +45,7 @@ export function createNativeCommands(deps) {
|
|
|
86
45
|
if (command === 'start' && opts.provider === 'claude' && opts.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
|
|
87
46
|
if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
|
|
88
47
|
let result;
|
|
89
|
-
if (command === 'up') result = await
|
|
48
|
+
if (command === 'up') result = await ensureNativeRuntime(ctx);
|
|
90
49
|
else if (command === 'serve') {
|
|
91
50
|
const service = await startNativeService(ctx, deps);
|
|
92
51
|
const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
|
|
@@ -94,7 +53,7 @@ export function createNativeCommands(deps) {
|
|
|
94
53
|
process.once('SIGINT', shutdown);
|
|
95
54
|
return;
|
|
96
55
|
} else if (command === 'start') {
|
|
97
|
-
await
|
|
56
|
+
await ensureNativeRuntime(ctx);
|
|
98
57
|
result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
|
|
99
58
|
cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
|
|
100
59
|
} else if (command === 'send') {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
RUNTIME_SESSION_MUTATION_TIMEOUT_MS,
|
|
5
5
|
RUNTIME_SESSION_START_TIMEOUT_MS
|
|
6
6
|
} from '../../runtime/client.mjs';
|
|
7
|
+
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
7
8
|
|
|
8
9
|
export function createPeerCommands(deps) {
|
|
9
10
|
const {
|
|
@@ -75,6 +76,7 @@ async function peerStart(ctx, args) {
|
|
|
75
76
|
const kind = inferPeerKind(id, opts.kind, firstCommand);
|
|
76
77
|
const role = opts.role || 'peer';
|
|
77
78
|
const cwd = path.resolve(opts.cwd || ctx.root);
|
|
79
|
+
const cwdIdentity = captureSelectedCwdSnapshot(cwd);
|
|
78
80
|
const built = buildPeerCommand(id, kind, opts, cmdArgs);
|
|
79
81
|
const command = built.command;
|
|
80
82
|
const binding = { ...built.binding, transport: 'tmux', runtime_session_id: id };
|
|
@@ -139,6 +141,7 @@ async function peerStart(ctx, args) {
|
|
|
139
141
|
role,
|
|
140
142
|
command,
|
|
141
143
|
cwd,
|
|
144
|
+
cwdIdentity,
|
|
142
145
|
binding,
|
|
143
146
|
env: childSessionEnv(),
|
|
144
147
|
restartOnEnvChange: Boolean(opts['restart-env']),
|
package/lib/cli/commands/run.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import process from 'node:process';
|
|
8
8
|
import { randomBytes } from 'node:crypto';
|
|
9
|
-
import { spawn } from 'node:child_process';
|
|
10
9
|
import { CliError } from '../../shared/errors.mjs';
|
|
10
|
+
import { spawnPinnedCwdProcess, spawnPinnedCwdPty } from '../../process/pinned-cwd.mjs';
|
|
11
|
+
import { captureSelectedCwdSnapshot } from '../../process/selected-cwd-identity.mjs';
|
|
11
12
|
import { tx } from '../../db/schema.mjs';
|
|
12
13
|
import { parseOpts, validateOpts } from '../../cli-args.mjs';
|
|
13
14
|
import { runtimeProcessIdentity } from '../../runtime/state.mjs';
|
|
@@ -57,6 +58,7 @@ async function cmdRun(ctx, args) {
|
|
|
57
58
|
const id = identity.id;
|
|
58
59
|
const role = opts.role || 'peer';
|
|
59
60
|
const cwd = path.resolve(opts.cwd || ctx.cwd);
|
|
61
|
+
const expectedCwdIdentity = captureSelectedCwdSnapshot(cwd);
|
|
60
62
|
const command = cmdArgs.length ? cmdArgs[0] : defaultSessionCommand(kind);
|
|
61
63
|
const commandArgs = cmdArgs.length ? cmdArgs.slice(1) : [];
|
|
62
64
|
const binding = bindingFromRun(id, kind, command, commandArgs, 'hcc-run');
|
|
@@ -67,6 +69,7 @@ async function cmdRun(ctx, args) {
|
|
|
67
69
|
kind,
|
|
68
70
|
role,
|
|
69
71
|
cwd,
|
|
72
|
+
expectedCwdIdentity,
|
|
70
73
|
command,
|
|
71
74
|
commandArgs,
|
|
72
75
|
binding,
|
|
@@ -131,10 +134,12 @@ async function cmdRun(ctx, args) {
|
|
|
131
134
|
db.close();
|
|
132
135
|
}
|
|
133
136
|
console.error(redactedLogText(`${CLI_NAME}: running ${id} (${kind}, ${role}) -> ${command} ${commandArgs.join(' ')}`.trim()));
|
|
134
|
-
const child =
|
|
137
|
+
const child = spawnPinnedCwdProcess(command, commandArgs, {
|
|
135
138
|
cwd,
|
|
139
|
+
expectedIdentity: expectedCwdIdentity,
|
|
136
140
|
stdio: 'inherit',
|
|
137
|
-
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath }
|
|
141
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath },
|
|
142
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
138
143
|
});
|
|
139
144
|
const exitCode = await new Promise((resolve) => {
|
|
140
145
|
child.on('exit', (code, signal) => resolve({ code, signal }));
|
|
@@ -174,7 +179,7 @@ async function cmdRun(ctx, args) {
|
|
|
174
179
|
* the local terminal and a shared buffer file so hcc web can stream it to
|
|
175
180
|
* browsers. Input from the browser is written to a .in file that we relay.
|
|
176
181
|
*/
|
|
177
|
-
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs, binding, force = false }) {
|
|
182
|
+
async function cmdRunWebManaged(ctx, { id, kind, role, cwd, expectedCwdIdentity, command, commandArgs, binding, force = false }) {
|
|
178
183
|
registerProjectActivity(ctx);
|
|
179
184
|
const ptyModule = await import('node-pty');
|
|
180
185
|
const pty = ptyModule.default || ptyModule;
|
|
@@ -265,9 +270,11 @@ async function cmdRunWebManaged(ctx, { id, kind, role, cwd, command, commandArgs
|
|
|
265
270
|
const cols = process.stdout.columns || 120;
|
|
266
271
|
const rows = process.stdout.rows || 40;
|
|
267
272
|
|
|
268
|
-
const child = pty
|
|
273
|
+
const child = spawnPinnedCwdPty(pty, command, commandArgs, {
|
|
269
274
|
name: 'xterm-256color', cols, rows, cwd,
|
|
270
|
-
|
|
275
|
+
expectedIdentity: expectedCwdIdentity,
|
|
276
|
+
env: childSessionEnv({ HCC_PEER: id, HCC_ROOT: ctx.root, HCC_DB: ctx.dbPath, TERM: 'xterm-256color' },
|
|
277
|
+
process.env, { rootIdentity: ctx.initialRootIdentity })
|
|
271
278
|
});
|
|
272
279
|
const childExit = trackPtyExit(child);
|
|
273
280
|
const wrapperTermination = installPtyTerminationHandlers(child);
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
// hcc scan detects provider sessions; the tmux helpers implement DB-proven,
|
|
3
3
|
// evidence-checked tmux GC (plan/validate/execute) for hcc-managed sessions.
|
|
4
4
|
|
|
5
|
-
import path from 'node:path';
|
|
6
5
|
import process from 'node:process';
|
|
7
6
|
import { spawnSync } from 'node:child_process';
|
|
8
7
|
import { CliError } from '../../shared/errors.mjs';
|
|
@@ -527,7 +526,7 @@ async function planTmuxGc(ctx, opts) {
|
|
|
527
526
|
continue;
|
|
528
527
|
}
|
|
529
528
|
const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');
|
|
530
|
-
if (hccRoot &&
|
|
529
|
+
if (hccRoot && rootEvidence(ctx.root, hccRoot).state !== 'match') {
|
|
531
530
|
skip('hcc_root_mismatch', { hcc_root: hccRoot });
|
|
532
531
|
continue;
|
|
533
532
|
}
|
package/lib/cli-runtime.mjs
CHANGED
|
@@ -2,15 +2,32 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import process from 'node:process';
|
|
4
4
|
import { projectDbPath } from './runtime/paths.mjs';
|
|
5
|
+
import { captureSelectedCwdSnapshot } from './process/selected-cwd-identity.mjs';
|
|
5
6
|
|
|
6
7
|
export function createContext(global = {}, deps = {}) {
|
|
7
8
|
const cwd = deps.cwd || process.cwd();
|
|
8
9
|
const detectRoot = deps.detectRoot;
|
|
9
10
|
if (typeof detectRoot !== 'function') throw new TypeError('createContext requires deps.detectRoot');
|
|
10
|
-
const
|
|
11
|
+
const requestedRoot = detectRoot(cwd, global.root);
|
|
12
|
+
// Freeze an existing root's canonical pathname for this command. A symlink
|
|
13
|
+
// supplied through --root (or HCC_ROOT) may be retargeted after context
|
|
14
|
+
// construction; later state and runtime path derivations must not silently
|
|
15
|
+
// switch projects. Missing roots retain their path for commands that create
|
|
16
|
+
// a project directory.
|
|
17
|
+
let root;
|
|
18
|
+
let initialRootIdentity = null;
|
|
19
|
+
try {
|
|
20
|
+
root = fs.realpathSync.native(requestedRoot);
|
|
21
|
+
initialRootIdentity = captureSelectedCwdSnapshot(root);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
25
|
+
root = requestedRoot;
|
|
26
|
+
}
|
|
11
27
|
const envDb = global.root ? null : process.env.HCC_DB;
|
|
12
28
|
const dbPath = path.resolve(global.db || envDb || projectDbPath(root));
|
|
13
|
-
return { cwd, root, dbPath, json: global.json,
|
|
29
|
+
return { cwd, root, dbPath, json: global.json,
|
|
30
|
+
explicitRoot: Boolean(global.root || process.env.HCC_ROOT), initialRootIdentity };
|
|
14
31
|
}
|
|
15
32
|
|
|
16
33
|
export function shellCommand(args, quoteArg) {
|
|
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { readTx, tx } from '../../db/schema.mjs';
|
|
4
4
|
import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
|
|
5
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
5
6
|
import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
|
|
6
7
|
|
|
7
8
|
export const HISTORY_GC_BATCH_SIZE = 256;
|
|
@@ -59,6 +60,10 @@ export function finalizeGcLockSubjects(db, subjects, evidenceByOwner, options =
|
|
|
59
60
|
result.live += 1;
|
|
60
61
|
continue;
|
|
61
62
|
}
|
|
63
|
+
if (isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(owner))) {
|
|
64
|
+
result.deferred += 1;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
62
67
|
if (dryRun || deleteExactRow(db, 'locks', planned.lock)) result.deleted += 1;
|
|
63
68
|
else result.deferred += 1;
|
|
64
69
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { isPeerEvidenceCompatibilityProtected } from '../peers/evidence.mjs';
|
|
2
3
|
|
|
3
4
|
export const WHOLE_LOCK_SCOPE = '*';
|
|
4
5
|
|
|
@@ -87,5 +88,6 @@ export function visibleLocksWithEvidence(locks, nowSec, {
|
|
|
87
88
|
evidenceByOwner = new Map()
|
|
88
89
|
} = {}) {
|
|
89
90
|
return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
|
|
90
|
-
evidenceByOwner.get(lock.owner)?.state === 'live'
|
|
91
|
+
evidenceByOwner.get(lock.owner)?.state === 'live' ||
|
|
92
|
+
isPeerEvidenceCompatibilityProtected(evidenceByOwner.get(lock.owner)));
|
|
91
93
|
}
|
|
@@ -1,18 +1,26 @@
|
|
|
1
|
-
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
1
|
+
import { compareProcessIdentity, isProcessIdentityIncompatible } from '../../process/identity.mjs';
|
|
2
2
|
|
|
3
3
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
4
|
+
const INCOMPATIBLE_PROCESS_REASON = 'process_identity_incompatible';
|
|
5
|
+
|
|
6
|
+
export function isPeerEvidenceCompatibilityProtected(evidence = {}) {
|
|
7
|
+
return evidence.state === 'unknown' && evidence.reason === INCOMPATIBLE_PROCESS_REASON;
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export function classifyPeerActivity(peer = {}, options = {}) {
|
|
6
11
|
const activePeerTtl = Number(options.activePeerTtl ?? DEFAULT_ACTIVE_PEER_TTL);
|
|
7
12
|
const graceActive = Boolean(options.graceActive);
|
|
8
13
|
const evidenceState = peer.evidence_state || peer.evidence?.state || 'unknown';
|
|
14
|
+
const evidenceReason = peer.evidence_reason || peer.evidence?.reason;
|
|
9
15
|
const age = Number(peer.age_sec);
|
|
10
16
|
const active = evidenceState === 'live' ||
|
|
17
|
+
isPeerEvidenceCompatibilityProtected({ state: evidenceState, reason: evidenceReason }) ||
|
|
11
18
|
(evidenceState !== 'dead' && (graceActive || (Number.isFinite(age) && age <= activePeerTtl)));
|
|
12
19
|
return { active, stale: !active };
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export function peerEvidenceAllowsReap(evidence = {}, options = {}) {
|
|
23
|
+
if (isPeerEvidenceCompatibilityProtected(evidence)) return false;
|
|
16
24
|
if (evidence.state === 'dead') return true;
|
|
17
25
|
if (evidence.state !== 'unknown') return false;
|
|
18
26
|
const nowSec = Number(options.nowSec);
|
|
@@ -37,6 +45,9 @@ function processState(evidence) {
|
|
|
37
45
|
if (evidence.current?.state === 'unknown') return 'unknown';
|
|
38
46
|
if (evidence.current?.state === 'dead') return 'dead';
|
|
39
47
|
if (evidence.current?.state !== 'live') return 'unknown';
|
|
48
|
+
if (isProcessIdentityIncompatible(evidence.storedIdentity, evidence.current.identity)) {
|
|
49
|
+
return 'incompatible';
|
|
50
|
+
}
|
|
40
51
|
return compareProcessIdentity(evidence.storedIdentity, evidence.current.identity);
|
|
41
52
|
}
|
|
42
53
|
|
|
@@ -48,6 +59,9 @@ function aggregateProcesses(processes) {
|
|
|
48
59
|
if (states.includes('live')) {
|
|
49
60
|
return { state: 'live', reason: 'process_identity_match' };
|
|
50
61
|
}
|
|
62
|
+
if (states.includes('incompatible')) {
|
|
63
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
64
|
+
}
|
|
51
65
|
if (states.every((state) => state === 'dead')) {
|
|
52
66
|
const missing = processes.every((item) => item?.current?.state === 'dead');
|
|
53
67
|
return {
|
|
@@ -81,8 +95,14 @@ function tmuxVerifiedLive(tmux) {
|
|
|
81
95
|
export function resolvePeerEvidence(input = {}) {
|
|
82
96
|
const ordinaryProcess = aggregateProcesses(input.processes);
|
|
83
97
|
if (ordinaryProcess.state === 'live') return ordinaryProcess;
|
|
98
|
+
// A live PID with incomparable Darwin generations proves neither ownership
|
|
99
|
+
// nor exit. Retain that distinction through persisted status and TTL gates.
|
|
100
|
+
if (isPeerEvidenceCompatibilityProtected(ordinaryProcess)) return ordinaryProcess;
|
|
84
101
|
|
|
85
102
|
const tmux = input.tmux?.managed ? input.tmux : null;
|
|
103
|
+
if (ordinaryProcess.state === 'unknown' && tmux && tmuxProcessState(tmux) === 'incompatible') {
|
|
104
|
+
return { state: 'unknown', reason: INCOMPATIBLE_PROCESS_REASON };
|
|
105
|
+
}
|
|
86
106
|
if (ordinaryProcess.state === 'unknown' && tmux && tmuxVerifiedLive(tmux)) {
|
|
87
107
|
return { state: 'live', reason: 'tmux_identity_match' };
|
|
88
108
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { compareProcessIdentity } from '../../process/identity.mjs';
|
|
2
2
|
import { tx } from '../../db/schema.mjs';
|
|
3
3
|
import { CliError } from '../../shared/errors.mjs';
|
|
4
|
+
import { isPeerEvidenceCompatibilityProtected } from './evidence.mjs';
|
|
4
5
|
|
|
5
6
|
function completeIdentity(identity) {
|
|
6
7
|
return Boolean(
|
|
@@ -84,6 +85,9 @@ export function validateTmuxGcBindingEvidence(subject = {}, observed = {}, optio
|
|
|
84
85
|
if (subject.owner_evidence?.state === 'live') {
|
|
85
86
|
return { ok: false, reason: 'tmux_owner_process_live' };
|
|
86
87
|
}
|
|
88
|
+
if (isPeerEvidenceCompatibilityProtected(subject.owner_evidence)) {
|
|
89
|
+
return { ok: false, reason: 'tmux_owner_process_incompatible' };
|
|
90
|
+
}
|
|
87
91
|
|
|
88
92
|
const dead = validateTmuxGcDeadProcessEvidence(authority, observed, options);
|
|
89
93
|
if (dead.ok) return { ok: true, mode: 'dead_process' };
|