@logicseek/hello-cc 1.0.4 → 1.0.5

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 CHANGED
@@ -16,6 +16,41 @@ the release description from the current changelog section. Use
16
16
  `npm run release:github` with `GH_TOKEN` or `GITHUB_TOKEN` only for local
17
17
  backfills.
18
18
 
19
+ ## 1.0.5
20
+
21
+ ### Summary
22
+
23
+ macOS process identities use the kernel boot session UUID so changes to the
24
+ reported boot time cannot make a running process appear to have been replaced.
25
+ The issue was observed during post-release sustained acceptance of 1.0.4.
26
+
27
+ ### Highlights
28
+
29
+ - Preserve process identity across changes to the wall-clock-derived boot time.
30
+ - Keep live processes with an older Darwin identity format protected while their
31
+ ownership cannot be compared; a format change does not prove that an owner exited.
32
+ - Retain strict process state, start time and zombie checks, with unknown results
33
+ when a stable boot session identifier cannot be read.
34
+ - Keep the existing npm `latest` release channel.
35
+ - Confirm the original local runtime has exited when its stop response is lost,
36
+ while preserving unknown owners and replacement runtime pointers.
37
+ - Retain structured shutdown error codes when Node warnings share stderr.
38
+
39
+ ### Validation
40
+
41
+ Targeted regression tests cover boot identity drift, boot-session changes,
42
+ malformed observations and conservative handling of existing runtime ownership.
43
+ Full source, package, current-device and sustained-run validation are recorded
44
+ separately. The interrupted 1.0.4 sustained run remains a failed attempt.
45
+ The first 1.0.5 candidate passed the current-Mac sustained check; a subsequent
46
+ CI transport timeout exposed the separate stop-confirmation failure. Its
47
+ targeted verification and replacement candidate retain separate evidence.
48
+
49
+ Existing processes keep the implementation they started with. Quit and reopen
50
+ Harness normally to activate the update. An older active owned runtime should
51
+ finish and stop normally before it is reclaimed; do not force takeover based
52
+ on an incompatible identity marker.
53
+
19
54
  ## 1.0.4
20
55
 
21
56
  ### Summary
package/docs/dsh.md CHANGED
@@ -18,6 +18,8 @@ node /absolute/path/to/hello-cc/bin/hcc.mjs --root /path/to/project dsh web -- -
18
18
 
19
19
  Below, `hcc` means a CLI containing these changes. An older global installation may not include them.
20
20
 
21
+ macOS 1.0.5 upgrades use a stable boot-session identity. Follow the [process identity migration notes](macos-process-identity.md) and restart owned runtimes normally.
22
+
21
23
  ## Choose An Entry Point
22
24
 
23
25
  | Entry | Use | Lifecycle owner |
@@ -113,7 +115,7 @@ Project overlays work directly from source. The managed manifest records the Cor
113
115
 
114
116
  ```bash
115
117
  npm pack --ignore-scripts
116
- dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.4.tgz
118
+ dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.5.tgz
117
119
  ```
118
120
 
119
121
  Harness requires explicit approval for native dependency builds. If add returns `ERR_PNPM_IGNORED_BUILDS`, merge the following into the selected profile's `pnpm-workspace.yaml` and rerun the same add command. The default path is `~/.dsh/profiles/web/pnpm-workspace.yaml`; use the configured DSH_HOME when different. Preserve existing settings and approve only the candidate's pinned native dependency:
package/docs/dsh.zh-CN.md CHANGED
@@ -18,6 +18,8 @@ node /absolute/path/to/hello-cc/bin/hcc.mjs --root /path/to/project dsh web -- -
18
18
 
19
19
  下文 `hcc` 指包含这些代码的 CLI。已有的全局 `hcc` 未必包含此接入。
20
20
 
21
+ macOS 从 1.0.5 起使用稳定的启动周期标记。升级时按[进程身份迁移说明](macos-process-identity.md)正常重启所管理的运行时,保留身份无法比较的旧进程。
22
+
21
23
  ## 选择入口
22
24
 
23
25
  | 入口 | 适用场景 | 会话由谁持有 |
@@ -115,7 +117,7 @@ hcc native start --peer dsh-reviewer --provider dsh --binary /absolute/path/to/d
115
117
 
116
118
  ```bash
117
119
  npm pack --ignore-scripts
118
- dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.4.tgz
120
+ dsh plugin --profile web add /absolute/path/to/logicseek-hello-cc-1.0.5.tgz
119
121
  ```
120
122
 
121
123
  Harness 的 pnpm 策略要求明确允许原生依赖构建。若首次 add 返回 `ERR_PNPM_IGNORED_BUILDS`,在所选 profile 的 `pnpm-workspace.yaml` 中合并以下配置,然后重跑同一 add 命令;默认位置是 `~/.dsh/profiles/web/pnpm-workspace.yaml`,自定义 DSH_HOME 时以该目录为准。保留文件中已有设置,只批准候选包使用的固定版本:
@@ -0,0 +1,13 @@
1
+ # macOS 进程身份与升级
2
+
3
+ macOS 的 `kern.boottime` 是墙上时钟时间,同一次开机期间也可能因时钟校正改变。以它的秒、微秒字段识别进程,会把仍在运行的进程误判为新实例。
4
+
5
+ Darwin 身份改为 `darwin:<kern.bootsessionuuid>:<UTC/C ps lstart>`。开机 UUID 严格验证并统一小写;缺失、查询失败或格式错误时返回 `unknown`,不会退回 `kern.boottime`。进程开始时间仍前后读取两次,状态中的 zombie 仍视为已退出。PID、进程开始时间和命令摘要的所有权校验没有放宽。
6
+
7
+ 旧版本保存的 `<boot 秒>:<boot 微秒>:<ps lstart>` 无法可靠转换成新标记。对同一存活 PID,旧、新格式之间返回 `unknown`,既不授予所有权,也不证明旧进程已经退出。其 runtime 指针不因超过普通未知状态宽限期而被回收;Cordis 恢复和 native 启动也必须保留仍存活的旧 owner。与真实进程退出或同格式 PID 复用有关的判定保持原有规则。
8
+
9
+ 这种专用 `unknown` 同样保留 peer/binding、过期锁和缓冲文件,阻止过期任务被接管;它不会赋予 `live` 身份、续租或写权限。普通未知状态的超时策略不变。
10
+
11
+ 升级前,先用原版本正常关闭所属 native worker/runtime,正常退出 Harness Desktop,再安装并启动新版本,使运行中的 owner 重新建立身份。保留账号配置、会话与协作数据库;不要手工改写旧身份、删除仍存活 runtime 的指针,或仅凭 PID 强行接管。若旧进程已经退出,OS 的死亡证据仍允许正常恢复。没有完整身份的其他未知状态继续使用原有策略。
12
+
13
+ 回归覆盖同一次开机期间 `kern.boottime` 漂移、不同 boot UUID、错误和缺失 UUID、PATH 缺失时的固定系统路径、语言环境、PID 复用、exec、真实自有 zombie,以及跨格式 runtime 指针、native 启动和 Cordis 恢复边界。
package/lib/cli/app.mjs CHANGED
@@ -54,7 +54,8 @@ import { readPackageMeta } from '../package-meta.mjs';
54
54
  import { removeGuidanceBlocks as removeGuidanceBlocksForRoot, writeGuidance as writeGuidanceForRoot } from '../guidance.mjs';
55
55
  import { globalRuntimePath, runtimePath } from '../runtime/paths.mjs';
56
56
  import { readRuntime, reclaimRuntimePointerFiles } from '../runtime/state.mjs';
57
- import { waitForProcessIdentityExit } from '../process/identity.mjs';
57
+ import { waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity } from '../process/identity.mjs';
58
+ import { withFileLock } from '../shared/file-lock.mjs';
58
59
 
59
60
  import { runtimeRequest } from '../runtime/client.mjs';
60
61
 
@@ -319,7 +320,7 @@ const { cmdDown, cmdFindRoot, cmdWhichReal } = createMiscCommands({
319
320
  path, fs, process, CliError, parseOpts, printResult,
320
321
  readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
321
322
  reclaimRuntimePointerFiles, helpDown, loadSetup,
322
- waitForProcessIdentityExit,
323
+ waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
323
324
  PRODUCT_NAME
324
325
  });
325
326
 
@@ -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) {
@@ -2,13 +2,56 @@
2
2
 
3
3
  export function createMiscCommands(deps) {
4
4
  const {
5
- path, process, CliError, parseOpts, printResult,
5
+ path, fs, process, CliError, parseOpts, printResult,
6
6
  readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
7
7
  reclaimRuntimePointerFiles, helpDown, loadSetup,
8
- waitForProcessIdentityExit,
8
+ waitForProcessIdentityExit, inspectProcessIdentity, compareProcessIdentity, withFileLock,
9
9
  PRODUCT_NAME
10
10
  } = deps;
11
11
 
12
+ function sameStopPointer(left, right) {
13
+ return left.raw === right.raw && ['dev', 'ino', 'size', 'mtimeMs', 'ctimeMs']
14
+ .every((key) => left.stat[key] === right.stat[key]);
15
+ }
16
+
17
+ function readStopPointer(file) {
18
+ const stat = fs.lstatSync(file);
19
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('invalid runtime pointer');
20
+ const raw = fs.readFileSync(file, 'utf8');
21
+ const current = fs.lstatSync(file);
22
+ if (current.isSymbolicLink() || !current.isFile() ||
23
+ !sameStopPointer({ raw, stat }, { raw, stat: current })) {
24
+ throw new Error('runtime pointer changed');
25
+ }
26
+ return { raw, stat };
27
+ }
28
+
29
+ function stopOwnerEvidence(runtime, pointerFiles) {
30
+ try {
31
+ if (runtime.source === 'env') return null;
32
+ const source = pointerFiles.find((file) => path.resolve(file) === path.resolve(runtime.source || ''));
33
+ const url = new URL(runtime.base_url);
34
+ if (!source || !['http:', 'https:'].includes(url.protocol) ||
35
+ !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)) return null;
36
+ const owner = runtime.process_identity || runtime.processIdentity;
37
+ if (Object.hasOwn(runtime, 'process_identity') && Object.hasOwn(runtime, 'processIdentity')) return null;
38
+ if (owner?.pid !== Number(runtime.pid) || compareProcessIdentity(owner, owner) !== 'live') return null;
39
+ const observed = inspectProcessIdentity(owner.pid);
40
+ if (observed.state !== 'live' || compareProcessIdentity(owner, observed.identity) !== 'live' ||
41
+ owner.commandHash !== observed.identity.commandHash) return null;
42
+ const pointer = readStopPointer(source);
43
+ const recorded = JSON.parse(pointer.raw);
44
+ if (Object.hasOwn(recorded, 'process_identity') && Object.hasOwn(recorded, 'processIdentity')) return null;
45
+ const recordedOwner = recorded.process_identity || recorded.processIdentity;
46
+ if (Number(recorded.pid) !== owner.pid ||
47
+ compareProcessIdentity(owner, recordedOwner) !== 'live' || owner.commandHash !== recordedOwner.commandHash ||
48
+ ['base_url', 'token', 'tls_ca_file'].some((key) => recorded[key] !== runtime[key])) return null;
49
+ return { source, owner, pointer };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
12
55
  async function cmdDown(ctx, args) {
13
56
  if (args[0] === '--help' || args[0] === '-h') return helpDown();
14
57
  const pointerFiles = [runtimePath(ctx), globalRuntimePath()];
@@ -23,17 +66,39 @@ export function createMiscCommands(deps) {
23
66
  `${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
24
67
  return;
25
68
  }
69
+ const stopEvidence = stopOwnerEvidence(runtime, pointerFiles);
26
70
  try {
27
71
  await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
28
72
  } catch (err) {
29
73
  if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
30
- if (runtime.source === 'env') throw err;
31
- const source = path.resolve(String(runtime.source || ''));
32
- const localSource = pointerFiles.find((file) => path.resolve(file) === source);
33
- if (!localSource) throw err;
34
- const cleanup = reclaimRuntimePointerFiles([localSource], { reclaimUnknown: false });
35
- if (cleanup.reclaimed !== 1 || cleanup.blocked) throw err;
36
- printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
74
+ if (!stopEvidence) throw err;
75
+ const stopped = await waitForProcessIdentityExit(stopEvidence.owner, {
76
+ timeoutMs: 5_000,
77
+ intervalMs: 25
78
+ });
79
+ if (stopped.state !== 'dead') throw err;
80
+ let cleanup;
81
+ try {
82
+ cleanup = withFileLock(stopEvidence.source, (file) => {
83
+ let current;
84
+ try { current = readStopPointer(file); }
85
+ catch (error) {
86
+ if (error.code === 'ENOENT') return { reclaimed: 0, blocked: false };
87
+ throw error;
88
+ }
89
+ if (!sameStopPointer(stopEvidence.pointer, current)) throw err;
90
+ // Hold the pointer's existing lock across comparison and reclamation.
91
+ // A replacement owner must never be removed by this stop request.
92
+ return reclaimRuntimePointerFiles([file], {
93
+ reclaimUnknown: false,
94
+ withLock: (_file, operation) => operation(file)
95
+ });
96
+ }, { createParent: false });
97
+ } catch { throw err; }
98
+ if (cleanup.blocked || cleanup.reclaimed > 1) throw err;
99
+ printResult(ctx, { runtime: stopEvidence.source }, () => cleanup.reclaimed === 1
100
+ ? `${PRODUCT_NAME} stale runtime pointer removed`
101
+ : `${PRODUCT_NAME} runtime stopped`);
37
102
  return;
38
103
  }
39
104
  if (runtime.source !== 'env') {
@@ -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' };
@@ -16,7 +16,7 @@ import { createCoordinationState } from '../coordination-state.mjs';
16
16
  import { createScopedMcpTools } from '../mcp/tools.mjs';
17
17
  import { resolveProjectDatabase } from '../runtime/project-path.mjs';
18
18
  import { registerProjectActivity } from '../runtime/projects.mjs';
19
- import { inspectProcessIdentity } from '../process/identity.mjs';
19
+ import { compareProcessIdentity, inspectProcessIdentity } from '../process/identity.mjs';
20
20
 
21
21
  const fail = (message) => { throw new CliError('DSH_COLLABORATION_CONFLICT', message); };
22
22
 
@@ -29,9 +29,8 @@ function verifiedDeadOwner(peer) {
29
29
  // A recycled PID has a different start token. A changed command alone does
30
30
  // not prove the original process exited (a live process may change its title).
31
31
  return observed.state === 'live' && observed.identity?.pid === peer.pid &&
32
- typeof observed.identity.startToken === 'string' &&
33
- typeof observed.identity.commandHash === 'string' && /^[a-f0-9]{64}$/.test(observed.identity.commandHash) &&
34
- observed.identity.startToken !== peer.pid_start_token;
32
+ compareProcessIdentity({ pid: peer.pid, startToken: peer.pid_start_token,
33
+ commandHash: peer.pid_command_hash }, observed.identity) === 'dead';
35
34
  }
36
35
 
37
36
  // One in-process authority per Agent. All mutations still go through the same
@@ -84,17 +84,20 @@ function run(command, args, environment) {
84
84
  return spawnSync(command, args, options);
85
85
  }
86
86
 
87
+ const MAC_BOOT_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
88
+
87
89
  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;
90
+ const uuid = String(text ?? '').trim();
91
+ return MAC_BOOT_UUID.test(uuid) ? uuid.toLowerCase() : null;
92
+ }
92
93
 
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}`;
94
+ function macTokenFormat(token) {
95
+ const start = '(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) [A-Z][a-z]{2}\\s+\\d{1,2} \\d{2}:\\d{2}:\\d{2} \\d{4}';
96
+ const legacy = new RegExp(`^(\\d+):(\\d+):${start}$`).exec(token);
97
+ if (legacy && BigInt(legacy[1]) > 0n && Number(legacy[2]) >= 0 && Number(legacy[2]) <= 999999) return 'legacy-wall-clock';
98
+ const current = /^darwin:([^:]+):(.+)$/.exec(token);
99
+ if (current && MAC_BOOT_UUID.test(current[1]) && new RegExp(`^${start}$`).test(current[2])) return 'boot-session';
100
+ return null;
98
101
  }
99
102
 
100
103
  function parseMacStateStart(text) {
@@ -106,9 +109,11 @@ function parseMacStateStart(text) {
106
109
 
107
110
  function macObservation(pid) {
108
111
  const unknown = { state: 'unknown', identity: null };
109
- let boot = run('sysctl', ['-n', 'kern.boottime']);
112
+ // kern.boottime is a wall-clock timestamp and can change during one boot.
113
+ // The boot-session UUID remains stable across those clock corrections.
114
+ let boot = run('sysctl', ['-n', 'kern.bootsessionuuid']);
110
115
  // Launchers often omit /usr/sbin from PATH; macOS still provides sysctl here.
111
- if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.boottime']);
116
+ if (boot.error?.code === 'ENOENT') boot = run('/usr/sbin/sysctl', ['-n', 'kern.bootsessionuuid']);
112
117
  if (boot.error || boot.status !== 0) return unknown;
113
118
  const bootToken = parseMacBootToken(boot.stdout);
114
119
  if (!bootToken) return unknown;
@@ -140,7 +145,7 @@ function macObservation(pid) {
140
145
  state: 'live',
141
146
  identity: {
142
147
  pid,
143
- startToken: `${bootToken}:${parsed.startToken}`,
148
+ startToken: `darwin:${bootToken}:${parsed.startToken}`,
144
149
  commandHash: hashCommand(parsed.command)
145
150
  }
146
151
  };
@@ -187,10 +192,9 @@ export async function waitForProcessIdentityExit(value, options = {}) {
187
192
  const observed = inspect(pid);
188
193
  if (observed?.state === 'dead') return { state: 'dead', identity: null };
189
194
  if (observed?.state === 'live' && isCompleteIdentity(observed.identity)) {
190
- if (storedIdentity && compareProcessIdentity(storedIdentity, observed.identity) === 'dead') {
191
- return { state: 'dead', identity: null };
192
- }
193
- lastObservation = observed;
195
+ const comparison = storedIdentity ? compareProcessIdentity(storedIdentity, observed.identity) : 'live';
196
+ if (comparison === 'dead') return { state: 'dead', identity: null };
197
+ lastObservation = comparison === 'live' ? observed : { state: 'unknown', identity: null };
194
198
  } else {
195
199
  lastObservation = { state: 'unknown', identity: null };
196
200
  }
@@ -238,8 +242,19 @@ export function inspectProcessIdentity(value) {
238
242
  return { state: 'live', identity };
239
243
  }
240
244
 
245
+ export function isProcessIdentityIncompatible(stored, current) {
246
+ if (!isCompleteIdentity(stored) || !isCompleteIdentity(current) || stored.pid !== current.pid) return false;
247
+ const storedFormat = macTokenFormat(stored.startToken), currentFormat = macTokenFormat(current.startToken);
248
+ return Boolean(storedFormat && currentFormat && storedFormat !== currentFormat);
249
+ }
250
+
241
251
  export function compareProcessIdentity(stored, current) {
242
252
  if (!isCompleteIdentity(stored) || !isCompleteIdentity(current)) return 'unknown';
243
- return stored.pid === current.pid &&
244
- stored.startToken === current.startToken ? 'live' : 'dead';
253
+ if (stored.pid !== current.pid) return 'dead';
254
+ if (stored.startToken === current.startToken) return 'live';
255
+ // An upgrade cannot translate wall-clock boot evidence to a boot UUID.
256
+ // Preserve a still-present legacy owner until it exits normally; a format
257
+ // change proves neither ownership nor process exit.
258
+ if (isProcessIdentityIncompatible(stored, current)) return 'unknown';
259
+ return 'dead';
245
260
  }
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { performance } from 'node:perf_hooks';
4
4
 
5
- import { resolvePeerEvidence } from '../core/peers/evidence.mjs';
5
+ import { isPeerEvidenceCompatibilityProtected, resolvePeerEvidence } from '../core/peers/evidence.mjs';
6
6
  import { inspectProcessIdentity } from '../process/identity.mjs';
7
7
 
8
8
  function canonicalDirectories(values) {
@@ -261,6 +261,9 @@ export function collectBufferEvidence({
261
261
  if (evidence.state === 'live') {
262
262
  recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
263
263
  addExternalGroup(protectedPaths, directory, id);
264
+ } else if (isPeerEvidenceCompatibilityProtected(evidence)) {
265
+ recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
266
+ addExternalGroup(protectedPaths, directory, id);
264
267
  } else if (evidence.state === 'unknown' &&
265
268
  boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
266
269
  addExternalGroup(unknownPaths, directory, id);
@@ -302,6 +305,9 @@ export function collectBufferEvidence({
302
305
  if (evidence.state === 'live') {
303
306
  recordKnownEvidence(unknownTracker, file, 'live', monotonicNowMs);
304
307
  protectedPaths.add(file);
308
+ } else if (isPeerEvidenceCompatibilityProtected(evidence)) {
309
+ recordKnownEvidence(unknownTracker, file, 'incompatible', monotonicNowMs);
310
+ protectedPaths.add(file);
305
311
  } else if (evidence.state === 'unknown' &&
306
312
  boundedUnknown(file, { nowMs, monotonicNowMs, unknownGraceMs, unknownTracker, gcCutoffs })) {
307
313
  unknownPaths.add(file);
@@ -50,7 +50,11 @@ export function classifyRuntimePointer(runtime, {
50
50
  if (current?.state !== 'live' || !completeProcessIdentity(current.identity)) {
51
51
  return { state: 'unknown', reclaimable: ageMs >= unknownGraceMs };
52
52
  }
53
- return compareProcessIdentity(stored, current.identity) === 'live'
53
+ const comparison = compareProcessIdentity(stored, current.identity);
54
+ // A live PID observed with an incompatible identity format is not an aged
55
+ // missing/unknown owner. In particular, do not reap a pre-upgrade Mac owner.
56
+ if (comparison === 'unknown') return { state: 'unknown', reclaimable: false };
57
+ return comparison === 'live'
54
58
  ? { state: 'alive', reclaimable: false }
55
59
  : { state: 'dead', reclaimable: true };
56
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@logicseek/hello-cc",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Local control plane for coordinating Claude Code, Codex, and other coding CLI sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -158,9 +158,50 @@ export async function waitForFixtureOwnerExit(owner, timeoutMs, {
158
158
  }
159
159
  }
160
160
 
161
+ function fixtureErrorPayload(output) {
162
+ const lines = String(output || '').split(/\r?\n/);
163
+ let start = null, standalone = false, quoted = false, escaped = false;
164
+ const stack = [];
165
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
166
+ const line = lines[lineIndex];
167
+ for (let offset = 0; offset < line.length; offset += 1) {
168
+ const char = line[offset];
169
+ if (start === null) {
170
+ if (char !== '{' && char !== '[') continue;
171
+ start = lineIndex;
172
+ standalone = line.slice(0, offset).trim() === '';
173
+ }
174
+ if (quoted) {
175
+ if (escaped) escaped = false;
176
+ else if (char === '\\') escaped = true;
177
+ else if (char === '"') quoted = false;
178
+ continue;
179
+ }
180
+ if (char === '"') quoted = true;
181
+ else if (char === '{' || char === '[') stack.push(char);
182
+ else if (char === '}' || char === ']') {
183
+ if (stack.pop() !== (char === '}' ? '{' : '[')) return null;
184
+ if (stack.length) continue;
185
+ // Consume arrays and prefixed JSON blocks as a unit, so their nested
186
+ // objects can never masquerade as independent CLI error documents.
187
+ if (standalone && line.slice(offset + 1).trim() === '') {
188
+ try {
189
+ const parsed = JSON.parse(lines.slice(start, lineIndex + 1).join('\n'));
190
+ if (parsed && !Array.isArray(parsed) && parsed.error &&
191
+ typeof parsed.error === 'object' && !Array.isArray(parsed.error)) return parsed;
192
+ } catch {}
193
+ }
194
+ start = null;
195
+ }
196
+ }
197
+ }
198
+ return null;
199
+ }
200
+
161
201
  export function fixtureDownResult(result) {
162
- let payload = null;
163
- try { payload = JSON.parse(result.stdout || ''); } catch {}
202
+ // Public --json failures share stderr with Node warnings. Accept only whole
203
+ // independent JSON objects; ordinary output never enters the diagnostic.
204
+ const payload = fixtureErrorPayload(result.stdout) || fixtureErrorPayload(result.stderr);
164
205
  const extra = payload?.error?.extra || payload?.error || {};
165
206
  const code = payload?.error?.code || result.error?.code;
166
207
  const numeric = ['pid', 'elapsedMs', 'timeoutMs'];