@junghanacs/entwurf 0.13.1 → 0.14.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 (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -1,323 +0,0 @@
1
- /**
2
- * entwurf-v2-spawn — the 5c-3a spawn-bg RESUME watcher hand (0.11 Stage 0 step 5c-3a).
3
- * It WIRES the real spawn + socket-observe IO onto the pure release reducer (5c-1):
4
- * launch the resume child, watch for the FIRST observable liveness transition, feed
5
- * that observation to `reduceRelease`, and release the held lock EXACTLY ONCE — or, if
6
- * NO observation can be obtained, RETAIN the lock and surface the evidence rather than
7
- * release blind. Every IO seam is an injected dep (the gate fakes it with controlled
8
- * promises), so the spawn→observe→release ORDERING is gate-provable without a real
9
- * child, socket, or timer — the same "pure-before-IO, IO-via-dep" discipline 5b/5c-2
10
- * used.
11
- *
12
- * The load-bearing contract (Fable 3, the whole reason 5c is sliced pure-before-IO):
13
- * TIMEOUT IS NOT A RELEASE EVENT. A spawn-bg dispatch holds its per-gid lock until an
14
- * OBSERVED transition — `socket-alive` (the resumed child stood its control socket up)
15
- * or `child-exited` (any code, incl. null = killed by signal). A bare `observeTimeoutMs`
16
- * expiry proves NOTHING (the child may stand its socket up a moment later), so releasing
17
- * on it would reopen the exact double-spawn window 5a's lock exists to close. Instead the
18
- * timeout RESOLVES BY OBSERVATION: kill the child, then wait a BOUNDED `killGraceMs` for
19
- * the kill to produce a real `child-exited` (or a racing `socket-alive`). Only THAT
20
- * observation releases. If even the grace elapses with no observation, the hand does NOT
21
- * release — it returns a `lock-retained` diagnostic (released:false, with pid / socket /
22
- * lockPath / timeouts) so an operator can SEE the long-held lock, exactly as F2-P2
23
- * ("관측 가능해야 수용") demands. The function always returns BOUNDED — it never hangs.
24
- *
25
- * Post-spawn unexpected dep failure follows the SAME rule (GPT 5c-3a correction): once a
26
- * child exists, an observation-less release is forbidden. A watch/timer/kill dep that
27
- * throws is handled by best-effort kill → bounded attempt to OBSERVE the exit → release
28
- * if observed, else `lock-retained` fail-closed. There is NO direct-release escape hatch:
29
- * `deps.releaseLock` is reached ONLY through `reduceRelease` on a real observation event.
30
- *
31
- * Release authority is ALWAYS the LockClaim the decider handed over; the watcher reads
32
- * ONLY `plan.expectedSocketPath` and never re-derives a socket path or a lock by gid.
33
- */
34
-
35
- import type { ExecutionPlan } from "./entwurf-v2-decider.ts";
36
- import type { LockClaim } from "./entwurf-v2-lock.ts";
37
- import {
38
- decideReleasePolicy,
39
- initialReleaseState,
40
- type ReleaseEvent,
41
- type ReleasePolicy,
42
- type ReleaseState,
43
- reduceRelease,
44
- } from "./entwurf-v2-release.ts";
45
-
46
- /** The spawn-bg plan shape, narrowed from the decider's ExecutionPlan union. */
47
- export type SpawnBgPlan = Extract<ExecutionPlan, { transport: "spawn-bg" }>;
48
-
49
- /** A started resume child. `pid` is diagnostic-only (surfaced on a retained lock); the
50
- * watcher tracks the child by this opaque handle, never by gid. */
51
- export interface SpawnedChild {
52
- pid?: number;
53
- }
54
-
55
- /**
56
- * Every IO seam is a REQUIRED dep — the hand performs ZERO IO of its own so the gate can
57
- * drive every observation order without a real spawn/socket/timer.
58
- * - spawnChild — launch the resume child. Resolves a handle on a STARTED process;
59
- * THROWS on a spawn-time failure (→ `spawn-start-failed`: no child to watch).
60
- * - awaitSocketAlive — resolve when `expectedSocketPath` becomes observable (the child
61
- * stood its control socket up). NEVER resolves if the socket never appears — the
62
- * timeout path handles that. Honors the abort signal so a loser can be torn down.
63
- * - awaitChildExit — resolve with the child's exit code (null = killed by signal).
64
- * Honors the abort signal.
65
- * - awaitTimeout — resolve after `ms` (the observe deadline, and again the kill
66
- * grace). Honors the abort signal. NOTE: a timeout is NEVER fed to the reducer.
67
- * - killChild — terminate the started child. Used ONLY to escalate a timeout (or
68
- * a post-spawn dep failure) into a real `child-exited`; it is never a release itself.
69
- * - releaseLock — release the held claim; reached ONLY via reduceRelease, at most
70
- * once, only on a real observation event.
71
- * - killGraceMs — bounded wait, after a kill, for the resulting observation. Keeps
72
- * the watcher from hanging on a zombie: if it elapses, the lock is RETAINED (surfaced),
73
- * not released. A watcher-policy constant (NOT a routing decision — so it lives on the
74
- * dep, not the decider's plan).
75
- */
76
- export interface SpawnBgResumeDeps {
77
- spawnChild: (plan: SpawnBgPlan) => Promise<SpawnedChild>;
78
- awaitSocketAlive: (socketPath: string, signal: AbortSignal) => Promise<void>;
79
- awaitChildExit: (child: SpawnedChild, signal: AbortSignal) => Promise<number | null>;
80
- awaitTimeout: (ms: number, signal: AbortSignal) => Promise<void>;
81
- killChild: (child: SpawnedChild) => void;
82
- releaseLock: (lock: LockClaim) => void;
83
- killGraceMs: number;
84
- }
85
-
86
- /** Evidence carried on a `lock-retained` outcome so an operator can SEE (and clear) the
87
- * lock that no observation released. */
88
- export interface SpawnRetainedDiagnostic {
89
- targetGardenId: string;
90
- pid?: number;
91
- expectedSocketPath: string;
92
- lockPath: string;
93
- observeTimeoutMs: number;
94
- killGraceMs: number;
95
- }
96
-
97
- /**
98
- * The terminal outcome of a spawn-bg resume watch. The three RELEASED outcomes each fed
99
- * exactly one observation event to the reducer; the `lock-retained` outcome fed NONE (it
100
- * is the observation-less fail-closed — `released:false`, lock left for the operator).
101
- * - socket-alive — the resumed child stood its socket up.
102
- * - child-exited — the child exited (any code; null = signal/kill).
103
- * - spawn-start-failed — the child never started (spawnChild threw); nothing to watch.
104
- * - lock-retained — no observation was obtainable (kill grace elapsed, or a
105
- * post-spawn dep failed and the exit could not be observed). Lock NOT released.
106
- */
107
- export type SpawnBgResumeResult =
108
- | { kind: "socket-alive"; released: true; pid?: number }
109
- | { kind: "child-exited"; released: true; exitCode: number | null; pid?: number }
110
- | { kind: "spawn-start-failed"; released: true; error: string }
111
- | {
112
- kind: "lock-retained";
113
- released: false;
114
- reason: "kill-unconfirmed" | "observe-failed";
115
- error?: string;
116
- diagnostic: SpawnRetainedDiagnostic;
117
- };
118
-
119
- // An internal tag for the observation race: which source settled first.
120
- type Observation = { tag: "socket" } | { tag: "exit"; code: number | null } | { tag: "timeout" };
121
-
122
- function tagSocket(p: Promise<void>): Promise<Observation> {
123
- return p.then(() => ({ tag: "socket" }) as const);
124
- }
125
- function tagExit(p: Promise<number | null>): Promise<Observation> {
126
- return p.then((code) => ({ tag: "exit", code }) as const);
127
- }
128
- function tagTimeout(p: Promise<void>): Promise<Observation> {
129
- return p.then(() => ({ tag: "timeout" }) as const);
130
- }
131
-
132
- function errMsg(err: unknown): string {
133
- return err instanceof Error ? err.message : String(err);
134
- }
135
-
136
- /** Swallow a watcher promise's eventual rejection when it is abandoned without a race
137
- * handler (the S1 sync-throw path tears down before the race attaches one), so an aborted
138
- * loser cannot surface as an unhandledRejection. A no-op when the promise never started. */
139
- function defuse(p: Promise<unknown> | undefined): void {
140
- if (p) void p.catch(() => {});
141
- }
142
-
143
- /**
144
- * Launch a spawn-bg resume child and watch for the first observable liveness transition,
145
- * releasing the held lock EXACTLY ONCE on that observation — or RETAINING the lock (with
146
- * surfaced evidence) if no observation can be obtained. `lock` MUST be the in-domain claim
147
- * the decider handed over — `decideReleasePolicy` throws if it is null or paired with the
148
- * wrong gid (a mis-wire is fail-loud, not a runtime branch). Always returns bounded.
149
- *
150
- * A `releaseLock` throw on a RELEASED path propagates honestly (the observation already
151
- * happened, so the caller must NOT re-spawn; the lock is dirty and needs manual cleanup —
152
- * 5b masking direction). Such a throw is the ONE non-result exit; every other terminal
153
- * state is a returned `SpawnBgResumeResult`.
154
- */
155
- export async function executeSpawnBgResume(
156
- plan: SpawnBgPlan,
157
- lock: LockClaim | null,
158
- deps: SpawnBgResumeDeps,
159
- ): Promise<SpawnBgResumeResult> {
160
- // Throws on a null / mis-paired lock (?7 + gid invariants). After this the spawn-bg
161
- // policy is release-after-spawn-observation and the lock is non-null.
162
- const policy: ReleasePolicy = decideReleasePolicy(plan, lock);
163
- const held = lock as LockClaim;
164
-
165
- let state: ReleaseState = initialReleaseState();
166
- // The ONLY path to deps.releaseLock: fold a real observation event through the reducer
167
- // and release iff (and exactly once) the reducer says so. No direct-release hatch.
168
- const fire = (event: ReleaseEvent): void => {
169
- const r = reduceRelease(policy, state, event);
170
- state = r.state;
171
- if (r.shouldRelease) deps.releaseLock(held);
172
- };
173
-
174
- // Spawn. A throw here means NO child exists → spawn-start-failed releases (nothing to
175
- // watch; keeping the lock would pin the gid forever). No child means no lock-leak risk.
176
- let child: SpawnedChild;
177
- try {
178
- child = await deps.spawnChild(plan);
179
- } catch (err) {
180
- fire({ kind: "spawn-start-failed", error: errMsg(err) });
181
- return { kind: "spawn-start-failed", released: true, error: errMsg(err) };
182
- }
183
-
184
- const controller = new AbortController();
185
- const { signal } = controller;
186
- // Created ONCE and reused across the primary race and the kill-grace race; in the
187
- // timeout branch both are still pending (timeout won), so re-racing them is safe.
188
- //
189
- // S1 (Fable 2차): the creation is in its OWN try because a watch dep can throw
190
- // SYNCHRONOUSLY (a buggy dep that throws where it must RETURN a Promise — the same grade
191
- // the send hand's case-13 backstop covers). The child already exists, so this is a
192
- // post-spawn failure and the SAME rule applies: an observation-less release is forbidden.
193
- // Best-effort kill, defuse any watcher that DID start (so its later rejection is not
194
- // unhandled), tear down, and fail-closed to a retained diagnostic. We do NOT try to
195
- // observe the exit — awaitChildExit may itself be the thrower; retained is the honest
196
- // floor. (The async-rejection path is handled later by the race catch → backstop.)
197
- let socketP!: Promise<Observation>;
198
- let exitP!: Promise<Observation>;
199
- try {
200
- socketP = tagSocket(deps.awaitSocketAlive(plan.expectedSocketPath, signal));
201
- exitP = tagExit(deps.awaitChildExit(child, signal));
202
- } catch (err) {
203
- try {
204
- deps.killChild(child);
205
- } catch {
206
- // best-effort
207
- }
208
- controller.abort();
209
- defuse(socketP);
210
- defuse(exitP);
211
- return retained(plan, child, "observe-failed", deps.killGraceMs, held.lockPath, errMsg(err));
212
- }
213
-
214
- try {
215
- // Primary observation race. `timeout` participates but is NEVER a release event.
216
- let first: Observation;
217
- try {
218
- const timeoutP = tagTimeout(deps.awaitTimeout(plan.observeTimeoutMs, signal));
219
- first = await Promise.race([socketP, exitP, timeoutP]);
220
- } catch (err) {
221
- // A watch/timer dep rejected BEFORE any observation → post-spawn backstop.
222
- return await backstop(plan, deps, child, held.lockPath, exitP, signal, fire, errMsg(err));
223
- }
224
-
225
- if (first.tag === "socket") {
226
- fire({ kind: "socket-alive" });
227
- return { kind: "socket-alive", released: true, pid: child.pid };
228
- }
229
- if (first.tag === "exit") {
230
- fire({ kind: "child-exited", code: first.code });
231
- return { kind: "child-exited", released: true, exitCode: first.code, pid: child.pid };
232
- }
233
-
234
- // first.tag === "timeout": NOT a release. Escalate to a kill, then wait a BOUNDED
235
- // grace for the kill to produce a real child-exited (or a racing socket-alive).
236
- try {
237
- deps.killChild(child);
238
- } catch {
239
- // kill itself threw — still try to observe within the grace; if nothing, retain.
240
- }
241
- let second: Observation;
242
- try {
243
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
244
- second = await Promise.race([socketP, exitP, graceP]);
245
- } catch (err) {
246
- // A dep rejected during the grace → cannot confirm; retain the lock, surface it.
247
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, errMsg(err));
248
- }
249
- if (second.tag === "socket") {
250
- fire({ kind: "socket-alive" });
251
- return { kind: "socket-alive", released: true, pid: child.pid };
252
- }
253
- if (second.tag === "exit") {
254
- fire({ kind: "child-exited", code: second.code });
255
- return { kind: "child-exited", released: true, exitCode: second.code, pid: child.pid };
256
- }
257
- // Grace elapsed with NO observation → the kill is unconfirmed. Do NOT release blind;
258
- // retain the lock and surface the evidence (bounded return, never a hang).
259
- return retained(plan, child, "kill-unconfirmed", deps.killGraceMs, held.lockPath, undefined);
260
- } finally {
261
- // Tear down the losing watchers (their timers / FS watches) on every exit.
262
- controller.abort();
263
- }
264
- }
265
-
266
- /**
267
- * Post-spawn backstop (GPT 5c-3a correction): a child exists but a watch/timer dep threw
268
- * before any observation. An observation-less release is forbidden (it reopens the
269
- * double-spawn window), so: best-effort kill → BOUNDED attempt to OBSERVE the exit →
270
- * release iff observed, else `lock-retained` fail-closed. We race ONLY the exit (not the
271
- * already-suspect socket watcher) plus a fresh grace timer.
272
- */
273
- async function backstop(
274
- plan: SpawnBgPlan,
275
- deps: SpawnBgResumeDeps,
276
- child: SpawnedChild,
277
- lockPath: string,
278
- exitP: Promise<Observation>,
279
- signal: AbortSignal,
280
- fire: (event: ReleaseEvent) => void,
281
- originalError: string,
282
- ): Promise<SpawnBgResumeResult> {
283
- try {
284
- deps.killChild(child);
285
- } catch {
286
- // best-effort
287
- }
288
- try {
289
- const graceP = tagTimeout(deps.awaitTimeout(deps.killGraceMs, signal));
290
- const obs = await Promise.race([exitP, graceP]);
291
- if (obs.tag === "exit") {
292
- fire({ kind: "child-exited", code: obs.code });
293
- return { kind: "child-exited", released: true, exitCode: obs.code, pid: child.pid };
294
- }
295
- } catch {
296
- // exit watcher itself was the failing dep (or threw again) → fall through to retain.
297
- }
298
- return retained(plan, child, "observe-failed", deps.killGraceMs, lockPath, originalError);
299
- }
300
-
301
- function retained(
302
- plan: SpawnBgPlan,
303
- child: SpawnedChild,
304
- reason: "kill-unconfirmed" | "observe-failed",
305
- killGraceMs: number,
306
- lockPath: string,
307
- error: string | undefined,
308
- ): SpawnBgResumeResult {
309
- return {
310
- kind: "lock-retained",
311
- released: false,
312
- reason,
313
- error,
314
- diagnostic: {
315
- targetGardenId: plan.targetGardenId,
316
- pid: child.pid,
317
- expectedSocketPath: plan.expectedSocketPath,
318
- lockPath,
319
- observeTimeoutMs: plan.observeTimeoutMs,
320
- killGraceMs,
321
- },
322
- };
323
- }
@@ -1,275 +0,0 @@
1
- // Deterministic gate for the S2a ACP SDK dependency surface.
2
- //
3
- // Pins the ACP runtime deps to the current behavior-oracle versions and locks
4
- // the peer-resolution that makes the Claude ACP adapter satisfiable:
5
- //
6
- // @agentclientprotocol/sdk 1.3.0 wire SDK (acp-bridge import source)
7
- // @agentclientprotocol/claude-agent-acp 0.64.0 Claude adapter (spawn binary)
8
- // @anthropic-ai/sdk 0.100.1 peer-resolution pin ONLY (see below)
9
- //
10
- // 2026-07-31 bump 0.63.0 → 0.64.0 — a ONE-FEATURE adapter release. Measured
11
- // against the upstream checkout (release commit 9cc5a09): the entire functional
12
- // delta is `src/elicitation.ts` +14 lines (commit d7a65ce), which stamps the
13
- // AskUserQuestion form's "Other" free-text field with a shared
14
- // `_meta._askUserQuestionCustomAnswer` marker so ACP clients can recognize a
15
- // custom-answer companion across Codex/Claude bridges. Published package:
16
- // unpackedSize 529,638 → 530,740 B, fileCount 24 → 24. Runtime deps are
17
- // IDENTICAL (`@agentclientprotocol/sdk` 1.3.0, `@anthropic-ai/claude-agent-sdk`
18
- // 0.3.220) — the upstream lock's `@modelcontextprotocol/sdk` 1.29.0 → 1.30.0 is
19
- // THEIR dev tree; our resolution stays 1.29.0 because our runtime dep set did
20
- // not move. Re-measured: the anthropic peer still resolves 0.100.1.
21
- //
22
- // Reachability is STRONGER than "off by default", and it is measured, not
23
- // assumed. `backend.ts` sends `clientCapabilities: {}` — a hardcoded literal with
24
- // no operator-config seam — so upstream computes `elicitationSupport.form =
25
- // false`. Two independent upstream gates then close the new marker off:
26
- // `acp-agent.ts:5358` disallows `AskUserQuestion` outright, and `:5449` composes
27
- // the final list by CONCATENATION (`[...userProvidedOptions.disallowedTools,
28
- // ...disallowedTools]`), so no operator `disallowedTools` can remove it; and the
29
- // form-elicitation emission branch at `:4558` is gated on the same flag. The only
30
- // lever is `clientCapabilities`, which entwurf does not expose to configuration —
31
- // so this marker cannot reach our wire without an entwurf SOURCE change. That is
32
- // the honest claim for THIS delta; do not generalize it to other axes.
33
- //
34
- // HISTORY — 2026-07-30 bump 0.62.0 → 0.63.0 — an ADAPTER-CODE release, not a dependency
35
- // refresh. Measured on the packed tarballs: 137,294 → 142,084 bytes, with
36
- // `dist/acp-agent.js`, `dist/tools.js` and the `dist/acp-agent.d.ts` surface all
37
- // moved. Three upstream fixes ride it (#923 denied-tool resolution, #916
38
- // tool_progress heartbeat keying, #917 Bash terminal metas keyed off the
39
- // announced tool_use id) plus claude-agent-sdk 0.3.219 → 0.3.220.
40
- //
41
- // Reachability onto OUR surface is narrower than that list, and is measured, not
42
- // assumed: `backend.ts` sends `clientCapabilities: {}`, and the adapter gates
43
- // terminal metas on `clientCapabilities?._meta?.["terminal_output"] === true`
44
- // and subagent transcript on the matching capability — so both stay off for
45
- // every backend we ship. What CAN still reach the wire is opt-in-free metadata
46
- // (`_meta.claudeCode.title` / `.subagent`) and #916's re-keyed heartbeats; the
47
- // mapper ignores those, so no visible state changes. Do not write this up as
48
- // "0.63.0 does not reach us" — write what each axis was measured to do.
49
- //
50
- // HISTORY — 2026-07-27 bump 0.61.0 → 0.62.0: that one WAS a pure dependency
51
- // refresh; the adapter's `dist/` was byte-identical across the two releases and
52
- // only its declared deps moved (claude-agent-sdk 0.3.217 → 0.3.219). It was
53
- // specifically NOT a fix for the bundled-MCP readiness race (ROADMAP 🔴), and
54
- // 0.63.0 adds no explicit readiness fence either. Effective runtime behavior is
55
- // NOT claimed identical across either bump: the transitive SDK moved both times,
56
- // and MCP startup lives inside it, so timing may differ — what is established is
57
- // only that no new fence was added.
58
- //
59
- // The anthropic SDK is NOT an API client / auth surface here. It is a direct
60
- // dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.220's peer floor
61
- // (>=0.93.0 — re-measured at 0.3.220, unchanged from 0.3.219 and 0.3.217, so the
62
- // 0.100.1 pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
63
- // unmet — a failure that would only surface at the first raw turn. The
64
- // lockfile proves the same shape. Source-level import / API-client
65
- // instantiation / credential use stays forbidden — asserted in layer (4).
66
- // (GPT hard constraint 2, revised 2026-06-18: direct dep allowed ONLY as an
67
- // exact peer-resolution pin; source-level use remains forbidden.)
68
- //
69
- // Layers:
70
- // (1) package.json exact pins for all three deps;
71
- // (2) pnpm-lock peer-resolution lock (adapter + claude-agent-sdk → 0.100.1);
72
- // (2b) runtime peer-resolution probe (claude-agent-sdk context → 0.100.1);
73
- // (3) @agentclientprotocol/sdk value-export surface (silent-rename gate);
74
- // (4) no tracked source imports the anthropic SDK or builds an API client.
75
-
76
- import { strict as assert } from "node:assert";
77
- import { execFileSync } from "node:child_process";
78
- import { existsSync, readFileSync } from "node:fs";
79
- import { createRequire } from "node:module";
80
- import { dirname, resolve } from "node:path";
81
-
82
- // Split literals so this gate's own regexes never self-match in layer (4).
83
- const ANTHROPIC_SDK = `@anthropic-ai/${"sdk"}`;
84
- const API_CLIENT_CLASS = `${"Anthropic"}`;
85
-
86
- const repoRoot = resolve(import.meta.dirname, "..");
87
- const read = (p: string): string => readFileSync(resolve(repoRoot, p), "utf8");
88
-
89
- // ---------------------------------------------------------------------------
90
- // (1) package.json — exact pins (no caret/range; S2a freezes the oracle set)
91
- // ---------------------------------------------------------------------------
92
- const pkg = JSON.parse(read("package.json")) as { dependencies?: Record<string, string> };
93
- const deps = pkg.dependencies ?? {};
94
- const PINS: Record<string, string> = {
95
- "@agentclientprotocol/sdk": "1.3.0",
96
- "@agentclientprotocol/claude-agent-acp": "0.64.0",
97
- [ANTHROPIC_SDK]: "0.100.1",
98
- };
99
- for (const [name, ver] of Object.entries(PINS)) {
100
- assert.equal(
101
- deps[name],
102
- ver,
103
- `package.json dependencies["${name}"] must be exact "${ver}" (got "${deps[name]}") — S2a pins the current oracle versions`,
104
- );
105
- }
106
-
107
- // ---------------------------------------------------------------------------
108
- // (2) pnpm-lock — peer-resolution lock
109
- // The adapter and claude-agent-sdk MUST peer-resolve the anthropic SDK to
110
- // 0.100.1; a 0.91.1 resolution means the >=0.93.0 peer is unmet (the bug
111
- // this whole pin exists to prevent).
112
- // ---------------------------------------------------------------------------
113
- const lock = read("pnpm-lock.yaml");
114
- assert.match(
115
- lock,
116
- /@agentclientprotocol\/claude-agent-acp@0\.64\.0\(@anthropic-ai\/sdk@0\.100\.1/,
117
- "pnpm-lock: claude-agent-acp@0.64.0 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
118
- );
119
- assert.match(
120
- lock,
121
- /@anthropic-ai\/claude-agent-sdk@0\.3\.220\(@anthropic-ai\/sdk@0\.100\.1/,
122
- "pnpm-lock: claude-agent-sdk@0.3.220 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
123
- );
124
-
125
- // ---------------------------------------------------------------------------
126
- // (2b) runtime peer-resolution probe — the actual Node resolver, not lock text.
127
- // Layer (2) freezes the publish/install floor; this probes that the
128
- // claude-agent-sdk → @anthropic-ai/sdk peer edge really resolves to
129
- // 0.100.1 in a live module graph. Two different failures, both needed.
130
- // Note: a top-level/adapter-context resolve of the anthropic SDK may see
131
- // 0.91.1 (pi's own transitive) — that is normal. The edge that must be
132
- // 0.100.1 is the one *inside* claude-agent-sdk's context.
133
- // ---------------------------------------------------------------------------
134
- const pkgInfoFromEntry = (entryPath: string): { name: string; version: string; dir: string } => {
135
- // A `<pkg>/package.json` subpath resolve can fail under "exports"; walk up
136
- // from the resolved entry to the nearest package.json instead.
137
- let dir = dirname(entryPath);
138
- for (;;) {
139
- try {
140
- const pj = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as {
141
- name?: string;
142
- version?: string;
143
- };
144
- if (pj.name && pj.version) return { name: pj.name, version: pj.version, dir };
145
- } catch {
146
- // no package.json here (or unreadable) — keep walking up.
147
- }
148
- const parent = dirname(dir);
149
- if (parent === dir) throw new Error(`no package.json found walking up from ${entryPath}`);
150
- dir = parent;
151
- }
152
- };
153
-
154
- const rootRequire = createRequire(resolve(repoRoot, "package.json"));
155
- const adapterPkgJson = rootRequire.resolve("@agentclientprotocol/claude-agent-acp/package.json");
156
- const adapterRequire = createRequire(adapterPkgJson);
157
- const casEntry = adapterRequire.resolve("@anthropic-ai/claude-agent-sdk");
158
- const casInfo = pkgInfoFromEntry(casEntry);
159
- assert.equal(
160
- casInfo.version,
161
- "0.3.220",
162
- `@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.220 from the adapter context (got ${casInfo.version})`,
163
- );
164
- const casRequire = createRequire(resolve(casInfo.dir, "package.json"));
165
- const sdkEntry = casRequire.resolve(ANTHROPIC_SDK);
166
- const sdkInfo = pkgInfoFromEntry(sdkEntry);
167
- assert.equal(
168
- sdkInfo.version,
169
- "0.100.1",
170
- `${ANTHROPIC_SDK} must peer-resolve to 0.100.1 from the claude-agent-sdk context (got ${sdkInfo.version}) — a 0.91.1 here means the >=0.93.0 peer is unmet and the raw turn would break`,
171
- );
172
-
173
- // ---------------------------------------------------------------------------
174
- // (2c) adapter-context wire-SDK + MCP-SDK runtime resolve — the ACP dep bump's
175
- // real fault surface. Lock text (layer 2) freezes the publish floor; these probe
176
- // the LIVE module graph the adapter actually traverses: the adapter must SEE
177
- // the same wire SDK the backend imports at root (1.3.0), and claude-agent-sdk
178
- // must SEE its declared MCP peer (1.29.x). Cheap edges, both newly relevant
179
- // after the 0.54→0.61 / 1.1→1.3 bump.
180
- // ---------------------------------------------------------------------------
181
- const wireEntry = adapterRequire.resolve("@agentclientprotocol/sdk");
182
- const wireInfo = pkgInfoFromEntry(wireEntry);
183
- assert.equal(
184
- wireInfo.version,
185
- "1.3.0",
186
- `@agentclientprotocol/sdk must runtime-resolve to 1.3.0 from the adapter context (got ${wireInfo.version}) — the adapter and the backend must share one wire SDK`,
187
- );
188
- // @modelcontextprotocol/sdk gates its bare specifier behind "exports", so a
189
- // require.resolve of the package ROOT throws (no resolvable entry) — resolve a
190
- // real SUBPATH instead and walk up to its package.json.
191
- //
192
- // This used to read the repo's own hoisted copy and justify it with "pnpm
193
- // hoists ONE mcp instance". That is true of today's lockfile, which is exactly
194
- // why it asserted nothing: the read never traversed the edge it claimed to
195
- // verify, so a nested @modelcontextprotocol/sdk under claude-agent-sdk would
196
- // leave this gate GREEN while the adapter loaded a peer no gate had seen. A
197
- // probe whose subject is "whatever the root happens to hoist" is a coincidence,
198
- // not a check. Resolve FROM the claude-agent-sdk context so the assertion binds
199
- // the real adapter → claude-agent-sdk → MCP edge, the same way the anthropic
200
- // SDK peer above is bound.
201
- const mcpEntry = casRequire.resolve("@modelcontextprotocol/sdk/server/index.js");
202
- const mcpInfo = pkgInfoFromEntry(mcpEntry);
203
- assert.ok(
204
- mcpInfo.version.startsWith("1.29."),
205
- `@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.220 declares a ^1.29.0 peer`,
206
- );
207
-
208
- // ---------------------------------------------------------------------------
209
- // (3) @agentclientprotocol/sdk value-export surface (silent-rename gate)
210
- // The ACP bridge imports these from the wire SDK; a silent upstream
211
- // rename would not fail typecheck (type-only erasure) but would break the
212
- // raw turn. Assert the *value* exports exist at runtime.
213
- // ---------------------------------------------------------------------------
214
- // These are the value imports the real ACP code uses: the connectAcpClient
215
- // adapter (acp-client.ts) drives `client` + the `AGENT_METHODS`/`CLIENT_METHODS`
216
- // method tables; the backend + raw-turn smoke value-import `ndJsonStream` (the
217
- // stdio transport) and `PROTOCOL_VERSION`. The deprecated `ClientSideConnection`
218
- // is no longer used, so it is no longer gated. (Per GPT: gate only the value
219
- // imports the real code uses — type-only imports are erased.)
220
- const acpSdk = (await import("@agentclientprotocol/sdk")) as Record<string, unknown>;
221
- for (const sym of ["client", "ndJsonStream", "PROTOCOL_VERSION", "AGENT_METHODS", "CLIENT_METHODS"]) {
222
- assert.ok(
223
- sym in acpSdk,
224
- `@agentclientprotocol/sdk lost value export "${sym}" — silent upstream rename; the raw ACP turn would break`,
225
- );
226
- }
227
-
228
- // ---------------------------------------------------------------------------
229
- // (4) no source-level anthropic SDK import / API client
230
- // peer-pin only. The credential boundary (AGENTS §Operating boundaries)
231
- // forbids the bridge from importing the SDK or instantiating an API client.
232
- // Line-based: only real `import ... from` / `require(...)` statements count,
233
- // so this gate's own split-literal regexes above are never offenders.
234
- // ---------------------------------------------------------------------------
235
- const tracked = execFileSync("git", ["ls-files", "*.ts", "*.js", "*.mjs", "*.cjs"], {
236
- cwd: repoRoot,
237
- encoding: "utf8",
238
- })
239
- .split("\n")
240
- .filter(Boolean)
241
- // `git ls-files` still names an UNSTAGED deletion (same contract as
242
- // check-install-surface / the run.sh floor sweeps): a release-surface
243
- // migration deletes tracked files before the commit workflow stages them;
244
- // absence cannot import the SDK and must not crash this read-only sweep.
245
- .filter((f) => existsSync(resolve(repoRoot, f)));
246
-
247
- // Specifier-shaped: any module binding to the anthropic SDK, in any of the
248
- // forms a source file could reach it — static `from`, `export ... from`,
249
- // side-effect `import "X"`, dynamic `import("X")`, and `require("X")`.
250
- const spec = `["']${ANTHROPIC_SDK.replace("/", "\\/")}["']`;
251
- const specifierPatterns: ReadonlyArray<{ re: RegExp; kind: string }> = [
252
- { re: new RegExp(String.raw`\bfrom\s+${spec}`), kind: `import/export from ${ANTHROPIC_SDK}` },
253
- { re: new RegExp(String.raw`^\s*import\s+${spec}`), kind: `side-effect import ${ANTHROPIC_SDK}` },
254
- { re: new RegExp(String.raw`\bimport\(\s*${spec}\s*\)`), kind: `dynamic import(${ANTHROPIC_SDK})` },
255
- { re: new RegExp(String.raw`\brequire\(\s*${spec}\s*\)`), kind: `require(${ANTHROPIC_SDK})` },
256
- ];
257
- const clientRe = new RegExp(String.raw`\bnew\s+${API_CLIENT_CLASS}\s*\(`);
258
-
259
- const offenders: string[] = [];
260
- for (const f of tracked) {
261
- const src = read(f);
262
- for (const line of src.split("\n")) {
263
- for (const { re, kind } of specifierPatterns) {
264
- if (re.test(line)) offenders.push(`${f}: ${kind}`);
265
- }
266
- if (clientRe.test(line)) offenders.push(`${f}: new ${API_CLIENT_CLASS}() API client`);
267
- }
268
- }
269
- assert.equal(
270
- offenders.length,
271
- 0,
272
- `${ANTHROPIC_SDK} is a peer-resolution pin ONLY — no source import / API client allowed:\n ${offenders.join("\n ")}`,
273
- );
274
-
275
- console.log("[check-acp-sdk-surface] ACP dep pins + peer-resolution lock + wire-SDK export surface + no-client-use ok");