@junghanacs/entwurf 0.14.0 → 0.14.2
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/AGENTS.md +13 -2
- package/CHANGELOG.md +63 -0
- package/DELIVERY.md +57 -0
- package/README.md +16 -7
- package/VERIFY.md +4 -4
- package/demo/README.md +3 -1
- package/demo/demo-baseline.sh +12 -1
- package/demo/demo.sh +9 -1
- package/docs/acp-backend-rail.md +103 -4
- package/docs/external-mcp-host.md +1 -1
- package/docs/setup-clean-host.md +3 -3
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +12 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/classify-tmux-cwd.js +47 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +45 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +18 -47
- package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
- package/mcp/entwurf-bridge/src/index.ts +14 -5
- package/mcp/entwurf-bridge/tsconfig.build.json +15 -5
- package/package.json +9 -9
- package/pi-extensions/entwurf-control.ts +13 -4
- package/pi-extensions/lib/acp/backend.ts +229 -9
- package/pi-extensions/lib/classify-tmux-cwd.ts +50 -0
- package/pi-extensions/lib/mux-fresh-call.ts +57 -4
- package/pi-extensions/lib/mux-resume-call.ts +21 -53
- package/run.sh +70 -25
- package/scripts/agy-bridge-config.py +47 -13
- package/scripts/agy-bridge.sh +73 -23
- package/scripts/check-acp-prompt-lifecycle.ts +221 -9
- package/scripts/check-entwurf-bridge-boot.ts +28 -0
- package/scripts/check-gate-qualification.ts +5 -3
- package/scripts/check-mux-resume-call.ts +11 -10
- package/scripts/check-probe-bridge-command.ts +201 -0
- package/scripts/check-release-gate-outcomes.ts +54 -1
- package/scripts/doctor-pi-provider.ts +155 -51
- package/scripts/meta-bridge-state.py +75 -1
- package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
- package/scripts/mutants/bridge-command-boot.json +107 -0
- package/scripts/mutants/meta-retire.json +47 -0
- package/scripts/mutants/mux-fresh-call.json +48 -4
- package/scripts/mutants/mux-resume-call.json +3 -3
- package/scripts/mutants/release-gate.json +13 -0
- package/scripts/probe-bridge-command.ts +330 -0
- package/scripts/raw-async-delivery/README.md +158 -1
- package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-agy-install-state.sh +76 -2
- package/scripts/smoke-entwurf-chain-live.ts +12 -4
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-meta-install-state.sh +169 -3
- package/scripts/smoke-mux-fresh-call-live.ts +1 -1
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/smoke-pi-provider-state.sh +135 -6
- package/scripts/smoke-resident-garden-guard.sh +2 -2
|
@@ -161,6 +161,35 @@ interface AcpBridgeEvent {
|
|
|
161
161
|
decision?: "approved" | "cancelled";
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A one-shot latch for "the child has ended", created at spawn and settled by
|
|
166
|
+
* `onChildGone`. `settled` answers the question with no waiting at all; the
|
|
167
|
+
* promise is only for the bounded post-mortem window (settleChildEnd).
|
|
168
|
+
*/
|
|
169
|
+
interface ChildEndLatch {
|
|
170
|
+
settled: boolean;
|
|
171
|
+
promise: Promise<void>;
|
|
172
|
+
/** Called exactly once by onChildGone; cleared so a second end is a no-op. */
|
|
173
|
+
settle?: () => void;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function makeChildEndLatch(): ChildEndLatch {
|
|
177
|
+
let settle!: () => void;
|
|
178
|
+
const promise = new Promise<void>((resolve) => {
|
|
179
|
+
settle = resolve;
|
|
180
|
+
});
|
|
181
|
+
const latch: ChildEndLatch = {
|
|
182
|
+
settled: false,
|
|
183
|
+
promise,
|
|
184
|
+
settle: () => {
|
|
185
|
+
latch.settled = true;
|
|
186
|
+
latch.settle = undefined;
|
|
187
|
+
settle();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
return latch;
|
|
191
|
+
}
|
|
192
|
+
|
|
164
193
|
interface BridgeSession {
|
|
165
194
|
key: string;
|
|
166
195
|
cwd: string;
|
|
@@ -183,8 +212,36 @@ interface BridgeSession {
|
|
|
183
212
|
stderrTail: string[];
|
|
184
213
|
/** How the child ended, once it has — folded into the prompt-phase error. */
|
|
185
214
|
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
215
|
+
/**
|
|
216
|
+
* LATCH for the child's end, readable at ANY time — deliberately not a
|
|
217
|
+
* callback.
|
|
218
|
+
*
|
|
219
|
+
* `notifyChildGone` lives only between the two lines of
|
|
220
|
+
* `awaitAcpPromptTurn`'s try/finally, so it can carry the exit status only
|
|
221
|
+
* when the child's `exit` event wins the race against the transport — and on
|
|
222
|
+
* the shape this backend actually runs, it does not. With piped stdio on
|
|
223
|
+
* Linux the child's stdout EOF was measured landing about a millisecond BEFORE
|
|
224
|
+
* node emits `exit`, for a clean exit and for SIGKILL alike (issue #72). The
|
|
225
|
+
* SDK's generic "ACP connection closed" therefore settles the race first,
|
|
226
|
+
* `finally` clears the callback, and the exit status arriving one tick later
|
|
227
|
+
* has nowhere to go — which is how #72's field sample reached the operator
|
|
228
|
+
* naming neither exit code nor signal. The opposite order stays possible and
|
|
229
|
+
* is still handled (notifyChildGone), so both are covered by the gate.
|
|
230
|
+
*
|
|
231
|
+
* The latch outlives the race: `settled` is the durable fact and `promise`
|
|
232
|
+
* lets a failing turn wait a BOUNDED moment for a late end (settleChildEnd).
|
|
233
|
+
*/
|
|
234
|
+
childEnd: ChildEndLatch;
|
|
186
235
|
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
187
236
|
notifyChildGone?: (err: Error) => void;
|
|
237
|
+
/**
|
|
238
|
+
* This TURN owns reporting the child's end, so the next turn must not also
|
|
239
|
+
* announce it. Raised at the top of a failure path — BEFORE the bounded
|
|
240
|
+
* settle and before any teardown — so an end observed during that window is
|
|
241
|
+
* still a natural one (we have not signalled anything yet) but is reported
|
|
242
|
+
* exactly once, by the turn that failed on it.
|
|
243
|
+
*/
|
|
244
|
+
reporting?: boolean;
|
|
188
245
|
/**
|
|
189
246
|
* We are tearing this child down ON PURPOSE (turn-scoped teardown, config
|
|
190
247
|
* drift, error/abort cleanup). Its exit is then expected, not news: without
|
|
@@ -229,10 +286,13 @@ function onChildGone(session: BridgeSession, exit?: { code: number | null; signa
|
|
|
229
286
|
if (exit) session.exit = exit;
|
|
230
287
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
231
288
|
retainedChildren.delete(session.child);
|
|
289
|
+
// Close the latch FIRST and unconditionally: it is the durable fact, and a
|
|
290
|
+
// failing turn may already be waiting on it inside its bounded settle window.
|
|
291
|
+
session.childEnd.settle?.();
|
|
232
292
|
if (session.notifyChildGone) {
|
|
233
293
|
// A turn was waiting on this child — it reports the death itself.
|
|
234
294
|
session.notifyChildGone(childEndedError(session));
|
|
235
|
-
} else if (!session.retiring) {
|
|
295
|
+
} else if (!session.retiring && !session.reporting) {
|
|
236
296
|
// Died BETWEEN turns with no turn to fail and nobody tearing it down, so
|
|
237
297
|
// nobody has seen it. Without this the next turn would silently open a
|
|
238
298
|
// fresh child and read as an ordinary cold start, hiding that the backend
|
|
@@ -281,6 +341,139 @@ function childEndedError(session: BridgeSession): Error {
|
|
|
281
341
|
);
|
|
282
342
|
}
|
|
283
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The BOUNDED post-mortem window a turn that ALREADY failed may wait for the
|
|
346
|
+
* child's exit status.
|
|
347
|
+
*
|
|
348
|
+
* This is NOT a prompt deadline and must never become one: nothing here can end
|
|
349
|
+
* a running turn. It opens only after the prompt has already settled as a
|
|
350
|
+
* transport closure, and it closes on the child's own `exit` — which follows the
|
|
351
|
+
* stdout EOF by about a millisecond, so this is headroom, not a felt wait.
|
|
352
|
+
*/
|
|
353
|
+
const CHILD_END_SETTLE_MS = 500;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The SDK's exact words for "the transport ended under a pending request"
|
|
357
|
+
* (`@agentclientprotocol/sdk` jsonrpc.js / acp.js:
|
|
358
|
+
* `closeSignal.reason ?? new Error("ACP connection closed")`).
|
|
359
|
+
*
|
|
360
|
+
* Matched EXACTLY, not by substring: this text appears only when the close
|
|
361
|
+
* carried NO reason — a clean stdout EOF rather than an errored stream — which
|
|
362
|
+
* is the one failure shape that arrives with no lifecycle facts of its own. A
|
|
363
|
+
* close that DID carry a reason already explains itself and must not be given a
|
|
364
|
+
* settle delay, and our own `childEndedError` already names the exit status.
|
|
365
|
+
* Widening this to a substring test would put that delay on errors that do not
|
|
366
|
+
* need it.
|
|
367
|
+
*/
|
|
368
|
+
const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
|
|
369
|
+
|
|
370
|
+
function isAcpConnectionClosure(err: unknown): boolean {
|
|
371
|
+
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
372
|
+
return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Wait a BOUNDED moment for a child end that the transport already implied.
|
|
377
|
+
*
|
|
378
|
+
* Resolves immediately when the latch is already closed (the common case once
|
|
379
|
+
* the ~1ms gap has passed) and never rejects. The timer is deliberately NOT
|
|
380
|
+
* unref'd: it is the only thing that ends this wait, so letting the loop drain
|
|
381
|
+
* past it would leave a failing turn unsealed — the opposite of the honesty this
|
|
382
|
+
* exists for. It is cleared on both exits, so it holds nothing open.
|
|
383
|
+
*/
|
|
384
|
+
async function settleChildEnd(latch: ChildEndLatch, ms: number): Promise<void> {
|
|
385
|
+
if (latch.settled) return;
|
|
386
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
+
try {
|
|
388
|
+
await Promise.race([
|
|
389
|
+
latch.promise,
|
|
390
|
+
new Promise<void>((resolve) => {
|
|
391
|
+
timer = setTimeout(resolve, ms);
|
|
392
|
+
}),
|
|
393
|
+
]);
|
|
394
|
+
} finally {
|
|
395
|
+
if (timer) clearTimeout(timer);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The lifecycle line appended to a transport-closure failure, so the operator
|
|
401
|
+
* reads WHICH phase died and HOW the child ended.
|
|
402
|
+
*
|
|
403
|
+
* Wording is load-bearing for the same reason `childEndedError`'s is: pi
|
|
404
|
+
* classifies a failed assistant message against `RETRYABLE_PROVIDER_ERROR_PATTERN`
|
|
405
|
+
* (@earendil-works/pi-ai `utils/retry`), whose terms include "timed out",
|
|
406
|
+
* "timeout", "terminated", "connection lost" and "ended without". None of those
|
|
407
|
+
* may appear here, or a dead child would put a full cold prompt replay back on
|
|
408
|
+
* the table — with the tool side effects this turn already produced.
|
|
409
|
+
*
|
|
410
|
+
* That pattern is a bare substring alternation (`new RegExp(terms.join("|"), "i")`
|
|
411
|
+
* — NO word boundaries) and its terms enumerate the HTTP statuses "429", "500",
|
|
412
|
+
* "502", "503", "504" and "524". So the settle bound is deliberately NOT interpolated: rendering
|
|
413
|
+
* CHILD_END_SETTLE_MS made this line say "within 500ms", which pi read as an
|
|
414
|
+
* HTTP 500 and classified as transient. Naming the window in prose keeps a later
|
|
415
|
+
* change of that constant from silently re-arming the replay — do not "improve"
|
|
416
|
+
* this by putting the number back; the bound belongs in the code and the gate.
|
|
417
|
+
*
|
|
418
|
+
* The three endings are kept DISTINCT on purpose (issue #72 Done-when): an
|
|
419
|
+
* exit code, a signal, and "we waited and it never said" are three different
|
|
420
|
+
* facts, and collapsing them is what made the original sample unreadable.
|
|
421
|
+
*/
|
|
422
|
+
function childEndLifecycleLine(opts: {
|
|
423
|
+
/** "pre-prompt" covers a new turn's bootstrap AND a reuse turn's pre-send steps. */
|
|
424
|
+
phase: "pre-prompt" | "prompt";
|
|
425
|
+
exit?: { code: number | null; signal: NodeJS.Signals | null };
|
|
426
|
+
ended: boolean;
|
|
427
|
+
}): string {
|
|
428
|
+
const where =
|
|
429
|
+
opts.phase === "prompt"
|
|
430
|
+
? "the ACP backend connection closed while the prompt was still in flight"
|
|
431
|
+
: "the ACP backend connection closed before this turn's prompt was sent";
|
|
432
|
+
const how = opts.ended
|
|
433
|
+
? `the child ended (${describeChildEnd(opts.exit)})`
|
|
434
|
+
: "the child reported no exit status within the bounded post-mortem window";
|
|
435
|
+
return `[acp] lifecycle: ${where} — ${how}; this turn has no answer`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The failure path's post-mortem: enrich ONLY the transport-closure shape, and
|
|
440
|
+
* only after waiting a bounded moment for the exit status the close implies.
|
|
441
|
+
*
|
|
442
|
+
* Returns `undefined` for every other failure — an abort, a bootstrap throw, or
|
|
443
|
+
* a child death our own `notifyChildGone` already diagnosed — so no other error
|
|
444
|
+
* pays a delay and none is double-reported.
|
|
445
|
+
*
|
|
446
|
+
* Callers MUST run this BEFORE tearing the child down: our teardown SIGTERMs the
|
|
447
|
+
* process group, so an exit read after it would be OUR signal recorded as the
|
|
448
|
+
* child's cause of death.
|
|
449
|
+
*
|
|
450
|
+
* The stderr claim is deliberately NARROW. Running before teardown means the tail
|
|
451
|
+
* already collected is not cut short by our own cleanup, and stderr arriving
|
|
452
|
+
* during this window still lands in it. It does NOT promise the child's last
|
|
453
|
+
* words: node's `exit` can precede the stdio drain, and nothing here waits for
|
|
454
|
+
* the stderr pipe to close. Draining it is a separate lever, not this one.
|
|
455
|
+
*/
|
|
456
|
+
async function diagnoseTransportClosure(opts: {
|
|
457
|
+
err: unknown;
|
|
458
|
+
session: BridgeSession | undefined;
|
|
459
|
+
phase: "pre-prompt" | "prompt";
|
|
460
|
+
aborted: boolean;
|
|
461
|
+
}): Promise<string | undefined> {
|
|
462
|
+
if (opts.aborted || !opts.session) return undefined;
|
|
463
|
+
if (!isAcpConnectionClosure(opts.err)) return undefined;
|
|
464
|
+
const session = opts.session;
|
|
465
|
+
// This turn owns the announcement from here on, so the next turn must not
|
|
466
|
+
// repeat it. Raised BEFORE the wait and before any signal of ours, so an end
|
|
467
|
+
// observed inside the window is still a natural one.
|
|
468
|
+
session.reporting = true;
|
|
469
|
+
await settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);
|
|
470
|
+
return childEndLifecycleLine({
|
|
471
|
+
phase: opts.phase,
|
|
472
|
+
exit: session.exit,
|
|
473
|
+
ended: session.childEnd.settled,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
284
477
|
// ---------------------------------------------------------------------------
|
|
285
478
|
// timeout / launch / permission / stopReason / teardown helpers
|
|
286
479
|
// ---------------------------------------------------------------------------
|
|
@@ -728,12 +921,16 @@ export function streamAcpTurn(
|
|
|
728
921
|
stream.end();
|
|
729
922
|
}
|
|
730
923
|
|
|
731
|
-
function finishError(err: unknown, aborted: boolean, stderrTail?: string[]): void {
|
|
924
|
+
function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
|
|
732
925
|
finalizeAcpStreamState(state);
|
|
733
926
|
state.output.stopReason = aborted ? "aborted" : "error";
|
|
734
927
|
const base = err instanceof Error ? err.message : String(err);
|
|
928
|
+
// The FIRST failure stays first and verbatim (it is what the backend
|
|
929
|
+
// actually said); the lifecycle line is added, never substituted, so a
|
|
930
|
+
// reader can still match the transport's own text.
|
|
931
|
+
const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
|
|
735
932
|
const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
|
|
736
|
-
const full = tail ? `${
|
|
933
|
+
const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
|
|
737
934
|
// A-c: a real failure (not an abort) that looks like a context-window
|
|
738
935
|
// overflow gets an actionable hint appended, so "API Error" stops hiding
|
|
739
936
|
// the turn-scoped full-transcript-replay cause.
|
|
@@ -924,6 +1121,8 @@ export function streamAcpTurn(
|
|
|
924
1121
|
let child: AcpChildLike | undefined;
|
|
925
1122
|
let session: BridgeSession | undefined;
|
|
926
1123
|
let onAbort: (() => void) | undefined;
|
|
1124
|
+
/** Which phase a failure belongs to — flipped once the prompt is on the wire. */
|
|
1125
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
927
1126
|
const stderrTail: string[] = [];
|
|
928
1127
|
const sessionKey = resolveSessionKey(opts, cwd);
|
|
929
1128
|
try {
|
|
@@ -1012,6 +1211,9 @@ export function streamAcpTurn(
|
|
|
1012
1211
|
// this turn with the session, so a later reuse turn can still report
|
|
1013
1212
|
// the child's dying words.
|
|
1014
1213
|
stderrTail,
|
|
1214
|
+
// Armed at spawn, before ANY turn can fail on this child — the latch
|
|
1215
|
+
// must already exist when the `exit` listener below can fire.
|
|
1216
|
+
childEnd: makeChildEndLatch(),
|
|
1015
1217
|
};
|
|
1016
1218
|
const sess = session;
|
|
1017
1219
|
spawned.once("exit", (...args: unknown[]) =>
|
|
@@ -1102,6 +1304,9 @@ export function streamAcpTurn(
|
|
|
1102
1304
|
signal.removeEventListener("abort", onAbort);
|
|
1103
1305
|
onAbort = undefined;
|
|
1104
1306
|
}
|
|
1307
|
+
// From here the prompt is on the wire: a transport closure now is a
|
|
1308
|
+
// PROMPT-phase failure, and the catch says so.
|
|
1309
|
+
phase = "prompt";
|
|
1105
1310
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1106
1311
|
signal,
|
|
1107
1312
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1134,6 +1339,13 @@ export function streamAcpTurn(
|
|
|
1134
1339
|
session.busy = false;
|
|
1135
1340
|
if (bridgeSessions.get(sessionKey) === session) bridgeSessions.delete(sessionKey);
|
|
1136
1341
|
}
|
|
1342
|
+
// ORDER IS THE CONTRACT (#72): natural settle → seal → teardown.
|
|
1343
|
+
// The bounded wait for the child's own exit status runs BEFORE we signal
|
|
1344
|
+
// anything, so what we report is how the child actually ended and not our
|
|
1345
|
+
// own SIGTERM; the tail collected by then is sealed before our cleanup
|
|
1346
|
+
// touches the stderr pipe.
|
|
1347
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1348
|
+
finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
|
|
1137
1349
|
// error/abort → drop the (uncertain) session and close its child; an
|
|
1138
1350
|
// uncertain connection must never be reused (GPT ④).
|
|
1139
1351
|
if (child) {
|
|
@@ -1142,7 +1354,6 @@ export function streamAcpTurn(
|
|
|
1142
1354
|
session?.connection.close?.(err);
|
|
1143
1355
|
teardownChild(child);
|
|
1144
1356
|
}
|
|
1145
|
-
finishError(err, aborted, session?.stderrTail ?? stderrTail);
|
|
1146
1357
|
} finally {
|
|
1147
1358
|
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
1148
1359
|
}
|
|
@@ -1150,6 +1361,8 @@ export function streamAcpTurn(
|
|
|
1150
1361
|
|
|
1151
1362
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1152
1363
|
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
1364
|
+
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1365
|
+
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1153
1366
|
try {
|
|
1154
1367
|
if (signal?.aborted) throw new Error("aborted before prompt");
|
|
1155
1368
|
|
|
@@ -1171,6 +1384,7 @@ export function streamAcpTurn(
|
|
|
1171
1384
|
// ahead of the wire write; the prompt driver then owns the abort surface.
|
|
1172
1385
|
const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
|
|
1173
1386
|
if (signal?.aborted) throw new Error("aborted during payload hook");
|
|
1387
|
+
phase = "prompt";
|
|
1174
1388
|
const promptResult = await awaitAcpPromptTurn(session, wireParams, {
|
|
1175
1389
|
signal,
|
|
1176
1390
|
graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
|
|
@@ -1188,16 +1402,22 @@ export function streamAcpTurn(
|
|
|
1188
1402
|
const aborted = Boolean(signal?.aborted);
|
|
1189
1403
|
session.activePromptHandler = undefined;
|
|
1190
1404
|
session.busy = false;
|
|
1191
|
-
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1192
1405
|
if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
|
|
1406
|
+
// The SAME diagnostics a new turn reports, in the SAME order (#72):
|
|
1407
|
+
// natural settle → seal → teardown. This is the path the field sample
|
|
1408
|
+
// took — a retained child that died after its tool phase — so the
|
|
1409
|
+
// ordering matters most here: teardown first would have overwritten the
|
|
1410
|
+
// child's own exit status with our SIGTERM.
|
|
1411
|
+
const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
|
|
1412
|
+
// Without the session-scoped tail a mid-turn child death on a resident
|
|
1413
|
+
// session surfaced as a bare "ACP connection closed" with nothing to read
|
|
1414
|
+
// it by.
|
|
1415
|
+
finishError(err, aborted, session.stderrTail, lifecycle);
|
|
1416
|
+
// error/abort on a reused session → drop it and close the child (GPT ④).
|
|
1193
1417
|
session.retiring = true;
|
|
1194
1418
|
retainedChildren.delete(session.child);
|
|
1195
1419
|
session.connection.close?.(err);
|
|
1196
1420
|
teardownChild(session.child);
|
|
1197
|
-
// The SAME diagnostics a new turn reports. Without the session-scoped
|
|
1198
|
-
// tail a mid-turn child death on a resident session surfaced as a bare
|
|
1199
|
-
// "ACP connection closed" with nothing to read it by.
|
|
1200
|
-
finishError(err, aborted, session.stderrTail);
|
|
1201
1421
|
}
|
|
1202
1422
|
}
|
|
1203
1423
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* classify-tmux-cwd — the ONE classification of a start directory that is about to be handed
|
|
3
|
+
* to tmux as a `-c` value. Shared leaf of the resume and fresh launch compositions; it owns
|
|
4
|
+
* the classification and NOTHING else — no hints (each consumer phrases its own: resume says
|
|
5
|
+
* "recorded cwd", fresh says "requested cwd"), no argv, no tmux, no fallback directory.
|
|
6
|
+
*
|
|
7
|
+
* Every rule below is a MEASURED tmux 3.6a behaviour (2026-08-06, private server), and each
|
|
8
|
+
* one is a way a launch would look successful while being wrong:
|
|
9
|
+
*
|
|
10
|
+
* 1. a NONEXISTENT `-c` is silent. tmux exits 0, opens the window, and the child falls back
|
|
11
|
+
* to `$HOME`. A launch whose directory has been deleted would therefore open a visible
|
|
12
|
+
* window in the wrong project and look successful. Nothing downstream can catch that:
|
|
13
|
+
* the launch receipt would be perfectly well-formed.
|
|
14
|
+
* 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
|
|
15
|
+
* (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its
|
|
16
|
+
* command. A path is data; tmux reads it as a format. So `#` is refused outright.
|
|
17
|
+
* 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
|
|
18
|
+
* arrived intact. So there is no quoting grammar here, and none is owed.
|
|
19
|
+
*
|
|
20
|
+
* That is the entire defence: one existence check and one character. No escaping layer, no
|
|
21
|
+
* sanitiser, no trim, no realpath/symlink policy — a symlinked project dir is a normal thing
|
|
22
|
+
* to work in, and a value is classified exactly as given.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { statSync } from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/** Why a candidate `-c` value was refused. Four stable literals — both consuming
|
|
29
|
+
* compositions widen their own reject unions with this type, so the strings are contract. */
|
|
30
|
+
export type TmuxCwdRejectReason = "cwd-not-absolute" | "cwd-format-token" | "cwd-missing" | "cwd-not-directory";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Classify a candidate start directory. Split into separate reasons rather than one because
|
|
34
|
+
* the operator's next move differs: an absolute-path bug is a caller defect, a missing
|
|
35
|
+
* directory is a moved/deleted project, and a `#` is a path tmux would rewrite under us.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyTmuxCwd(cwd: string): TmuxCwdRejectReason | null {
|
|
38
|
+
if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
|
|
39
|
+
// tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
|
|
40
|
+
// was observed executing; neither is something to escape our way out of.
|
|
41
|
+
if (cwd.includes("#")) return "cwd-format-token";
|
|
42
|
+
let st: ReturnType<typeof statSync>;
|
|
43
|
+
try {
|
|
44
|
+
st = statSync(cwd);
|
|
45
|
+
} catch {
|
|
46
|
+
// tmux would NOT report this — it opens the window and lands the child in $HOME.
|
|
47
|
+
return "cwd-missing";
|
|
48
|
+
}
|
|
49
|
+
return st.isDirectory() ? null : "cwd-not-directory";
|
|
50
|
+
}
|
|
@@ -25,9 +25,28 @@
|
|
|
25
25
|
* caller's own inbound surface. Merging them would claim knowledge this module cannot have.
|
|
26
26
|
* 4. A launch with no callback is a REAL outcome, not an error to retry. No watcher, no poll,
|
|
27
27
|
* no timeout supervisor. The window is visible; the operator can look.
|
|
28
|
+
*
|
|
29
|
+
* ── The optional REQUESTED cwd (issue #73) ──
|
|
30
|
+
*
|
|
31
|
+
* A fresh sibling starts wherever the caller happens to be — unless the caller names ONE
|
|
32
|
+
* literal start directory. That input exists so a cross-repo fresh consultation never has to
|
|
33
|
+
* ride `entwurf_resume_call` for a dormant record's recorded cwd: resume stays a continuity
|
|
34
|
+
* verb, and placement pressure stays here. The rules are deliberately narrow:
|
|
35
|
+
*
|
|
36
|
+
* - `undefined` and the exact empty string mean OMIT: no `-c` reaches tmux and the argv is
|
|
37
|
+
* byte-identical to the pre-#73 shape. Anything else is taken LITERALLY — no trim, no
|
|
38
|
+
* realpath, no project-name resolution, no store/peers/record lookup. The caller is the
|
|
39
|
+
* only cwd authority this module knows.
|
|
40
|
+
* - the value is classified by the shared `classify-tmux-cwd.ts` leaf BEFORE any mutation
|
|
41
|
+
* (same four stable reasons as resume; the measured tmux 3.6a facts live on that leaf).
|
|
42
|
+
* This module's hints phrase them as the REQUESTED cwd; resume's say RECORDED.
|
|
43
|
+
* - the receipt echoes what was REQUESTED, exactly as `runtimePath` does. It never reports
|
|
44
|
+
* `pane_current_path`: proving where the pane actually landed belongs to acceptance, not
|
|
45
|
+
* to the launch receipt.
|
|
28
46
|
*/
|
|
29
47
|
|
|
30
48
|
import { randomBytes } from "node:crypto";
|
|
49
|
+
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
31
50
|
import {
|
|
32
51
|
assertLaunchTarget,
|
|
33
52
|
LaunchPreconditionError,
|
|
@@ -142,10 +161,12 @@ export function buildFreshCallPrompt(params: {
|
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
/** A launch that was refused, or a placement that could not be established. Every value is a
|
|
145
|
-
* NAMED refusal — this module has no fallback launch.
|
|
164
|
+
* NAMED refusal — this module has no fallback launch and no fallback directory. The cwd members
|
|
165
|
+
* come from the shared classification leaf and their string values are stable contract. */
|
|
146
166
|
export type FreshCallRejectReason =
|
|
147
167
|
| PlacementRejectReason
|
|
148
168
|
| LaunchRejectReason
|
|
169
|
+
| TmuxCwdRejectReason
|
|
149
170
|
| "caller-identity-unavailable"
|
|
150
171
|
| "model-empty"
|
|
151
172
|
| "model-invalid"
|
|
@@ -158,6 +179,10 @@ export type FreshCallRejectReason =
|
|
|
158
179
|
export interface FreshCallReceipt extends WindowHandle {
|
|
159
180
|
backend: FreshCallBackend;
|
|
160
181
|
model: string;
|
|
182
|
+
/** The REQUESTED start directory — present only when the caller supplied one. The same kind
|
|
183
|
+
* of fact as `runtimePath`: what tmux was asked for, never an observation of where the pane
|
|
184
|
+
* landed. */
|
|
185
|
+
cwd?: string;
|
|
161
186
|
runtimePath: string;
|
|
162
187
|
nonce: string;
|
|
163
188
|
}
|
|
@@ -174,20 +199,28 @@ function defaultRandomHex(): string {
|
|
|
174
199
|
return randomBytes(12).toString("hex");
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
/** Launch argv: the leaf's detached-append shape,
|
|
202
|
+
/** Launch argv: the leaf's detached-append shape, optionally `-c` at the resume-symmetric token
|
|
203
|
+
* position (after `-t`, before `-P -F`), the runtime, then the backend's dialect. An omitted cwd
|
|
204
|
+
* yields the exact pre-#73 argv — no carrier at all. */
|
|
178
205
|
export function buildFreshCallArgs(
|
|
179
206
|
placement: Placement,
|
|
180
207
|
runtimePath: string,
|
|
181
208
|
backendArgs: readonly string[],
|
|
209
|
+
cwd?: string,
|
|
182
210
|
): string[] {
|
|
183
211
|
assertSelector("session", placement.sessionId);
|
|
184
212
|
assertLaunchTarget(runtimePath);
|
|
213
|
+
if (cwd !== undefined) {
|
|
214
|
+
const bad = classifyTmuxCwd(cwd);
|
|
215
|
+
if (bad) throw new Error(`mux-fresh-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
|
|
216
|
+
}
|
|
185
217
|
return [
|
|
186
218
|
"new-window",
|
|
187
219
|
"-d",
|
|
188
220
|
"-a",
|
|
189
221
|
"-t",
|
|
190
222
|
`${placement.sessionId}:{end}`,
|
|
223
|
+
...(cwd === undefined ? [] : ["-c", cwd]),
|
|
191
224
|
"-P",
|
|
192
225
|
"-F",
|
|
193
226
|
APPEND_FORMAT,
|
|
@@ -207,7 +240,7 @@ export function buildFreshCallArgs(
|
|
|
207
240
|
* against a store, or guesses it: an empty value is a named refusal, not a lookup.
|
|
208
241
|
*/
|
|
209
242
|
export function freshCall(
|
|
210
|
-
params: { backend: FreshCallBackend; model: string; task: string; callerGardenId: string | null },
|
|
243
|
+
params: { backend: FreshCallBackend; model: string; task: string; cwd?: string; callerGardenId: string | null },
|
|
211
244
|
env: NodeJS.ProcessEnv = process.env,
|
|
212
245
|
nonce: string = mintNonce(),
|
|
213
246
|
): FreshCallResult {
|
|
@@ -220,6 +253,14 @@ export function freshCall(
|
|
|
220
253
|
const task = params.task.trim();
|
|
221
254
|
if (task.length === 0) return { ok: false, reason: "task-empty" };
|
|
222
255
|
if (task.length > TASK_MAX_CHARS) return { ok: false, reason: "task-too-long" };
|
|
256
|
+
// ONLY `undefined` and the exact empty string mean "no cwd". Everything else is the literal
|
|
257
|
+
// value — deliberately untrimmed, so a whitespace-mangled path is refused loudly by the
|
|
258
|
+
// classification below instead of being silently repaired into a different directory.
|
|
259
|
+
const cwd = params.cwd === undefined || params.cwd === "" ? undefined : params.cwd;
|
|
260
|
+
if (cwd !== undefined) {
|
|
261
|
+
const badCwd = classifyTmuxCwd(cwd);
|
|
262
|
+
if (badCwd) return { ok: false, reason: badCwd };
|
|
263
|
+
}
|
|
223
264
|
|
|
224
265
|
let runtimePath: string;
|
|
225
266
|
try {
|
|
@@ -240,7 +281,10 @@ export function freshCall(
|
|
|
240
281
|
callerGardenId: params.callerGardenId,
|
|
241
282
|
nonce,
|
|
242
283
|
});
|
|
243
|
-
const run = runTmux(
|
|
284
|
+
const run = runTmux(
|
|
285
|
+
buildFreshCallArgs(placement, runtimePath, buildBackendArgs(params.backend, prompt, model), cwd),
|
|
286
|
+
env,
|
|
287
|
+
);
|
|
244
288
|
assertTmuxOk("new-window", run);
|
|
245
289
|
|
|
246
290
|
let fields: ReturnType<typeof parseWindowFields>;
|
|
@@ -265,6 +309,7 @@ export function freshCall(
|
|
|
265
309
|
...fields,
|
|
266
310
|
backend: params.backend,
|
|
267
311
|
model,
|
|
312
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
268
313
|
runtimePath,
|
|
269
314
|
nonce,
|
|
270
315
|
},
|
|
@@ -280,6 +325,13 @@ const REJECT_HINT: Record<FreshCallRejectReason, string> = {
|
|
|
280
325
|
"anchor-mismatch": "tmux answered about a different pane than the one asked about",
|
|
281
326
|
"caller-identity-unavailable":
|
|
282
327
|
"this surface has no record-backed garden id for the caller, so the sibling would have no address to call back to",
|
|
328
|
+
"cwd-not-absolute":
|
|
329
|
+
"the requested cwd is not an absolute path (the value is taken literally — nothing trims or resolves it)",
|
|
330
|
+
"cwd-format-token":
|
|
331
|
+
"the requested cwd contains '#', which tmux expands as a format inside -c — it would silently rewrite the path or run a command",
|
|
332
|
+
"cwd-missing":
|
|
333
|
+
"the requested cwd does not exist; tmux would not report this, it would open the window in $HOME and look successful",
|
|
334
|
+
"cwd-not-directory": "the requested cwd exists but is not a directory",
|
|
283
335
|
"model-empty": "model is empty after trimming; fresh calls require an explicit model",
|
|
284
336
|
"model-invalid": `model must be one ${MODEL_MAX_CHARS}-character argv-safe id/alias without whitespace or tmux syntax`,
|
|
285
337
|
"task-empty": "task is empty after trimming",
|
|
@@ -314,6 +366,7 @@ export function renderFreshCall(result: FreshCallResult): { text: string; isErro
|
|
|
314
366
|
`[entwurf fresh call →]\n` +
|
|
315
367
|
` backend: ${r.backend} (${r.runtimePath})\n` +
|
|
316
368
|
` model: ${r.model} (requested on the runtime CLI)\n` +
|
|
369
|
+
(r.cwd === undefined ? "" : ` cwd: ${r.cwd} (requested start directory — not an observation)\n`) +
|
|
317
370
|
` window: ${r.windowId} (index ${r.windowIndex}) in session ${r.sessionId}\n` +
|
|
318
371
|
` pane: ${r.paneId} pid ${r.panePid}\n` +
|
|
319
372
|
` nonce: ${r.nonce}\n` +
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*
|
|
5
5
|
* ── Why this is a module and not a parameter on fresh-call ──
|
|
6
6
|
*
|
|
7
|
-
* `mux-fresh-call` carries a TASK to a runtime it names
|
|
8
|
-
* happens to be
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* `mux-fresh-call` carries a TASK to a runtime it names, and starts the sibling wherever the
|
|
8
|
+
* caller happens to be unless the caller REQUESTS one literal start directory (#73). A resume
|
|
9
|
+
* carries neither a task nor a caller choice: the argv comes from the record (`entwurf-v2-
|
|
10
|
+
* visible-resume` builds it) and the cwd comes from the record too — it is the directory the
|
|
11
|
+
* citizen's own transcript header remembers, never something the caller picks. Those are
|
|
12
|
+
* different inputs with a different risk, so they get a different module rather than a fourth
|
|
13
|
+
* parameter on a composition whose contract is "identity is an OUTPUT".
|
|
13
14
|
*
|
|
14
15
|
* visible-resume composition → resume-call → placement leaf (unchanged, carrier-free)
|
|
15
16
|
* resume-call -X-> garden identity, records, locks, delivery
|
|
@@ -20,22 +21,15 @@
|
|
|
20
21
|
*
|
|
21
22
|
* `mux-placement`'s `buildAppendArgs` deliberately emits no `-c` ("default shell only"), and it
|
|
22
23
|
* stays that way — a resume must not widen the leaf's grammar for the three other callers. So
|
|
23
|
-
* the `-c`
|
|
24
|
-
*
|
|
24
|
+
* the `-c` SHAPE lives here, while the classification of the value lives in the shared
|
|
25
|
+
* `classify-tmux-cwd.ts` leaf (fresh-call hands tmux the same flag, and a twin copy of the
|
|
26
|
+
* measured rules would rot apart on the next tmux hazard). The measured tmux 3.6a facts —
|
|
27
|
+
* a nonexistent `-c` silently lands the child in `$HOME`, `#` is format-expanded, whitespace
|
|
28
|
+
* is safe — are documented on that leaf. `|` is fine too: the cwd never enters the `-F` row
|
|
29
|
+
* (see `APPEND_FORMAT` below).
|
|
25
30
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* window in the wrong project and look successful. Nothing downstream can catch that: the
|
|
29
|
-
* launch receipt would be perfectly well-formed.
|
|
30
|
-
* 2. `-c` is FORMAT-EXPANDED. `#{pane_id}` inside the value silently rewrote the path
|
|
31
|
-
* (`<dir>/#{pane_id}` → `<dir>/%0`), and a `#(…)` value was observed running its command.
|
|
32
|
-
* A path is data; tmux reads it as a format. So `#` is refused outright.
|
|
33
|
-
* 3. whitespace is SAFE — argv is an array and nothing re-splits. A dir named `with space`
|
|
34
|
-
* arrived intact. So there is no quoting grammar here, and none is owed. `|` is fine too:
|
|
35
|
-
* the cwd never enters the `-F` row (see `APPEND_FORMAT` below).
|
|
36
|
-
*
|
|
37
|
-
* That is the entire defence: one existence check and one character. No escaping layer, no
|
|
38
|
-
* sanitiser, no symlink policy — a symlinked project dir is a normal thing to work in.
|
|
31
|
+
* What stays HERE is the phrasing: this module's hints say "recorded cwd", because a resume's
|
|
32
|
+
* directory comes from the record — fresh-call's say "requested cwd" for the same reasons.
|
|
39
33
|
*
|
|
40
34
|
* ── What the receipt does NOT say ──
|
|
41
35
|
*
|
|
@@ -47,8 +41,7 @@
|
|
|
47
41
|
* to acceptance, not to the product's launch receipt.
|
|
48
42
|
*/
|
|
49
43
|
|
|
50
|
-
import {
|
|
51
|
-
import path from "node:path";
|
|
44
|
+
import { classifyTmuxCwd, type TmuxCwdRejectReason } from "./classify-tmux-cwd.ts";
|
|
52
45
|
import {
|
|
53
46
|
assertLaunchTarget,
|
|
54
47
|
LaunchPreconditionError,
|
|
@@ -74,14 +67,9 @@ import {
|
|
|
74
67
|
export const RESUME_CALL_RUNTIME = "pi";
|
|
75
68
|
|
|
76
69
|
/** Why a resume window could not be opened. Every value is a NAMED refusal; this module has no
|
|
77
|
-
* fallback launch and no fallback directory.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
| LaunchRejectReason
|
|
81
|
-
| "cwd-not-absolute"
|
|
82
|
-
| "cwd-format-token"
|
|
83
|
-
| "cwd-missing"
|
|
84
|
-
| "cwd-not-directory";
|
|
70
|
+
* fallback launch and no fallback directory. The cwd members come from the shared classification
|
|
71
|
+
* leaf and their string values are stable contract. */
|
|
72
|
+
export type ResumeCallRejectReason = PlacementRejectReason | LaunchRejectReason | TmuxCwdRejectReason;
|
|
85
73
|
|
|
86
74
|
/** Coordinates plus what was handed to tmux. `cwd` is the REQUESTED start directory — the same
|
|
87
75
|
* kind of fact as `runtimePath`, namely what tmux was asked for, not an observation. */
|
|
@@ -92,26 +80,6 @@ export interface ResumeCallReceipt extends WindowHandle {
|
|
|
92
80
|
|
|
93
81
|
export type ResumeCallResult = { ok: true; receipt: ResumeCallReceipt } | { ok: false; reason: ResumeCallRejectReason };
|
|
94
82
|
|
|
95
|
-
/**
|
|
96
|
-
* Classify a candidate start directory. Split into three reasons rather than one because the
|
|
97
|
-
* operator's next move differs: an absolute-path bug is a caller defect, a missing directory is
|
|
98
|
-
* a moved/deleted project, and a `#` is a path tmux would rewrite under us.
|
|
99
|
-
*/
|
|
100
|
-
export function classifyResumeCwd(cwd: string): ResumeCallRejectReason | null {
|
|
101
|
-
if (!path.isAbsolute(cwd)) return "cwd-not-absolute";
|
|
102
|
-
// tmux expands formats inside the `-c` VALUE. `#{…}` rewrote the path silently and `#(…)`
|
|
103
|
-
// was observed executing; neither is something to escape our way out of.
|
|
104
|
-
if (cwd.includes("#")) return "cwd-format-token";
|
|
105
|
-
let st: ReturnType<typeof statSync>;
|
|
106
|
-
try {
|
|
107
|
-
st = statSync(cwd);
|
|
108
|
-
} catch {
|
|
109
|
-
// tmux would NOT report this — it opens the window and lands the child in $HOME.
|
|
110
|
-
return "cwd-missing";
|
|
111
|
-
}
|
|
112
|
-
return st.isDirectory() ? null : "cwd-not-directory";
|
|
113
|
-
}
|
|
114
|
-
|
|
115
83
|
/**
|
|
116
84
|
* Launch argv: the leaf's detached-append shape plus `-c`, the runtime, then the caller's flags.
|
|
117
85
|
* `--` is what keeps tmux from reading the runtime or its flags as tmux options.
|
|
@@ -124,7 +92,7 @@ export function buildResumeCallArgs(
|
|
|
124
92
|
): string[] {
|
|
125
93
|
assertSelector("session", placement.sessionId);
|
|
126
94
|
assertLaunchTarget(runtimePath);
|
|
127
|
-
const bad =
|
|
95
|
+
const bad = classifyTmuxCwd(cwd);
|
|
128
96
|
if (bad) throw new Error(`mux-resume-call: refusing to build argv with an unusable cwd (${bad}): ${cwd}`);
|
|
129
97
|
return [
|
|
130
98
|
"new-window",
|
|
@@ -154,7 +122,7 @@ export function resumeCall(
|
|
|
154
122
|
params: { cwd: string; runtimeArgs: readonly string[] },
|
|
155
123
|
env: NodeJS.ProcessEnv = process.env,
|
|
156
124
|
): ResumeCallResult {
|
|
157
|
-
const badCwd =
|
|
125
|
+
const badCwd = classifyTmuxCwd(params.cwd);
|
|
158
126
|
if (badCwd) return { ok: false, reason: badCwd };
|
|
159
127
|
|
|
160
128
|
let runtimePath: string;
|