@cortexkit/opencode-magic-context 0.36.1 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/config/project-security.d.ts +2 -1
  3. package/dist/config/project-security.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +10 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/{conflict-warning-hook-mt0ttc4x.js → conflict-warning-hook-an4fxhwf.js} +3 -3
  7. package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
  8. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  9. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  10. package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
  11. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  12. package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
  13. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  14. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  15. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  16. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  17. package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
  18. package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
  19. package/dist/features/magic-context/storage-db.d.ts +13 -2
  20. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-meta-persisted.d.ts +22 -0
  22. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  24. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  25. package/dist/features/magic-context/storage-primers.d.ts +11 -0
  26. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  28. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
  29. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  30. package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
  31. package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
  32. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
  35. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/execute-status.d.ts +2 -1
  37. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
  42. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/module-state-sync.d.ts +8 -0
  44. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/module-wire.d.ts +6 -1
  47. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/rust-mode-transform.d.ts +17 -0
  49. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  50. package/dist/hooks/magic-context/strip-content.d.ts +25 -0
  51. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +11 -3
  54. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/transform.d.ts +10 -0
  56. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  58. package/dist/{index-1skzp4dc.js → index-0ze9p983.js} +572 -92
  59. package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
  60. package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
  61. package/dist/{index-zp6x6s6a.js → index-cae89eas.js} +25 -6
  62. package/dist/index-cgyfn1s2.js +53 -0
  63. package/dist/{index-3zqcdwqr.js → index-crke4z5j.js} +7 -4
  64. package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
  65. package/dist/index-q0t189ay.js +741 -0
  66. package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +1172 -287
  69. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  70. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  71. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  72. package/dist/{recomp-orchestrator-a0g9b52q.js → recomp-orchestrator-c3ff16k7.js} +7 -6
  73. package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
  74. package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
  75. package/dist/shared/conflict-detector.d.ts +63 -0
  76. package/dist/shared/conflict-detector.d.ts.map +1 -1
  77. package/dist/shared/harness-provider-map.d.ts +8 -0
  78. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  79. package/dist/shared/logger.d.ts +8 -0
  80. package/dist/shared/logger.d.ts.map +1 -1
  81. package/dist/shared/models-dev-cache.d.ts +5 -0
  82. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  83. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  84. package/dist/shared/prompt-surface.d.ts +2 -0
  85. package/dist/shared/prompt-surface.d.ts.map +1 -1
  86. package/dist/shared/rpc-client.d.ts.map +1 -1
  87. package/dist/shared/rpc-types.d.ts +14 -0
  88. package/dist/shared/rpc-types.d.ts.map +1 -1
  89. package/dist/shared/rpc-utils.d.ts +19 -9
  90. package/dist/shared/rpc-utils.d.ts.map +1 -1
  91. package/dist/shared/window-geometry.d.ts +89 -0
  92. package/dist/shared/window-geometry.d.ts.map +1 -0
  93. package/dist/{storage-meta-session-df9tstzr.js → storage-meta-session-mk4gsnke.js} +4 -3
  94. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  95. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  96. package/dist/tui/data/context-db.d.ts.map +1 -1
  97. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  98. package/package.json +1 -1
  99. package/src/config/index.ts +2 -0
  100. package/src/config/latch-permanence-guard.test.ts +218 -0
  101. package/src/config/project-security.ts +10 -1
  102. package/src/config/schema/magic-context.ts +10 -0
  103. package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
  104. package/src/shared/conflict-detector.test.ts +176 -1
  105. package/src/shared/conflict-detector.ts +126 -3
  106. package/src/shared/harness-provider-map.test.ts +20 -0
  107. package/src/shared/harness-provider-map.ts +19 -0
  108. package/src/shared/logger.test.ts +119 -0
  109. package/src/shared/logger.ts +33 -15
  110. package/src/shared/models-dev-cache.test.ts +17 -0
  111. package/src/shared/models-dev-cache.ts +58 -6
  112. package/src/shared/prompt-surface-runtime.ts +5 -3
  113. package/src/shared/prompt-surface.test.ts +38 -0
  114. package/src/shared/prompt-surface.ts +15 -2
  115. package/src/shared/rpc-client.ts +4 -2
  116. package/src/shared/rpc-types.ts +14 -0
  117. package/src/shared/rpc-utils.test.ts +137 -13
  118. package/src/shared/rpc-utils.ts +144 -31
  119. package/src/shared/window-geometry.test.ts +309 -0
  120. package/src/shared/window-geometry.ts +629 -0
  121. package/src/tui/data/context-db.ts +5 -0
  122. package/src/tui/index.tsx +22 -0
  123. package/src/tui-compiled/data/context-db.ts +5 -0
  124. package/src/tui-compiled/index.tsx +319 -255
  125. package/dist/index-wgvxkqpx.js +0 -342
@@ -30,9 +30,15 @@ import { join } from "node:path";
30
30
  import type { ContextLimitProvenance } from "./context-limit-provenance";
31
31
  import { getMagicContextStorageDir } from "./data-path";
32
32
  import { getHarness } from "./harness";
33
- import { piModelRefToCanonical } from "./harness-provider-map";
33
+ import { modelRefLookupOrder } from "./harness-provider-map";
34
34
  import { sessionLog } from "./logger";
35
35
  import { shouldEnforcePrivateStoragePermissions } from "./storage-permissions";
36
+ import {
37
+ deriveWindowGeometry,
38
+ getWindowOverlay,
39
+ resolveWindowOverlayFacts,
40
+ type WindowGeometryResult,
41
+ } from "./window-geometry";
36
42
 
37
43
  interface OpencodeClientLike {
38
44
  config: {
@@ -196,14 +202,11 @@ function isFinitePositive(value: number | undefined): value is number {
196
202
  }
197
203
 
198
204
  function modelKeyLookupOrder(providerID: string, modelID: string): string[] {
199
- const full = `${providerID}/${modelID}`;
200
- const canonicalFull = piModelRefToCanonical(full);
201
- const candidates = [full, canonicalFull, modelID];
205
+ const candidates = [...modelRefLookupOrder(`${providerID}/${modelID}`), modelID];
202
206
  const colon = modelID.lastIndexOf(":");
203
207
  if (colon > 0) {
204
208
  const bareModel = modelID.slice(0, colon);
205
- const providerBare = `${providerID}/${bareModel}`;
206
- candidates.push(providerBare, piModelRefToCanonical(providerBare), bareModel);
209
+ candidates.push(...modelRefLookupOrder(`${providerID}/${bareModel}`), bareModel);
207
210
  }
208
211
  return [...new Set(candidates)];
209
212
  }
@@ -493,6 +496,50 @@ async function refreshModelLimitsOnce(client: OpencodeClientLike): Promise<boole
493
496
  * `ctx.model.contextWindow`), so for Pi this returns `undefined` and Pi's
494
497
  * own resolution path is used.
495
498
  */
499
+ export function getSdkWindowGeometry(
500
+ providerID: string,
501
+ modelID: string,
502
+ detectedContextLimit?: number,
503
+ options?: {
504
+ detectedLimitProvenance?: ContextLimitProvenance;
505
+ harness?: "opencode" | "pi";
506
+ },
507
+ ): WindowGeometryResult | undefined {
508
+ loadPersistedApiCacheOnce();
509
+ const metadata = lookupMetadataWithTagFallback(apiCache, providerID, modelID);
510
+ if (!metadata) return undefined;
511
+ const rawContext = metadata.contextLimit ?? metadata.limit;
512
+ const promptOnlyDetected =
513
+ options?.detectedLimitProvenance === "prompt_only" && isFinitePositive(detectedContextLimit)
514
+ ? detectedContextLimit
515
+ : undefined;
516
+ const result = deriveWindowGeometry(
517
+ providerID,
518
+ modelID,
519
+ {
520
+ context: rawContext,
521
+ input: metadata.inputLimit,
522
+ output: metadata.outputLimit,
523
+ },
524
+ {
525
+ overlay: resolveWindowOverlayFacts(providerID, modelID, getWindowOverlay()),
526
+ reserveConfig: outputReserveConfig,
527
+ harness: options?.harness ?? "opencode",
528
+ contextCap:
529
+ promptOnlyDetected === undefined && isFinitePositive(detectedContextLimit)
530
+ ? detectedContextLimit
531
+ : undefined,
532
+ },
533
+ );
534
+ if (!result || promptOnlyDetected === undefined) return result;
535
+ const usableSoft = promptOnlyDetected;
536
+ return {
537
+ ...result,
538
+ usableSoft,
539
+ usableHard: Math.max(usableSoft, Math.min(result.usableHard, promptOnlyDetected)),
540
+ };
541
+ }
542
+
496
543
  export function getSdkContextLimit(
497
544
  providerID: string,
498
545
  modelID: string,
@@ -502,6 +549,11 @@ export function getSdkContextLimit(
502
549
  detectedLimitProvenance?: ContextLimitProvenance;
503
550
  },
504
551
  ): number | undefined {
552
+ if (options?.reservation !== "none") {
553
+ return getSdkWindowGeometry(providerID, modelID, detectedContextLimit, {
554
+ detectedLimitProvenance: options?.detectedLimitProvenance,
555
+ })?.usableSoft;
556
+ }
505
557
  loadPersistedApiCacheOnce();
506
558
  const metadata = lookupMetadataWithTagFallback(apiCache, providerID, modelID);
507
559
  if (!metadata) return undefined;
@@ -13,6 +13,7 @@ import {
13
13
  CTX_REDUCE_LIGHT_DESCRIPTION,
14
14
  CTX_SEARCH_LIGHT_DESCRIPTION,
15
15
  } from "../tools/light-descriptions";
16
+ import { piModelRefToCanonical } from "./harness-provider-map";
16
17
  import { detectConfigFile } from "./jsonc-parser";
17
18
  import {
18
19
  type PromptSurfaceConfig,
@@ -242,13 +243,14 @@ export function createPromptSurfaceGuidanceEpochCache(runtime: PromptSurfaceRunt
242
243
 
243
244
  return {
244
245
  resolve(sessionId, config, modelKey) {
246
+ const canonicalModelKey = modelKey ? piModelRefToCanonical(modelKey) : undefined;
245
247
  const cached = epochs.get(sessionId);
246
- if (cached && cached.config === config && cached.modelKey === modelKey) {
248
+ if (cached && cached.config === config && cached.modelKey === canonicalModelKey) {
247
249
  return cached.selection;
248
250
  }
249
251
 
250
- const selection = runtime.resolveGuidance(config, modelKey);
251
- epochs.set(sessionId, { config, modelKey, selection });
252
+ const selection = runtime.resolveGuidance(config, canonicalModelKey);
253
+ epochs.set(sessionId, { config, modelKey: canonicalModelKey, selection });
252
254
  return selection;
253
255
  },
254
256
  clear(sessionId) {
@@ -47,6 +47,44 @@ describe("prompt-surface resolution", () => {
47
47
  }
48
48
  });
49
49
 
50
+ it("resolves Pi-native and canonical model keys with canonical precedence", () => {
51
+ const nativeKey = "openai-codex/gpt-5.6-sol";
52
+ const canonicalKey = "openai/gpt-5.6-sol";
53
+
54
+ expect(resolveCacheTtl({ default: "5m", [nativeKey]: "60m" }, canonicalKey)).toBe("60m");
55
+ expect(
56
+ resolvePromptSurface(
57
+ { default: "full", models: { [nativeKey]: "light" } },
58
+ canonicalKey,
59
+ ),
60
+ ).toEqual({ preset: "light", source: "exact" });
61
+ expect(
62
+ resolveCacheTtl(
63
+ { default: "5m", [nativeKey]: "60m", [canonicalKey]: "30m" },
64
+ nativeKey,
65
+ ),
66
+ ).toBe("30m");
67
+ expect(
68
+ resolvePromptSurface(
69
+ { default: "full", models: { [nativeKey]: "light", [canonicalKey]: "full" } },
70
+ nativeKey,
71
+ ),
72
+ ).toEqual({ preset: "full", source: "exact" });
73
+ });
74
+
75
+ it("leaves unknown provider keys unchanged", () => {
76
+ expect(
77
+ resolveCacheTtl(
78
+ { default: "5m", "custom-provider/model": "1m" },
79
+ "custom-provider/model",
80
+ ),
81
+ ).toBe("1m");
82
+ expect(modelKeyLookupOrder("custom-provider/model")[0]).toEqual({
83
+ key: "custom-provider/model",
84
+ source: "exact",
85
+ });
86
+ });
87
+
50
88
  it("derives a stable config identity independent of object key order", () => {
51
89
  const left = promptSurfaceConfigIdentity({
52
90
  default: "full",
@@ -1,3 +1,5 @@
1
+ import { modelRefLookupOrder } from "./harness-provider-map";
2
+
1
3
  /** The built-in prompt-surface variants. */
2
4
  export type PromptSurfacePreset = "full" | "light";
3
5
 
@@ -69,6 +71,8 @@ export interface ModelKeyCandidate {
69
71
  * provider/model boundary is the first slash; the rest of the string remains
70
72
  * the model ID, including additional slashes. Candidates are case-sensitive.
71
73
  *
74
+ * Known harness provider aliases are checked canonical-first at each specificity,
75
+ * so one shared config works on every harness and canonical wins on collisions.
72
76
  * Provider wildcards are checked after progressively less-specific model keys,
73
77
  * but before the caller's default. That keeps an exact or base-model override
74
78
  * authoritative while still allowing `provider/*` to cover otherwise-unlisted
@@ -82,10 +86,15 @@ export function modelKeyLookupOrder(modelKey: string | undefined): ModelKeyCandi
82
86
 
83
87
  const provider = modelKey.slice(0, slash);
84
88
  let modelID = modelKey.slice(slash + 1);
89
+ const providerRefs = modelRefLookupOrder(`${provider}/${modelID}`);
85
90
  const candidates: ModelKeyCandidate[] = [];
86
91
 
87
92
  while (modelID.length > 0) {
88
- candidates.push({ key: `${provider}/${modelID}`, source: "exact" });
93
+ for (const providerRef of providerRefs) {
94
+ const providerSlash = providerRef.indexOf("/");
95
+ const providerPrefix = providerRef.slice(0, providerSlash);
96
+ candidates.push({ key: `${providerPrefix}/${modelID}`, source: "exact" });
97
+ }
89
98
  candidates.push({ key: modelID, source: "bare" });
90
99
 
91
100
  const lastDash = modelID.lastIndexOf("-");
@@ -93,7 +102,11 @@ export function modelKeyLookupOrder(modelKey: string | undefined): ModelKeyCandi
93
102
  modelID = modelID.slice(0, lastDash);
94
103
  }
95
104
 
96
- candidates.push({ key: `${provider}/*`, source: "wildcard" });
105
+ for (const providerRef of providerRefs) {
106
+ const providerSlash = providerRef.indexOf("/");
107
+ const providerPrefix = providerRef.slice(0, providerSlash);
108
+ candidates.push({ key: `${providerPrefix}/*`, source: "wildcard" });
109
+ }
97
110
 
98
111
  const seen = new Set<string>();
99
112
  return candidates.filter((candidate) => {
@@ -149,7 +149,9 @@ export class MagicContextRpcClient {
149
149
  for (const entry of readdirSync(this.portDir)) {
150
150
  if (!entry.startsWith("port-") || !entry.endsWith(".json")) continue;
151
151
  const record = parseRpcPortFile(readFileSync(join(this.portDir, entry), "utf-8"));
152
- if (!record || !isPidAlive(record.pid)) continue;
152
+ // A denied liveness probe still leaves this as a candidate; the
153
+ // mandatory health check below confirms whether its RPC server is reachable.
154
+ if (!record || isPidAlive(record.pid) === "dead") continue;
153
155
  records.push(record);
154
156
  }
155
157
  } catch {
@@ -158,7 +160,7 @@ export class MagicContextRpcClient {
158
160
 
159
161
  try {
160
162
  const legacy = parseRpcPortFile(readFileSync(this.legacyPortFilePath, "utf-8"));
161
- if (legacy && (!legacy.pid || isPidAlive(legacy.pid))) records.push(legacy);
163
+ if (legacy && (!legacy.pid || isPidAlive(legacy.pid) !== "dead")) records.push(legacy);
162
164
  } catch {
163
165
  // Legacy discovery is optional.
164
166
  }
@@ -7,6 +7,7 @@ import type {
7
7
  DreamTaskBacklogMap,
8
8
  DreamTaskProgress,
9
9
  } from "../features/magic-context/dreamer/task-registry";
10
+ import type { LoggerDiagnostics } from "./logger";
10
11
 
11
12
  export interface SidebarSnapshot {
12
13
  sessionId: string;
@@ -135,6 +136,17 @@ export interface StatusDetail extends SidebarSnapshot {
135
136
  isSubagent: boolean;
136
137
  pendingOps: Array<{ tagId: number; operation: string }>;
137
138
  contextLimit: number;
139
+ windowGeometry?: {
140
+ usableSoft: number;
141
+ usableHard: number;
142
+ geometry: "shared_upfront" | "shared_truncating" | "separate";
143
+ derivation: {
144
+ window: number;
145
+ reserve: number;
146
+ reserveSource: "output_catalog" | "output_config" | "wall_margin" | "none";
147
+ geometry: "shared_upfront" | "shared_truncating" | "separate";
148
+ };
149
+ };
138
150
  /**
139
151
  * Parsed cache TTL in ms. -1 = never expires (cacheTtl "never"; Infinity
140
152
  * cannot ride JSON-RPC and 0 would be indistinguishable from unset). The
@@ -170,6 +182,8 @@ export interface StatusDetail extends SidebarSnapshot {
170
182
  toastDurationMs: number;
171
183
  /** One-line status data for the experimental memory mural. */
172
184
  mural?: { present: boolean; ageMs: number | null };
185
+ /** Runtime logger write failures observed by this plugin process. */
186
+ loggerDiagnostics: LoggerDiagnostics;
173
187
  /**
174
188
  * Stable storage-version probe: "which schema is the DB at, which fence does
175
189
  * this binary carry". Field names are deliberately snake_case, mirroring the
@@ -8,6 +8,7 @@ import {
8
8
  __setRpcIdentityTestHooks,
9
9
  discoverLivePiProcessIds,
10
10
  inspectLivePiProcesses,
11
+ isPidAlive,
11
12
  isPidIdentityPlausible,
12
13
  type RpcPortFileRecord,
13
14
  } from "./rpc-utils";
@@ -42,6 +43,13 @@ function psOutput(output: string | Error): typeof execFileSync {
42
43
  }) as typeof execFileSync;
43
44
  }
44
45
 
46
+ function tasklistOutput(entries: Array<[number, string]>): string {
47
+ return [
48
+ '"Image Name","PID","Session Name","Session#","Mem Usage"',
49
+ ...entries.map(([pid, command]) => `"${command}","${pid}","Console","1","10,000 K"`),
50
+ ].join("\r\n");
51
+ }
52
+
45
53
  afterEach(() => {
46
54
  __resetRpcIdentityTestHooks();
47
55
  });
@@ -78,6 +86,103 @@ describe("discoverLivePiProcessIds", () => {
78
86
  error: "ps unavailable",
79
87
  });
80
88
  });
89
+
90
+ test("uses tasklist instead of ps on Windows", () => {
91
+ const calls: string[] = [];
92
+ __setRpcIdentityTestHooks({
93
+ platform: "win32",
94
+ processListExecFileSync: ((file: string | URL) => {
95
+ calls.push(String(file));
96
+ return tasklistOutput([
97
+ [process.pid, "pi.exe"],
98
+ [41001, "pi.exe"],
99
+ [41002, "opencode.exe"],
100
+ ]);
101
+ }) as typeof execFileSync,
102
+ });
103
+
104
+ expect(inspectLivePiProcesses()).toEqual({
105
+ state: "known",
106
+ processIds: [41001],
107
+ });
108
+ expect(calls).toEqual(["tasklist"]);
109
+ });
110
+ });
111
+
112
+ describe("isPidAlive", () => {
113
+ test("distinguishes confirmed, dead, and inaccessible PID probes", () => {
114
+ const probeFailure = (code: string): NodeJS.ErrnoException => {
115
+ const error = new Error(`kill failed: ${code}`) as NodeJS.ErrnoException;
116
+ error.code = code;
117
+ return error;
118
+ };
119
+
120
+ __setRpcIdentityTestHooks({
121
+ processKill: (() => true) as typeof process.kill,
122
+ });
123
+ expect(isPidAlive(PID)).toBe("alive");
124
+
125
+ __setRpcIdentityTestHooks({
126
+ processKill: (() => {
127
+ throw probeFailure("ESRCH");
128
+ }) as typeof process.kill,
129
+ });
130
+ expect(isPidAlive(PID)).toBe("dead");
131
+
132
+ __setRpcIdentityTestHooks({
133
+ processKill: (() => {
134
+ throw probeFailure("EPERM");
135
+ }) as typeof process.kill,
136
+ });
137
+ expect(isPidAlive(PID)).toBe("inconclusive");
138
+ });
139
+
140
+ test("uses tasklist for Windows liveness and captures probe stderr", () => {
141
+ const calls: Array<{ file: string; args: readonly string[]; stdio: unknown }> = [];
142
+ __setRpcIdentityTestHooks({
143
+ platform: "win32",
144
+ execFileSync: ((
145
+ file: string | URL,
146
+ args: readonly string[] = [],
147
+ options: { stdio?: unknown } = {},
148
+ ) => {
149
+ calls.push({ file: String(file), args, stdio: options.stdio });
150
+ if (String(file) === "ps") throw new Error("ps must not run on Windows");
151
+ return tasklistOutput([[PID, "OpenCode.exe"]]);
152
+ }) as typeof execFileSync,
153
+ });
154
+
155
+ expect(isPidAlive(PID)).toBe("alive");
156
+ expect(calls).toEqual([
157
+ {
158
+ file: "tasklist",
159
+ args: ["/FO", "CSV", "/FI", `PID eq ${PID}`],
160
+ stdio: ["ignore", "pipe", "pipe"],
161
+ },
162
+ ]);
163
+ });
164
+
165
+ test("treats a successful tasklist no-match response as dead", () => {
166
+ __setRpcIdentityTestHooks({
167
+ platform: "win32",
168
+ execFileSync: (() =>
169
+ "INFO: No tasks are running which match the specified criteria.") as typeof execFileSync,
170
+ });
171
+
172
+ expect(isPidAlive(PID)).toBe("dead");
173
+ });
174
+
175
+ test("returns inconclusive when the Windows tasklist probe cannot spawn", () => {
176
+ __setRpcIdentityTestHooks({
177
+ platform: "win32",
178
+ execFileSync: (() => {
179
+ throw new Error("tasklist unavailable");
180
+ }) as typeof execFileSync,
181
+ });
182
+
183
+ expect(isPidAlive(PID)).toBe("inconclusive");
184
+ expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
185
+ });
81
186
  });
82
187
 
83
188
  describe("isPidIdentityPlausible", () => {
@@ -99,7 +204,7 @@ describe("isPidIdentityPlausible", () => {
99
204
  }) as typeof execFileSync,
100
205
  });
101
206
 
102
- expect(isPidIdentityPlausible(record(500_000))).toBe(false);
207
+ expect(isPidIdentityPlausible(record(500_000))).toBe("implausible");
103
208
  expect(readPaths).toEqual([`/proc/${PID}/stat`, "/proc/uptime"]);
104
209
  });
105
210
 
@@ -115,10 +220,10 @@ describe("isPidIdentityPlausible", () => {
115
220
 
116
221
  // The mocked process start is 1,100,000ms. The 120s tolerance is part of
117
222
  // the contract because port-file creation follows process startup.
118
- expect(isPidIdentityPlausible(record(1_000_000))).toBe(true);
223
+ expect(isPidIdentityPlausible(record(1_000_000))).toBe("plausible");
119
224
  });
120
225
 
121
- test("treats an unreadable Linux start-time probe as live", () => {
226
+ test("reports an unreadable Linux start-time probe as inconclusive", () => {
122
227
  __setRpcIdentityTestHooks({
123
228
  platform: "linux",
124
229
  readFileSync: linuxFiles({
@@ -126,17 +231,17 @@ describe("isPidIdentityPlausible", () => {
126
231
  }),
127
232
  });
128
233
 
129
- expect(isPidIdentityPlausible(record(500_000))).toBe(true);
234
+ expect(isPidIdentityPlausible(record(500_000))).toBe("inconclusive");
130
235
  });
131
236
 
132
- test("uses the legacy Linux command fallback and fails closed on probe errors", () => {
237
+ test("uses the legacy Linux command fallback and distinguishes probe errors", () => {
133
238
  __setRpcIdentityTestHooks({
134
239
  platform: "linux",
135
240
  readFileSync: linuxFiles({
136
241
  [`/proc/${PID}/cmdline`]: "/usr/sbin/opendkim --config /etc/opendkim.conf",
137
242
  }),
138
243
  });
139
- expect(isPidIdentityPlausible(record(0))).toBe(false);
244
+ expect(isPidIdentityPlausible(record(0))).toBe("implausible");
140
245
 
141
246
  __setRpcIdentityTestHooks({
142
247
  platform: "linux",
@@ -144,7 +249,7 @@ describe("isPidIdentityPlausible", () => {
144
249
  [`/proc/${PID}/cmdline`]: "/usr/local/bin/opencode serve",
145
250
  }),
146
251
  });
147
- expect(isPidIdentityPlausible(record(0))).toBe(true);
252
+ expect(isPidIdentityPlausible(record(0))).toBe("plausible");
148
253
 
149
254
  __setRpcIdentityTestHooks({
150
255
  platform: "linux",
@@ -152,7 +257,7 @@ describe("isPidIdentityPlausible", () => {
152
257
  [`/proc/${PID}/cmdline`]: new Error("procfs unavailable"),
153
258
  }),
154
259
  });
155
- expect(isPidIdentityPlausible(record(0))).toBe(true);
260
+ expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
156
261
  });
157
262
 
158
263
  test("uses ps start time and command probes on non-Linux platforms", () => {
@@ -162,7 +267,7 @@ describe("isPidIdentityPlausible", () => {
162
267
  });
163
268
  expect(
164
269
  isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 121_000)),
165
- ).toBe(false);
270
+ ).toBe("implausible");
166
271
 
167
272
  __setRpcIdentityTestHooks({
168
273
  platform: "darwin",
@@ -170,24 +275,43 @@ describe("isPidIdentityPlausible", () => {
170
275
  });
171
276
  expect(
172
277
  isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 120_000)),
173
- ).toBe(true);
278
+ ).toBe("plausible");
174
279
 
175
280
  __setRpcIdentityTestHooks({
176
281
  platform: "darwin",
177
282
  execFileSync: psOutput("/usr/sbin/opendkim -f"),
178
283
  });
179
- expect(isPidIdentityPlausible(record(0))).toBe(false);
284
+ expect(isPidIdentityPlausible(record(0))).toBe("implausible");
180
285
 
181
286
  __setRpcIdentityTestHooks({
182
287
  platform: "darwin",
183
288
  execFileSync: psOutput("/Applications/OpenCode.app/Contents/MacOS/opencode"),
184
289
  });
185
- expect(isPidIdentityPlausible(record(0))).toBe(true);
290
+ expect(isPidIdentityPlausible(record(0))).toBe("plausible");
186
291
 
187
292
  __setRpcIdentityTestHooks({
188
293
  platform: "darwin",
189
294
  execFileSync: psOutput(new Error("ps unavailable")),
190
295
  });
191
- expect(isPidIdentityPlausible(record(0))).toBe(true);
296
+ expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
297
+ });
298
+
299
+ test("uses tasklist for the Windows command fallback and skips unavailable start time", () => {
300
+ const calls: Array<{ file: string; args: readonly string[] }> = [];
301
+ __setRpcIdentityTestHooks({
302
+ platform: "win32",
303
+ execFileSync: ((file: string | URL, args: readonly string[] = []) => {
304
+ calls.push({ file: String(file), args });
305
+ if (String(file) === "ps") throw new Error("ps must not run on Windows");
306
+ return tasklistOutput([[PID, "OpenCode.exe"]]);
307
+ }) as typeof execFileSync,
308
+ });
309
+
310
+ expect(isPidIdentityPlausible(record(0))).toBe("plausible");
311
+ expect(calls).toEqual([{ file: "tasklist", args: ["/FO", "CSV", "/FI", `PID eq ${PID}`] }]);
312
+
313
+ calls.length = 0;
314
+ expect(isPidIdentityPlausible(record(NOW_MS))).toBe("inconclusive");
315
+ expect(calls).toEqual([]);
192
316
  });
193
317
  });