@junghanacs/entwurf 0.13.0 → 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.
- package/AGENTS.md +52 -17
- package/BASELINE.md +60 -206
- package/CHANGELOG.md +64 -0
- package/CONTRIBUTING.md +14 -10
- package/DELIVERY.md +118 -261
- package/README.md +91 -431
- package/VERIFY.md +74 -95
- package/demo/README.md +1 -1
- package/demo/demo-baseline.sh +1 -3
- package/demo/demo.sh +2 -5
- package/docs/acp-backend-rail.md +236 -0
- package/docs/external-mcp-host.md +131 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +124 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
- package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
- package/mcp/entwurf-bridge/src/index.ts +176 -67
- package/mcp/entwurf-bridge/start.sh +2 -2
- package/mcp/entwurf-bridge/test.sh +23 -9
- package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
- package/package.json +25 -11
- package/pi-extensions/entwurf-control.ts +220 -71
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +366 -38
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +11 -9
- package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
- package/pi-extensions/lib/entwurf-core.ts +15 -15
- package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
- package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
- package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
- package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
- package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
- package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
- package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
- package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
- package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
- package/pi-extensions/lib/meta-session.ts +93 -5
- package/pi-extensions/lib/mux-fresh-call.ts +328 -0
- package/pi-extensions/lib/mux-launch.ts +267 -0
- package/pi-extensions/lib/mux-placement.ts +387 -0
- package/pi-extensions/lib/mux-resume-call.ts +221 -0
- package/pi-extensions/lib/resume-launch-identity.ts +162 -0
- package/pi-extensions/lib/session-id.js +8 -5
- package/pi-extensions/lib/socket-discovery.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +688 -282
- package/scripts/agy-bridge-config.py +5 -1
- package/scripts/check-acp-backend-preflight.ts +1 -1
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-overlay.ts +13 -3
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-acp-stream-hooks.ts +504 -0
- package/scripts/check-elapsed.sh +25 -0
- package/scripts/check-entwurf-bridge-boot.ts +51 -4
- package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
- package/scripts/check-entwurf-control-rpc.ts +4 -3
- package/scripts/check-entwurf-resume-args.ts +72 -70
- package/scripts/check-entwurf-session-identity.ts +14 -10
- package/scripts/check-entwurf-v2-contract.ts +34 -59
- package/scripts/check-entwurf-v2-decider.ts +17 -177
- package/scripts/check-entwurf-v2-lock.ts +5 -2
- package/scripts/check-entwurf-v2-matrix.ts +3 -53
- package/scripts/check-entwurf-v2-production.ts +2 -91
- package/scripts/check-entwurf-v2-release.ts +10 -105
- package/scripts/check-entwurf-v2-runner.ts +4 -85
- package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
- package/scripts/check-entwurf-v2-send.ts +0 -28
- package/scripts/check-entwurf-v2-surface.ts +198 -128
- package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
- package/scripts/check-fresh-cut-gate.sh +1 -1
- package/scripts/check-gate-qualification.ts +101 -7
- package/scripts/check-install-container.sh +10 -2
- package/scripts/check-install-surface.ts +1 -1
- package/scripts/check-keyset-overlap.py +1 -1
- package/scripts/check-meta-facts.ts +249 -0
- package/scripts/check-meta-identity-consumers.ts +1 -1
- package/scripts/check-meta-session.ts +169 -0
- package/scripts/check-mux-launch-tmux.ts +316 -0
- package/scripts/check-mux-launch.ts +288 -0
- package/scripts/check-mux-launcher-fence.ts +264 -0
- package/scripts/check-mux-parent-artifact.ts +195 -0
- package/scripts/check-mux-placement-tmux.ts +322 -0
- package/scripts/check-mux-placement.ts +323 -0
- package/scripts/check-mux-resume-call.ts +282 -0
- package/scripts/check-probe-cli-shim.ts +25 -22
- package/scripts/check-probe-ordering.ts +121 -81
- package/scripts/check-release-gate-outcomes.ts +452 -0
- package/scripts/check-resume-launch-identity.ts +244 -0
- package/scripts/check-socket-discovery.ts +1 -1
- package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
- package/scripts/inventory-verification-surface.ts +349 -0
- package/scripts/lib/claude-launcher-fence.ts +322 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/mutation-qualify.ts +109 -3
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/meta-bridge-doctor.sh +6 -8
- package/scripts/meta-facts.ts +60 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-overlay.json +17 -0
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/acp-stream-hooks.json +158 -0
- package/scripts/mutants/bridge-boot-resume.json +45 -0
- package/scripts/mutants/meta-facts.json +50 -0
- package/scripts/mutants/meta-identity.json +36 -0
- package/scripts/mutants/mux-boundary.json +196 -0
- package/scripts/mutants/mux-fresh-call.json +185 -0
- package/scripts/mutants/mux-launcher-fence.json +123 -0
- package/scripts/mutants/mux-parent-artifact.json +39 -0
- package/scripts/mutants/mux-resume-call.json +148 -0
- package/scripts/mutants/probe-ordering.json +6 -1021
- package/scripts/mutants/release-gate.json +140 -0
- package/scripts/mutants/resume-args.json +76 -0
- package/scripts/mutants/resume-launch-identity.json +96 -0
- package/scripts/mutants/v2-surface.json +74 -23
- package/scripts/mutants/v2-visible-resume.json +215 -0
- package/scripts/new-session-id.ts +9 -4
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +10 -20
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
- package/scripts/smoke-meta-honesty.sh +1 -1
- package/scripts/smoke-meta-install-state.sh +4 -0
- package/scripts/smoke-mux-fresh-call-live.ts +365 -0
- package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
- package/scripts/smoke-pi-attach.ts +1 -1
- package/scripts/smoke-user-scope-citizen.sh +1 -1
- package/scripts/tsconfig.json +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
- package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
- package/scripts/check-acp-sdk-surface.ts +0 -236
- package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
- package/scripts/check-entwurf-v2-spawn.ts +0 -399
- package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
|
@@ -14,57 +14,33 @@
|
|
|
14
14
|
* from mode/action — that would blur the F1 ownership contract).
|
|
15
15
|
* - `renderEntwurfV2Result` — the result union → `{ text, isError }`, surfacing the carry-overs
|
|
16
16
|
* the surface must NOT drop: a reject's reason+diagnostic, a control `rejectReason` (N3), a
|
|
17
|
-
*
|
|
17
|
+
* and the N1 delivered+lock-dirty `execution-failed`.
|
|
18
18
|
* - `runAndRenderEntwurfV2FromSurface` — assemble production deps + run + render, so the root
|
|
19
19
|
* surface never needs to name the `EntwurfV2RunResult` union (it only sees `{text,isError}`).
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import * as path from "node:path";
|
|
23
22
|
import type { SenderEnvelope } from "./entwurf-control-rpc.ts";
|
|
24
23
|
import type { DispatchInput, EntwurfV2Mode } from "./entwurf-v2-decider.ts";
|
|
25
24
|
import { makeProductionEntwurfV2Deps, type ProductionEntwurfV2Opts } from "./entwurf-v2-production.ts";
|
|
26
25
|
import { type EntwurfV2RunResult, runEntwurfV2 } from "./entwurf-v2-runner.ts";
|
|
27
26
|
import { FRESH_CUT_PRESCRIPTION } from "./meta-session.ts";
|
|
28
27
|
|
|
29
|
-
/** The operator-policy SSOT for v2 dispatch's preflight prefix-auto-approve roots (5d-4b).
|
|
30
|
-
* ONE shared env var feeds BOTH surfaces (pi-native + MCP) — a pi session and an MCP child
|
|
31
|
-
* both inherit it, so there is no per-surface config fork. `prefixRoots` is operator policy,
|
|
32
|
-
* not session-local UX, so it is an env var, not a pi flag. */
|
|
33
|
-
export const ENTWURF_PREFIX_ROOTS_ENV = "ENTWURF_PREFIX_ROOTS";
|
|
34
|
-
|
|
35
|
-
/** Parse `ENTWURF_PREFIX_ROOTS` into the preflight's `prefixRoots`. `path.delimiter`-
|
|
36
|
-
* separated (`:` on Linux/macOS); entries are trimmed, empty segments dropped. Unset / empty
|
|
37
|
-
* / delimiters-only ⇒ `[]` (no prefix promotion — frozen decision 7, no package default).
|
|
38
|
-
* It does NOT throw on a nonexistent/typo path: `preflight`'s normalize keeps an absolute
|
|
39
|
-
* fallback, so a bad root simply never matches (a typo must not broaden approve, and must not
|
|
40
|
-
* turn every owned-outcome dispatch into a loud failure). `~` is left for preflight to expand. */
|
|
41
|
-
export function parseEntwurfPrefixRootsEnv(raw: string | undefined = process.env[ENTWURF_PREFIX_ROOTS_ENV]): string[] {
|
|
42
|
-
if (!raw) return [];
|
|
43
|
-
return raw
|
|
44
|
-
.split(path.delimiter)
|
|
45
|
-
.map((s) => s.trim())
|
|
46
|
-
.filter((s) => s.length > 0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
28
|
/** The raw shape a surface (pi tool / MCP verb) collects. `wants_reply` is snake_case to
|
|
50
29
|
* match the external `entwurf_v2` convention; the runner sees `wantsReply`. */
|
|
51
30
|
export interface SurfaceEntwurfV2Params {
|
|
52
31
|
target: string;
|
|
53
|
-
intent: "fire-and-forget"
|
|
32
|
+
intent: "fire-and-forget";
|
|
54
33
|
mode?: EntwurfV2Mode;
|
|
55
34
|
wants_reply?: boolean;
|
|
56
35
|
message: string;
|
|
57
36
|
}
|
|
58
37
|
|
|
59
38
|
/** ctx-free run options. The caller (entwurf-control.ts / MCP bridge) builds `senderProvider`
|
|
60
|
-
* from its own envelope source — this module never touches `ExtensionContext`.
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* undefined (no surface sets it). Explicit opts still win — kept for tests / a future surface. */
|
|
39
|
+
* from its own envelope source — this module never touches `ExtensionContext`. It is one
|
|
40
|
+
* field: the trust-preflight inputs (`agentDir`/`prefixRoots`, and the `ENTWURF_PREFIX_ROOTS`
|
|
41
|
+
* env SSOT behind them) went with the resume verdict they existed to guard. */
|
|
64
42
|
export interface EntwurfV2SurfaceRunOptions {
|
|
65
43
|
senderProvider: () => SenderEnvelope | undefined;
|
|
66
|
-
agentDir?: string;
|
|
67
|
-
prefixRoots?: readonly string[];
|
|
68
44
|
}
|
|
69
45
|
|
|
70
46
|
/** What the surface renders: the human/tool text + whether it is an error (a non-delivery). */
|
|
@@ -88,29 +64,40 @@ export function toDispatchInput(params: SurfaceEntwurfV2Params): DispatchInput {
|
|
|
88
64
|
|
|
89
65
|
/**
|
|
90
66
|
* Detour B (B-a) — actionable rendering of an honest reject. The decider is UNCHANGED:
|
|
91
|
-
* a reject stays a reject (Hard Rule 3)
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* reading as "delivery impossible". Returns undefined for rejects with no useful next step.
|
|
67
|
+
* a reject stays a reject (Hard Rule 3). This only appends a one-line "what to do instead"
|
|
68
|
+
* to the reject TEXT, so an honest reject stops reading as "delivery impossible". Returns
|
|
69
|
+
* undefined for rejects with no useful next step.
|
|
95
70
|
*/
|
|
96
71
|
export function actionableRejectHint(reason: string): string | undefined {
|
|
97
72
|
switch (reason) {
|
|
98
|
-
case "
|
|
99
|
-
//
|
|
100
|
-
//
|
|
73
|
+
case "dormant-fire-forget-unsupported":
|
|
74
|
+
// The cell that carries the whole cost of the visible-first cut. Naming the real
|
|
75
|
+
// state — the citizen exists and is not running — matters more than ever now that
|
|
76
|
+
// there is no verb behind it: a caller that reads "reject" as "wrong id" goes
|
|
77
|
+
// looking in the wrong place.
|
|
101
78
|
return (
|
|
102
|
-
"
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"(
|
|
79
|
+
"this citizen's record is intact but its session is not running, and delivery has no way to wake it. " +
|
|
80
|
+
"The resume that used to answer here launched a hidden background child and was withdrawn under the " +
|
|
81
|
+
"visible-first rule; the visible same-id resume is the separate lifecycle verb entwurf_resume_call. " +
|
|
82
|
+
"Reopen the citizen with entwurf_resume_call {target} (pi targets only; it runs no turn), then dispatch again."
|
|
83
|
+
);
|
|
84
|
+
case "indeterminate-no-spawn":
|
|
85
|
+
// The socket probe did not settle, so liveness is UNKNOWN — an unestablished probe
|
|
86
|
+
// is not a measured death, and dispatching into it could double-deliver. Say
|
|
87
|
+
// "the PROBE was inconclusive", never "the socket answered inconclusively":
|
|
88
|
+
// `indeterminate` also covers a probe that got no answer at all, was refused by
|
|
89
|
+
// permissions, or timed out, and "answered" claims a reply that may never have
|
|
90
|
+
// existed. The wire id is FROZEN and still spells "-no-spawn"; it names the rule
|
|
91
|
+
// (never dispatch into an indeterminate target), not a capability that still
|
|
92
|
+
// exists. So the hint's job is to say plainly what did NOT happen: nothing
|
|
93
|
+
// delivered, nothing started.
|
|
94
|
+
return (
|
|
95
|
+
"the control-socket probe was inconclusive, so the target's liveness is UNKNOWN — this is not a " +
|
|
96
|
+
"measured death. NOTHING was delivered and NO process was started (the reason id keeps its frozen " +
|
|
97
|
+
"'-no-spawn' wire spelling from an era when one could be; entwurf_v2 starts nothing on any rail). " +
|
|
98
|
+
"Re-run entwurf_peers to re-probe; if it stays indeterminate, check for a stale socket file left at " +
|
|
99
|
+
"that garden id by a session that died without cleaning up."
|
|
106
100
|
);
|
|
107
|
-
case "owned-live-no-autosend":
|
|
108
|
-
// A live target is reachable, but owned-outcome is not an auto-send (Q2/F1).
|
|
109
|
-
return "target is live — owned-outcome never auto-sends. Use intent: fire-and-forget (with wants_reply if you need a reply).";
|
|
110
|
-
case "native-push-no-resume-authority":
|
|
111
|
-
// A native-push backend (antigravity) has no pi-child to own, so owned-outcome has
|
|
112
|
-
// nothing to own. Delivery is a direct inject via fire-and-forget.
|
|
113
|
-
return "native-push target (e.g. antigravity) has no resume/spawn authority → owned-outcome is unsupported. Use intent: fire-and-forget to direct-inject into the live conversation.";
|
|
114
101
|
case "native-push-target-dead":
|
|
115
102
|
// The adapter probe found no live host process for the conversation.
|
|
116
103
|
return "native-push conversation is not live (no host process found). Re-open the conversation, then retry — there is nothing to inject into.";
|
|
@@ -135,7 +122,7 @@ export function actionableRejectHint(reason: string): string | undefined {
|
|
|
135
122
|
/** Render the outcome-rich result to `{ text, isError }`. A reject or a thrown/failed/dirty
|
|
136
123
|
* delivery is `isError:true`; a sent/fallback-sent/enqueued/observed delivery is `isError:false`.
|
|
137
124
|
* A control in-band `rejected` is a non-delivery (isError:true) and carries N3 `rejectReason`
|
|
138
|
-
* when present
|
|
125
|
+
* when present. */
|
|
139
126
|
export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2SurfaceRendered {
|
|
140
127
|
switch (result.kind) {
|
|
141
128
|
case "rejected": {
|
|
@@ -162,28 +149,6 @@ export function renderEntwurfV2Result(result: EntwurfV2RunResult): EntwurfV2Surf
|
|
|
162
149
|
isError: !delivered,
|
|
163
150
|
};
|
|
164
151
|
}
|
|
165
|
-
if (o.transport === "spawn-bg") {
|
|
166
|
-
const res = o.result;
|
|
167
|
-
if (res.kind === "lock-retained") {
|
|
168
|
-
const d = res.diagnostic;
|
|
169
|
-
return {
|
|
170
|
-
text:
|
|
171
|
-
`entwurf_v2 spawn-bg LOCK RETAINED (${res.reason}) — lock NOT released, operator must clear:` +
|
|
172
|
-
`\n target: ${d.targetGardenId}` +
|
|
173
|
-
`\n lockPath: ${d.lockPath}` +
|
|
174
|
-
`\n expectedSocketPath: ${d.expectedSocketPath}` +
|
|
175
|
-
`\n observeTimeoutMs: ${d.observeTimeoutMs}, killGraceMs: ${d.killGraceMs}` +
|
|
176
|
-
(res.error ? `\n error: ${res.error}` : ""),
|
|
177
|
-
isError: true,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
if (res.kind === "spawn-start-failed") {
|
|
181
|
-
return { text: `entwurf_v2 spawn-bg failed to start: ${res.error}`, isError: true };
|
|
182
|
-
}
|
|
183
|
-
const pid = "pid" in res && res.pid !== undefined ? ` (pid ${res.pid})` : "";
|
|
184
|
-
const exit = res.kind === "child-exited" ? ` exitCode=${res.exitCode}` : "";
|
|
185
|
-
return { text: `entwurf_v2 spawn-bg → ${res.kind}${pid}${exit}, lock released`, isError: false };
|
|
186
|
-
}
|
|
187
152
|
if (o.transport === "native-push") {
|
|
188
153
|
// direct-inject succeeded; note if the 1-shot re-probe retry fired.
|
|
189
154
|
return {
|
|
@@ -222,12 +187,7 @@ export async function runAndRenderEntwurfV2FromSurface(
|
|
|
222
187
|
params: SurfaceEntwurfV2Params,
|
|
223
188
|
opts: EntwurfV2SurfaceRunOptions,
|
|
224
189
|
): Promise<EntwurfV2SurfaceRendered> {
|
|
225
|
-
const prodOpts: ProductionEntwurfV2Opts = {
|
|
226
|
-
senderProvider: opts.senderProvider,
|
|
227
|
-
agentDir: opts.agentDir,
|
|
228
|
-
// Explicit opts win (test / future surface override); otherwise the shared env SSOT.
|
|
229
|
-
prefixRoots: opts.prefixRoots ?? parseEntwurfPrefixRootsEnv(),
|
|
230
|
-
};
|
|
190
|
+
const prodOpts: ProductionEntwurfV2Opts = { senderProvider: opts.senderProvider };
|
|
231
191
|
const result = await runEntwurfV2(toDispatchInput(params), makeProductionEntwurfV2Deps(prodOpts));
|
|
232
192
|
return renderEntwurfV2Result(result);
|
|
233
193
|
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entwurf-v2-visible-resume — stand a dormant pi citizen back up in a window the operator can
|
|
3
|
+
* see, under the same garden id, and say only what was actually observed.
|
|
4
|
+
*
|
|
5
|
+
* ── Where this sits ──
|
|
6
|
+
*
|
|
7
|
+
* surface (entwurf-control.ts · mcp bridge index.ts) composition root: supplies `launch`
|
|
8
|
+
* └─ entwurf-v2-visible-resume (this file) v2 leaves ONLY: lock, record, socket
|
|
9
|
+
* └─ deps.launch(argv, cwd) ── INJECTED SEAM ──→ mux-resume-call → placement leaf
|
|
10
|
+
*
|
|
11
|
+
* The seam is what keeps `docs/mux-launch-rail.md` §11 intact in both directions: v2 never
|
|
12
|
+
* imports mux, mux never imports entwurf core. This module cannot open a window; it can only
|
|
13
|
+
* ask whoever wired it to. That is deliberate — it means the delivery fence stays exactly where
|
|
14
|
+
* `check-mux-launch` scans for it.
|
|
15
|
+
*
|
|
16
|
+
* ── Why a resume is structurally SMALLER than a fresh call on identity, and LARGER on the lock ──
|
|
17
|
+
*
|
|
18
|
+
* `mux-fresh-call` mints no identity: the sibling calls back and its envelope IS the address.
|
|
19
|
+
* A resume already knows the address — that is what "same-id" means — so there is no nonce, no
|
|
20
|
+
* callback, and no correlation receipt. What it gains instead is the lock: two visible resumes
|
|
21
|
+
* of one garden id would put two processes on one transcript, and `entwurf_v2`'s in-domain
|
|
22
|
+
* dispatch takes the SAME per-gid lock, so a resume and a live send serialise against each other
|
|
23
|
+
* for free. The lock is consistency, not defence — there is no watcher behind it.
|
|
24
|
+
*
|
|
25
|
+
* ── The observation is a startup wait, not supervision ──
|
|
26
|
+
*
|
|
27
|
+
* Measured 2026-08-06: the resumed control socket appeared ~2s (ACP) and ~4s (native) after the
|
|
28
|
+
* window opened. A single immediate probe would therefore have observed `dead` and reported a
|
|
29
|
+
* successful resume as unobserved. So the observation is ONE bounded operation with a short
|
|
30
|
+
* interval, and everything that would make it supervision is deliberately absent:
|
|
31
|
+
*
|
|
32
|
+
* - no relaunch, ever. A refused or dead launch is a result, not a retry trigger.
|
|
33
|
+
* - no pane watcher. Until the deadline this module looks at ONE thing: the socket.
|
|
34
|
+
* - no env knob on the timeout. A tunable would make "unobserved" mean different things on
|
|
35
|
+
* different hosts, and the receipt would stop being comparable.
|
|
36
|
+
*
|
|
37
|
+
* On timeout the lock is RELEASED and the window is LEFT OPEN. That is the same rule fresh-call
|
|
38
|
+
* already ships ("a launch with no callback is a real outcome"): the window is visible, the
|
|
39
|
+
* operator can look, and a retained lock on a visible artifact is the worse failure — it would
|
|
40
|
+
* have to be cleared by hand on every slow host.
|
|
41
|
+
*
|
|
42
|
+
* ── Two receipts, never merged ──
|
|
43
|
+
*
|
|
44
|
+
* `launch` says tmux made a window and was asked to start pi. `observation` says whether the
|
|
45
|
+
* citizen's socket came back under the same id. Neither says the resumed citizen did anything:
|
|
46
|
+
* a resume runs no turn (measured — the transcript stayed byte-identical), so there is no turn
|
|
47
|
+
* outcome for this module to claim.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { buildResumePiArgs } from "./entwurf-resume-args.ts";
|
|
51
|
+
import {
|
|
52
|
+
type AcquireLockResult,
|
|
53
|
+
ENTWURF_V2_LOCK_DIR,
|
|
54
|
+
type LockClaim,
|
|
55
|
+
type ReleaseResult,
|
|
56
|
+
acquireLock as realAcquireLock,
|
|
57
|
+
releaseLock as realReleaseLock,
|
|
58
|
+
} from "./entwurf-v2-lock.ts";
|
|
59
|
+
import {
|
|
60
|
+
type LaunchIdentity,
|
|
61
|
+
ResumeBackendUnsupportedError,
|
|
62
|
+
resolveResumeLaunchIdentity,
|
|
63
|
+
} from "./resume-launch-identity.ts";
|
|
64
|
+
import { isValidSessionId } from "./session-id.js";
|
|
65
|
+
import {
|
|
66
|
+
CONTROL_SOCKET_DIR,
|
|
67
|
+
inspectTargetControlSocket,
|
|
68
|
+
mapInspectionToLiveness,
|
|
69
|
+
type TargetSocketInspection,
|
|
70
|
+
} from "./socket-discovery.ts";
|
|
71
|
+
import { probeSocketLiveness, type SocketLiveness } from "./socket-probe.ts";
|
|
72
|
+
|
|
73
|
+
/** How long ONE observation operation waits for the resumed citizen's socket. Fixed on purpose:
|
|
74
|
+
* see the module header on why this is not an env knob. */
|
|
75
|
+
export const RESUME_OBSERVE_TIMEOUT_MS = 30_000;
|
|
76
|
+
/** How often that one operation re-inspects. Short enough that a 2s startup is reported as the
|
|
77
|
+
* success it is, long enough that a 30s wait is ~60 lstats and not a spin. */
|
|
78
|
+
export const RESUME_OBSERVE_INTERVAL_MS = 500;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The narrow set of EXPECTED refusals. Record, store, transcript and model integrity are NOT in
|
|
82
|
+
* here: `resolveResumeLaunchIdentity` already throws a cause-rich error for each of those
|
|
83
|
+
* ("stale or foreign transcript", "relative transcriptPath", "no recorded model", "unresolvable
|
|
84
|
+
* ACP bridge", …), and copying those causes into an enum would duplicate a truth that already
|
|
85
|
+
* fails loud — the second copy is what goes stale.
|
|
86
|
+
*
|
|
87
|
+
* `target-not-pi` IS in here, and the line between it and those throws is worth stating: asking
|
|
88
|
+
* to resume a Claude Code or agy citizen is not a defect at all. Nothing is corrupt, the record
|
|
89
|
+
* is exactly right, and the operator has simply reached past a capability boundary — only pi
|
|
90
|
+
* stands a control socket up, so only pi has a same-id resume. That deserves a named refusal a
|
|
91
|
+
* caller can act on, not an error report about a store that is fine.
|
|
92
|
+
*/
|
|
93
|
+
export type VisibleResumeRejectReason =
|
|
94
|
+
| "target-invalid"
|
|
95
|
+
| "target-not-pi"
|
|
96
|
+
| "target-locked"
|
|
97
|
+
| "target-live"
|
|
98
|
+
| "target-indeterminate"
|
|
99
|
+
| "target-address-conflict"
|
|
100
|
+
| "launch-refused";
|
|
101
|
+
|
|
102
|
+
/** What the injected launcher gives back. Structural on purpose: naming mux's types here would
|
|
103
|
+
* be the import this split exists to avoid. */
|
|
104
|
+
export interface VisibleResumeLaunchHandle {
|
|
105
|
+
serverPid: string;
|
|
106
|
+
sessionId: string;
|
|
107
|
+
windowId: string;
|
|
108
|
+
windowIndex: string;
|
|
109
|
+
paneId: string;
|
|
110
|
+
panePid: string;
|
|
111
|
+
runtimePath: string;
|
|
112
|
+
cwd: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type VisibleResumeLaunchOutcome =
|
|
116
|
+
| { ok: true; handle: VisibleResumeLaunchHandle }
|
|
117
|
+
| { ok: false; reason: string; hint: string };
|
|
118
|
+
|
|
119
|
+
/** Fact 1: tmux created a window and was asked to start pi there. `cwd` is the REQUESTED start
|
|
120
|
+
* directory (what tmux was told), the same kind of fact as `runtimePath` — not an observation of
|
|
121
|
+
* where the pane landed. */
|
|
122
|
+
export interface ResumeLaunchReceipt {
|
|
123
|
+
targetGardenId: string;
|
|
124
|
+
sessionFile: string;
|
|
125
|
+
handle: VisibleResumeLaunchHandle;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fact 2: the citizen's control socket came back under the SAME garden id, or it did not. This
|
|
129
|
+
* is the only fact in this module that says a citizen is back. */
|
|
130
|
+
export interface ResumeObservationReceipt {
|
|
131
|
+
kind: "socket-alive" | "resume-unobserved";
|
|
132
|
+
socketPath: string;
|
|
133
|
+
waitedMs: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type VisibleResumeResult =
|
|
137
|
+
| { ok: true; launch: ResumeLaunchReceipt; observation: ResumeObservationReceipt }
|
|
138
|
+
| { ok: false; reason: VisibleResumeRejectReason; detail: string };
|
|
139
|
+
|
|
140
|
+
export interface VisibleResumeDeps {
|
|
141
|
+
acquireLock: (gardenId: string) => AcquireLockResult;
|
|
142
|
+
releaseLock: (claim: LockClaim) => ReleaseResult;
|
|
143
|
+
resolveIdentity: (gardenId: string) => LaunchIdentity;
|
|
144
|
+
inspectSocket: (gardenId: string) => Promise<TargetSocketInspection>;
|
|
145
|
+
probeSocket: (socketPath: string) => Promise<SocketLiveness>;
|
|
146
|
+
/** The ONLY way this module reaches tmux. The surface supplies it. */
|
|
147
|
+
launch: (input: { cwd: string; runtimeArgs: readonly string[] }) => VisibleResumeLaunchOutcome;
|
|
148
|
+
sleep: (ms: number) => Promise<void>;
|
|
149
|
+
now: () => number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Production wiring for everything except the launch seam — the surface owns that, because it
|
|
153
|
+
* is the only layer allowed to know both halves. */
|
|
154
|
+
export function makeVisibleResumeDeps(
|
|
155
|
+
launch: VisibleResumeDeps["launch"],
|
|
156
|
+
overrides: Partial<VisibleResumeDeps> = {},
|
|
157
|
+
): VisibleResumeDeps {
|
|
158
|
+
return {
|
|
159
|
+
acquireLock: (gid) => realAcquireLock(gid, { dir: ENTWURF_V2_LOCK_DIR }),
|
|
160
|
+
releaseLock: (claim) => realReleaseLock(claim, { dir: ENTWURF_V2_LOCK_DIR }),
|
|
161
|
+
resolveIdentity: resolveResumeLaunchIdentity,
|
|
162
|
+
inspectSocket: (gid) => inspectTargetControlSocket(gid, CONTROL_SOCKET_DIR),
|
|
163
|
+
probeSocket: (socketPath) => probeSocketLiveness(socketPath),
|
|
164
|
+
launch,
|
|
165
|
+
sleep: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
|
166
|
+
now: () => Date.now(),
|
|
167
|
+
...overrides,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reopen `targetGardenId` in a visible window.
|
|
173
|
+
*
|
|
174
|
+
* Order is the safety argument. The lock is taken FIRST, before the liveness question, so the
|
|
175
|
+
* answer cannot change under us between reading it and acting on it — a competing resume would
|
|
176
|
+
* otherwise pass its own `dead` check while ours was launching. Identity is resolved under that
|
|
177
|
+
* lock and BEFORE any window opens, so a citizen with no transcript (measured: a pi that never
|
|
178
|
+
* took a turn has a socket and a record but no transcriptPath) fails loud with its own cause and
|
|
179
|
+
* leaves nothing behind.
|
|
180
|
+
*/
|
|
181
|
+
export async function visibleResume(targetGardenId: string, deps: VisibleResumeDeps): Promise<VisibleResumeResult> {
|
|
182
|
+
// The address grammar is `session-id.js`'s, not a local copy. The socket filename, the lock
|
|
183
|
+
// path and the record key are all keyed on that SAME id, so a second regex here could drift
|
|
184
|
+
// into accepting an id one of those three would reject — and this value becomes a lock path
|
|
185
|
+
// and a socket path a few lines down.
|
|
186
|
+
if (!isValidSessionId(targetGardenId)) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
reason: "target-invalid",
|
|
190
|
+
detail: `${JSON.stringify(targetGardenId)} is not a garden id (YYYYMMDDTHHMMSS-xxxxxx)`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const acquired = deps.acquireLock(targetGardenId);
|
|
195
|
+
if (!acquired.ok) {
|
|
196
|
+
return { ok: false, reason: "target-locked", detail: acquired.conflict.detail };
|
|
197
|
+
}
|
|
198
|
+
const claim = acquired.claim;
|
|
199
|
+
|
|
200
|
+
let released = false;
|
|
201
|
+
/** Release exactly once, and never let a failed release read as success. A lock this module
|
|
202
|
+
* took and could not give back is a fact the operator has to know about — the next resume or
|
|
203
|
+
* send against that citizen will be refused, and silence would send them looking at the wrong
|
|
204
|
+
* layer. */
|
|
205
|
+
const release = (): void => {
|
|
206
|
+
if (released) return;
|
|
207
|
+
released = true;
|
|
208
|
+
const outcome = deps.releaseLock(claim);
|
|
209
|
+
if (outcome !== "released") {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`entwurf-v2-visible-resume: attempted to release the lock for ${targetGardenId}; the release returned ` +
|
|
212
|
+
`"${outcome}" instead of "released" (${claim.lockPath}) — the per-gid lock may still be held; clear ` +
|
|
213
|
+
`it by hand before resuming or dispatching to that citizen again.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// Record authority, under the lock, before any window. Every failure here is a cause-rich
|
|
220
|
+
// throw from the leaf (foreign transcript, relative or deleted session file, no recorded
|
|
221
|
+
// model, unresolvable bridge) and is deliberately NOT re-typed into a reject — with ONE
|
|
222
|
+
// exception, matched on the error's own `reason` FIELD rather than its wording: a citizen
|
|
223
|
+
// of a backend that has no same-id resume. That is a capability boundary, not a defect, so
|
|
224
|
+
// it is a named refusal. The record is not read a second time to ask which backend it was;
|
|
225
|
+
// the error already carries it.
|
|
226
|
+
let identity: LaunchIdentity;
|
|
227
|
+
try {
|
|
228
|
+
identity = deps.resolveIdentity(targetGardenId);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
if (err instanceof ResumeBackendUnsupportedError) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
reason: err.reason,
|
|
234
|
+
detail:
|
|
235
|
+
`${targetGardenId} is a ${err.backend} citizen; only pi citizens stand a control socket up, ` +
|
|
236
|
+
`so only they can be reopened under the same garden id`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Liveness preflight, still under the lock. A live citizen is addressed with
|
|
243
|
+
// `entwurf_v2 fire-and-forget`, not reopened — and refusing here is also what stops two
|
|
244
|
+
// windows existing for one garden id.
|
|
245
|
+
const inspection = await deps.inspectSocket(targetGardenId);
|
|
246
|
+
const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
247
|
+
if ("addressConflict" in mapped) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
reason: "target-address-conflict",
|
|
251
|
+
detail:
|
|
252
|
+
`the control-socket path for ${targetGardenId} is a symlink or not a socket — that address is ` +
|
|
253
|
+
`corrupt, and resuming would stand a citizen up behind it`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (mapped.liveness === "alive") {
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason: "target-live",
|
|
260
|
+
detail: `${targetGardenId} is already live at ${mapped.socketPath} — send to it with entwurf_v2 fire-and-forget instead`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if (mapped.liveness === "indeterminate") {
|
|
264
|
+
return {
|
|
265
|
+
ok: false,
|
|
266
|
+
reason: "target-indeterminate",
|
|
267
|
+
detail:
|
|
268
|
+
`the control-socket probe for ${targetGardenId} was inconclusive — it is not provably dormant, ` +
|
|
269
|
+
`and resuming a citizen that is actually running would put two processes on one transcript`,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const runtimeArgs = buildResumePiArgs({
|
|
274
|
+
sessionFile: identity.sessionFile,
|
|
275
|
+
explicitExtensionArgs: identity.explicitExtensionArgs,
|
|
276
|
+
provider: identity.provider,
|
|
277
|
+
model: identity.model,
|
|
278
|
+
});
|
|
279
|
+
const launched = deps.launch({ cwd: identity.cwd, runtimeArgs });
|
|
280
|
+
if (!launched.ok) {
|
|
281
|
+
return { ok: false, reason: "launch-refused", detail: `${launched.reason} — ${launched.hint}` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const observation = await observeSameGidSocket(targetGardenId, mapped.socketPath, deps);
|
|
285
|
+
return {
|
|
286
|
+
ok: true,
|
|
287
|
+
launch: { targetGardenId, sessionFile: identity.sessionFile, handle: launched.handle },
|
|
288
|
+
observation,
|
|
289
|
+
};
|
|
290
|
+
} finally {
|
|
291
|
+
release();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* ONE bounded operation: wait for the SAME garden id's control socket to answer. Alive is the
|
|
297
|
+
* only early exit — an inspection that comes back absent, corrupt or inconclusive during startup
|
|
298
|
+
* is a normal intermediate state, not a verdict, so it is simply not-yet.
|
|
299
|
+
*/
|
|
300
|
+
export async function observeSameGidSocket(
|
|
301
|
+
targetGardenId: string,
|
|
302
|
+
socketPath: string,
|
|
303
|
+
deps: Pick<VisibleResumeDeps, "inspectSocket" | "probeSocket" | "sleep" | "now">,
|
|
304
|
+
timeoutMs: number = RESUME_OBSERVE_TIMEOUT_MS,
|
|
305
|
+
intervalMs: number = RESUME_OBSERVE_INTERVAL_MS,
|
|
306
|
+
): Promise<ResumeObservationReceipt> {
|
|
307
|
+
const started = deps.now();
|
|
308
|
+
for (;;) {
|
|
309
|
+
const inspection = await deps.inspectSocket(targetGardenId);
|
|
310
|
+
const mapped = await mapInspectionToLiveness(inspection, deps.probeSocket);
|
|
311
|
+
if (!("addressConflict" in mapped) && mapped.liveness === "alive") {
|
|
312
|
+
return { kind: "socket-alive", socketPath: mapped.socketPath, waitedMs: deps.now() - started };
|
|
313
|
+
}
|
|
314
|
+
if (deps.now() - started >= timeoutMs) {
|
|
315
|
+
return { kind: "resume-unobserved", socketPath, waitedMs: deps.now() - started };
|
|
316
|
+
}
|
|
317
|
+
await deps.sleep(intervalMs);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const REJECT_HINT: Record<VisibleResumeRejectReason, string> = {
|
|
322
|
+
"target-invalid": "no citizen has that id; discover targets with entwurf_peers",
|
|
323
|
+
"target-not-pi": "that citizen's backend has no same-id resume — reach it with entwurf_v2, or open a fresh sibling",
|
|
324
|
+
"target-locked": "another resume or dispatch holds this citizen's lock right now",
|
|
325
|
+
"target-live": "that citizen is already running — this verb only reopens dormant ones",
|
|
326
|
+
"target-indeterminate": "the socket probe could not prove the citizen is dormant, so nothing was started",
|
|
327
|
+
"target-address-conflict": "that citizen's control-socket path is corrupt",
|
|
328
|
+
"launch-refused": "tmux was never asked to open a window",
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* ONE renderer for both surfaces — the same reason `mux-fresh-call` has one: the two
|
|
333
|
+
* registrations are separate literals, and a shared renderer is what keeps the operator-visible
|
|
334
|
+
* answer from drifting apart between native pi and the MCP bridge.
|
|
335
|
+
*
|
|
336
|
+
* The success text keeps the two receipts visibly apart, including in the good case. A reader
|
|
337
|
+
* who sees "window opened" and "socket alive" as one sentence has been told the resume worked;
|
|
338
|
+
* a reader who sees them as two facts can tell which one is missing when only one is there.
|
|
339
|
+
*/
|
|
340
|
+
export function renderVisibleResume(result: VisibleResumeResult): { text: string; isError: boolean } {
|
|
341
|
+
if (!result.ok) {
|
|
342
|
+
return {
|
|
343
|
+
text:
|
|
344
|
+
`entwurf_resume_call rejected: ${result.reason} — ${REJECT_HINT[result.reason]}. ` +
|
|
345
|
+
`${result.detail}. No window was opened.`,
|
|
346
|
+
isError: true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const h = result.launch.handle;
|
|
350
|
+
const observed = result.observation.kind === "socket-alive";
|
|
351
|
+
return {
|
|
352
|
+
text:
|
|
353
|
+
`[entwurf resume call ↻]\n` +
|
|
354
|
+
` target: ${result.launch.targetGardenId}\n` +
|
|
355
|
+
` session: ${result.launch.sessionFile}\n` +
|
|
356
|
+
` runtime: ${h.runtimePath} (requested start cwd ${h.cwd})\n` +
|
|
357
|
+
` window: ${h.windowId} (index ${h.windowIndex}) in session ${h.sessionId}\n` +
|
|
358
|
+
` pane: ${h.paneId} pid ${h.panePid}\n` +
|
|
359
|
+
`\n` +
|
|
360
|
+
`LAUNCH receipt: tmux created that window and was asked to start pi on the transcript above.\n` +
|
|
361
|
+
(observed
|
|
362
|
+
? `OBSERVATION receipt: the control socket for ${result.launch.targetGardenId} answered after ` +
|
|
363
|
+
`${result.observation.waitedMs}ms — that citizen is addressable again, and entwurf_v2 fire-and-forget ` +
|
|
364
|
+
`now reaches it. No turn was run by this call.`
|
|
365
|
+
: `OBSERVATION receipt: resume-unobserved — the control socket did not answer within ` +
|
|
366
|
+
`${result.observation.waitedMs}ms. The window is open and visible; read it directly. The lock was ` +
|
|
367
|
+
`released, nothing was retried, and nothing was killed.`),
|
|
368
|
+
isError: false,
|
|
369
|
+
};
|
|
370
|
+
}
|