@junghanacs/entwurf 0.35.0 → 0.36.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 (91) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +338 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/DELIVERY.md +15 -0
  5. package/FAQ.md +3 -3
  6. package/README.md +30 -16
  7. package/VERIFY.md +14 -11
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +29 -3
  10. package/docs/setup-clean-host.md +31 -23
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +46 -2
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/model-ids.js +67 -0
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +9 -20
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +4 -3
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +11 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +27 -1
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +2 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/native-push/codex-ws-client.js +99 -18
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +3 -3
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +13 -4
  22. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +178 -25
  23. package/mcp/entwurf-bridge/dist/scripts/codex-socket-classify.js +44 -0
  24. package/mcp/entwurf-bridge/tsconfig.build.json +5 -0
  25. package/package.json +20 -15
  26. package/pi/pi-durable/bootstrap.mjs +12 -8
  27. package/pi/pi-durable/carrier/runtime.js +52 -2
  28. package/pi/pi-durable/carrier/subagent.js +6 -1
  29. package/pi/pi-durable/carrier/tui.js +31 -4
  30. package/pi/pi-durable/carrier-relocation.mjs +2 -2
  31. package/pi/pi-durable/overlay/upstream-pin.json +79 -36
  32. package/pi-extensions/lib/acp/backend-adapter.ts +52 -2
  33. package/pi-extensions/lib/acp/model-ids.js +77 -0
  34. package/pi-extensions/lib/acp/models.ts +15 -22
  35. package/pi-extensions/lib/codex-fresh-preflight.ts +4 -3
  36. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +12 -0
  37. package/pi-extensions/lib/fresh-call-dispatch.ts +25 -1
  38. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  39. package/pi-extensions/lib/mux-fresh-call.ts +3 -0
  40. package/pi-extensions/lib/native-push/codex-ws-client.ts +129 -18
  41. package/pi-extensions/lib/pi-durable-runtime.ts +4 -4
  42. package/pi-extensions/lib/resume-launch-identity.ts +12 -4
  43. package/pi-extensions/meta-bridge-pi-durable.ts +230 -34
  44. package/run.sh +193 -58
  45. package/scripts/check-acp-backend-preflight.ts +10 -2
  46. package/scripts/check-acp-cortex.ts +7 -6
  47. package/scripts/check-acp-prompt-lifecycle.ts +2 -2
  48. package/scripts/check-acp-provider-surface.ts +1 -1
  49. package/scripts/check-acp-session-reuse.ts +5 -5
  50. package/scripts/check-acp-stop-reason.ts +2 -2
  51. package/scripts/check-acp-stream-hooks.ts +2 -2
  52. package/scripts/check-acp-usage-accounting.ts +2 -2
  53. package/scripts/check-codex-app-server-launch.ts +55 -12
  54. package/scripts/check-codex-native-push.ts +231 -12
  55. package/scripts/check-fresh-call-dispatch.ts +47 -4
  56. package/scripts/check-gate-qualification.ts +8 -6
  57. package/scripts/check-probe-ordering.ts +13 -6
  58. package/scripts/check-resume-launch-identity.ts +38 -0
  59. package/scripts/codex-app-server-launch.sh +54 -29
  60. package/scripts/codex-socket-classify.ts +46 -0
  61. package/scripts/emit-pi-durable-carrier.ts +159 -168
  62. package/scripts/mutants/acp-cortex.json +17 -8
  63. package/scripts/mutants/acp-set-model-readback.json +28 -0
  64. package/scripts/mutants/codex-app-server-launch.json +39 -12
  65. package/scripts/mutants/codex-native.json +15 -0
  66. package/scripts/mutants/control-send-receipt.json +1 -1
  67. package/scripts/mutants/fresh-call-dispatch.json +11 -0
  68. package/scripts/mutants/herdr-dev-checkout.json +70 -0
  69. package/scripts/mutants/pack-install.json +3 -3
  70. package/scripts/mutants/pi-durable.json +282 -16
  71. package/scripts/mutants/pi-floor.json +5 -5
  72. package/scripts/mutants/resume-launch-identity.json +15 -0
  73. package/scripts/mutants/v2-visible-resume.json +11 -0
  74. package/scripts/pi-durable-contact-host.mjs +1 -1
  75. package/scripts/resolve-acp-bridge.ts +1 -1
  76. package/scripts/smoke-acp-bundled-mcp-live.ts +1 -1
  77. package/scripts/smoke-acp-carrier-augment-live.ts +1 -1
  78. package/scripts/smoke-acp-long-turn-live.ts +1 -1
  79. package/scripts/smoke-acp-mcp-live.ts +1 -1
  80. package/scripts/smoke-acp-memory-containment-live.ts +1 -1
  81. package/scripts/smoke-acp-ordering-probe-live.ts +1 -1
  82. package/scripts/smoke-acp-overlay-live.ts +1 -1
  83. package/scripts/smoke-acp-provider-live.ts +1 -1
  84. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  85. package/scripts/smoke-acp-session-reuse-live.ts +1 -1
  86. package/scripts/smoke-acp-skill-live.ts +1 -1
  87. package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
  88. package/scripts/smoke-acp-v2-send-live.ts +1 -1
  89. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  90. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  91. package/pi/pi-durable/overlay/runtime-contacts.patch +0 -283
@@ -91,6 +91,21 @@
91
91
  "timeoutSeconds": 60,
92
92
  "signature": "[QK:RESUME-ID-SESSION-ABSOLUTE]",
93
93
  "signatureSource": "scripts/check-resume-launch-identity.ts"
94
+ },
95
+ {
96
+ "claim": "RESUME-ID-ACP-EXACT-CURATION",
97
+ "title": "judging the RECORDED provider instead of the emitted one lets a bare Claude 5 id recorded under another provider reopen under entwurf on a Cortex row",
98
+ "subject": "pi-extensions/lib/resume-launch-identity.ts",
99
+ "find": [
100
+ "\tif (provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);"
101
+ ],
102
+ "replace": [
103
+ "\tif (identity.provider === PROVIDER_ID && !isCuratedAcpModelId(model)) throw new AcpModelNotCuratedError(model);"
104
+ ],
105
+ "gate": ["bash", "run.sh", "check-resume-launch-identity"],
106
+ "timeoutSeconds": 60,
107
+ "signature": "[QK:RESUME-ID-ACP-EXACT-CURATION]",
108
+ "signatureSource": "scripts/check-resume-launch-identity.ts"
94
109
  }
95
110
  ]
96
111
  }
@@ -210,6 +210,17 @@
210
210
  "timeoutSeconds": 60,
211
211
  "signature": "[QK:V2RESUME-SHARED-ID-GRAMMAR]",
212
212
  "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
213
+ },
214
+ {
215
+ "claim": "V2RESUME-ACP-MODEL-REFUSED",
216
+ "title": "not matching AcpModelNotCuratedError turns the named refusal into a raw error escaping the resume verb",
217
+ "subject": "pi-extensions/lib/entwurf-v2-visible-resume.ts",
218
+ "find": ["\t\t\tif (err instanceof AcpModelNotCuratedError) {"],
219
+ "replace": ["\t\t\tif (err instanceof AcpModelNotCuratedError && false) {"],
220
+ "gate": ["bash", "run.sh", "check-entwurf-v2-visible-resume"],
221
+ "timeoutSeconds": 60,
222
+ "signature": "[QK:V2RESUME-ACP-MODEL-REFUSED]",
223
+ "signatureSource": "pi-extensions/lib/entwurf-v2-visible-resume.test.ts"
213
224
  }
214
225
  ]
215
226
  }
@@ -68,7 +68,7 @@ try {
68
68
  const tool = citizen.contact.extension.tools.find((t) => t.name === name);
69
69
  if (tool === undefined) throw new Error(`the contact registers no ${name}`);
70
70
  const result = await tool.execute({}, undefined, {});
71
- return result.content.map((block) => block.text).join("\n");
71
+ return result.output.map((block) => block.text).join("\n");
72
72
  };
73
73
  const view = citizen.durable.view.current();
74
74
  const report = {
@@ -16,7 +16,7 @@ import type { ResolvedTarget } from "../pi-extensions/lib/entwurf-core.ts";
16
16
  import { getRegistryRouting } from "../pi-extensions/lib/entwurf-core.ts";
17
17
 
18
18
  const isRemote = process.argv[2] === "remote";
19
- const target: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-5", explicitOnly: false };
19
+ const target: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-5-5", explicitOnly: false };
20
20
 
21
21
  // Throws EntwurfRoutingError (non-zero exit) when the bridge cannot be resolved —
22
22
  // exactly the fail-fast the smoke wants to observe as a failure, not a pass.
@@ -56,7 +56,7 @@ import { skipLive } from "./lib/live-skip.ts";
56
56
  import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
57
57
 
58
58
  const ACP_PROVIDER = "entwurf";
59
- const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
59
+ const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
60
60
 
61
61
  const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
62
62
  const SOCKET_SUFFIX = ".sock";
@@ -48,7 +48,7 @@ import { fileURLToPath } from "node:url";
48
48
  import { skipLive } from "./lib/live-skip.ts";
49
49
 
50
50
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
51
- const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
51
+ const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
52
52
  const PROVIDER = "entwurf";
53
53
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
54
54
 
@@ -44,7 +44,7 @@ import { fileURLToPath } from "node:url";
44
44
  import { skipLive } from "./lib/live-skip.ts";
45
45
 
46
46
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
47
- const MODEL = process.env.ENTWURF_ACP_LONG_TURN_MODEL?.trim() || "claude-sonnet-5";
47
+ const MODEL = process.env.ENTWURF_ACP_LONG_TURN_MODEL?.trim() || "claude-sonnet-5-5";
48
48
  const PROVIDER = "entwurf";
49
49
 
50
50
  /** The cutoff this cut removed. The turn must outlive it. */
@@ -30,7 +30,7 @@ import { skipLive } from "./lib/live-skip.ts";
30
30
 
31
31
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
32
32
  const PROBE_SERVER = join(REPO_ROOT, "scripts", "fixtures", "probe-mcp-server.ts");
33
- const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
33
+ const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
34
34
  const PROVIDER = "entwurf";
35
35
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
36
36
 
@@ -70,7 +70,7 @@ import {
70
70
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
71
71
  import { skipLive } from "./lib/live-skip.ts";
72
72
 
73
- const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_MEMORY_MODEL ?? "claude-sonnet-5";
73
+ const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_MEMORY_MODEL ?? "claude-sonnet-5-5";
74
74
  const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_MEMORY_ALLOW_PATH_FALLBACK === "1";
75
75
  const RAW_TAIL_CAP = 64 * 1024;
76
76
 
@@ -93,7 +93,7 @@ const SHIM_RUNTIME_FILES: readonly string[] = [
93
93
  join(REPO_ROOT, "scripts", "lib", "probe-cli-target.ts"),
94
94
  join(REPO_ROOT, "scripts", "lib", "probe-event-log.ts"),
95
95
  ];
96
- const MODEL_ID = process.env.ENTWURF_ACP_PROBE_MODEL?.trim() || "claude-sonnet-5";
96
+ const MODEL_ID = process.env.ENTWURF_ACP_PROBE_MODEL?.trim() || "claude-sonnet-5-5";
97
97
  const D1_MS = Number(process.env.PROBE_D1_MS ?? "2000") || 2000;
98
98
  const D2_MS = Number(process.env.PROBE_D2_MS ?? "8000") || 8000;
99
99
  const TEXT_TAIL_CAP = 400;
@@ -57,7 +57,7 @@ import {
57
57
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
58
58
  import { skipLive } from "./lib/live-skip.ts";
59
59
 
60
- const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_OVERLAY_MODEL ?? "claude-sonnet-5";
60
+ const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_OVERLAY_MODEL ?? "claude-sonnet-5-5";
61
61
  const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_OVERLAY_ALLOW_PATH_FALLBACK === "1";
62
62
  const RAW_TAIL_CAP = 64 * 1024;
63
63
 
@@ -29,7 +29,7 @@ import { fileURLToPath } from "node:url";
29
29
  import { skipLive } from "./lib/live-skip.ts";
30
30
 
31
31
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
32
- const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
32
+ const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
33
33
  const PROVIDER = "entwurf";
34
34
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
35
35
 
@@ -36,7 +36,7 @@ import { connectAcpClient } from "../pi-extensions/lib/acp/acp-client.ts";
36
36
  import { terminateChild } from "./lib/acp-child-cleanup.ts";
37
37
  import { skipLive } from "./lib/live-skip.ts";
38
38
 
39
- const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_RAW_TURN_MODEL ?? "claude-sonnet-5";
39
+ const REQUESTED_MODEL_ID = process.env.ENTWURF_ACP_RAW_TURN_MODEL ?? "claude-sonnet-5-5";
40
40
  const ALLOW_PATH_FALLBACK = process.env.ENTWURF_ACP_RAW_TURN_ALLOW_PATH_FALLBACK === "1";
41
41
  const RAW_TAIL_CAP = 64 * 1024; // cap captured raw NDJSON to 64KB tail on report.
42
42
 
@@ -36,7 +36,7 @@ import { normalizeContext } from "@earendil-works/pi-ai";
36
36
  import { skipLive } from "./lib/live-skip.ts";
37
37
 
38
38
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
39
- const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
39
+ const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
40
40
  // The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
41
41
  // every turn, and since 0.86 that declaration reaches a provider as the leading system
42
42
  // message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
@@ -31,7 +31,7 @@ import { fileURLToPath } from "node:url";
31
31
  import { skipLive } from "./lib/live-skip.ts";
32
32
 
33
33
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
34
- const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
34
+ const MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
35
35
  const PROVIDER = "entwurf";
36
36
  const TURN_TIMEOUT_MS = Number(process.env.ENTWURF_ACP_PROVIDER_TIMEOUT_MS) || 240_000;
37
37
 
@@ -20,7 +20,7 @@
20
20
  // LIVE-only (spawns a real pi + opens a real socket) — kept OUT of `pnpm check`;
21
21
  // honest skip when LIVE!=1 (skip = CI safety, NOT an acceptance PASS).
22
22
  // LIVE=1 ./run.sh smoke-acp-socket-citizen-live
23
- // override model via ENTWURF_S1_MODEL (default claude-opus-5).
23
+ // override model via ENTWURF_S1_MODEL (default claude-opus-5-5).
24
24
 
25
25
  import { type ChildProcess, spawn } from "node:child_process";
26
26
  import { existsSync } from "node:fs";
@@ -35,7 +35,7 @@ import { skipLive } from "./lib/live-skip.ts";
35
35
  import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
36
36
 
37
37
  const ACP_PROVIDER = "entwurf";
38
- const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-5";
38
+ const ACP_MODEL = process.env.ENTWURF_S1_MODEL?.trim() || "claude-opus-5-5";
39
39
 
40
40
  const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
41
41
  const SOCKET_SUFFIX = ".sock";
@@ -89,7 +89,7 @@ import { skipLive } from "./lib/live-skip.ts";
89
89
  import { PI_BOOT_TIMEOUT_MS, waitForPiRecord } from "./lib/pi-record-discovery.ts";
90
90
 
91
91
  const ACP_PROVIDER = "entwurf";
92
- const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5";
92
+ const ACP_MODEL = process.env.ENTWURF_ACP_PROVIDER_MODEL?.trim() || "claude-sonnet-5-5";
93
93
 
94
94
  const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
95
95
  const SOCKET_SUFFIX = ".sock";
@@ -74,7 +74,7 @@ const REAL_CONTROL_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
74
74
  const SOCKET_SUFFIX = ".sock";
75
75
 
76
76
  const GPT_TARGET = process.env.ENTWURF_CHAIN_GPT_TARGET?.trim() || "openai-codex/gpt-5.6-luna";
77
- const ACP_TARGET = process.env.ENTWURF_CHAIN_ACP_TARGET?.trim() || "entwurf/claude-sonnet-5";
77
+ const ACP_TARGET = process.env.ENTWURF_CHAIN_ACP_TARGET?.trim() || "entwurf/claude-sonnet-5-5";
78
78
  const BOOT_TIMEOUT_MS = 45_000;
79
79
  const CLAUDE_TURN_TIMEOUT_MS = Number(process.env.ENTWURF_CHAIN_CLAUDE_TIMEOUT_MS) || 240_000;
80
80
  const CHAIN_TIMEOUT_MS = Number(process.env.ENTWURF_CHAIN_TIMEOUT_MS) || 420_000;
@@ -109,7 +109,7 @@ const BRIDGE_LAUNCHER = path.join(REPO, "mcp", "entwurf-bridge", "start.sh");
109
109
  /** The two pi shapes. They differ in exactly one thing that matters here — whether the record
110
110
  * carries `provider=entwurf`, which is what makes the resume argv re-inject the bridge (#29). */
111
111
  const PI_NATIVE_MODEL = "openai-codex/gpt-6-luna";
112
- const PI_ACP_MODEL = "entwurf/claude-sonnet-5";
112
+ const PI_ACP_MODEL = "entwurf/claude-sonnet-5-5";
113
113
  const CLAUDE_MODEL = "claude-sonnet-5";
114
114
 
115
115
  // Bounded waits — harness-side only. Each names what it is waiting FOR, so a red run says which
@@ -1,283 +0,0 @@
1
- diff --git a/packages/coding-agent/src/experimental/durable/runtime.ts b/packages/coding-agent/src/experimental/durable/runtime.ts
2
- index fcd6e523..7f00e27f 100644
3
- --- a/packages/coding-agent/src/experimental/durable/runtime.ts
4
- +++ b/packages/coding-agent/src/experimental/durable/runtime.ts
5
- @@ -8,7 +8,9 @@ import {
6
- type ConversationView,
7
- type Cursor,
8
- type EntryRecord,
9
- + type Extension,
10
- Harness,
11
- + type InputSubmissionDraft,
12
- type ModelRef,
13
- ROOT_CONVERSATION_ID,
14
- type Submission,
15
- @@ -56,8 +58,10 @@ export interface DurableView {
16
- readonly conversations: readonly ConversationSummary[];
17
- readonly models: readonly ModelSummary[];
18
- readonly notices: readonly Notice[];
19
- - /** The live task graph while the task panel is open. */
20
- + /** The live task graph, observed for the session's lifetime whether or not its panel is shown. */
21
- readonly tasks?: TaskGraph;
22
- + /** Whether the task panel is shown; /tasks toggles only this. */
23
- + readonly tasksPanel: boolean;
24
- }
25
-
26
- export interface DurableViewSource {
27
- @@ -81,6 +85,10 @@ export interface DurableController {
28
- export interface OpenDurableOptions {
29
- readonly cwd?: string;
30
- readonly continueSession?: boolean;
31
- + /** Installed after the built-in extensions and before the Harness opens, so recovered work finds their tools. */
32
- + readonly extensions?: readonly Extension[];
33
- + /** A new session's explicit model, resolved as pi's `--provider`/`--model`; unresolved, the open fails. */
34
- + readonly model?: { readonly provider: string; readonly model: string };
35
- }
36
-
37
- export interface OpenDurableResult {
38
- @@ -88,6 +96,8 @@ export interface OpenDurableResult {
39
- readonly controller: DurableController;
40
- /** pi's settings, for the TUI's theme and terminal capabilities. */
41
- readonly settings: SettingsManager;
42
- + /** Admits input to the root conversation, whichever one is on screen; resolves at admission, not at the answer. */
43
- + submitToRoot(draft: InputSubmissionDraft): Promise<Submission>;
44
- close(): Promise<void>;
45
- }
46
-
47
- @@ -122,6 +132,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
48
- }
49
-
50
- export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
51
- + if (options.model !== undefined && options.continueSession) {
52
- + throw new Error("An explicit model applies only to a new session, not to a continued one");
53
- + }
54
- const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
55
- const envs = new ExecutionEnvs(location.cwd);
56
- let harness: Harness | undefined;
57
- @@ -132,6 +145,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
58
- const settings = createHarnessSettings(settingsManager);
59
- const registry = createCodingRegistry(settingsManager, location.cwd);
60
- registry.install(Subagent);
61
- + for (const extension of options.extensions ?? []) registry.install(extension);
62
-
63
- const pendingReports: unknown[] = [];
64
- let report: (error: unknown) => void = (error) => pendingReports.push(error);
65
- @@ -146,7 +160,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
66
- },
67
- context,
68
- );
69
- - const initial = location.created ? await findInitialAgentModel(settingsManager, modelRuntime) : undefined;
70
- + const initial = location.created
71
- + ? await findInitialAgentModel(settingsManager, modelRuntime, options.model)
72
- + : undefined;
73
- + if (options.model !== undefined) {
74
- + // pi resolves case-insensitive, partial and unknown ids too (a custom-id fallback with only a warning);
75
- + // an explicit model must be exactly one registered catalog model, named by its canonical id.
76
- + const asked = options.model;
77
- + const ref = initial?.model;
78
- + const thinking = initial?.thinkingLevel;
79
- + const registered = ref !== undefined && modelRuntime.getModel(ref.provider, ref.modelId) !== undefined;
80
- + const named =
81
- + ref !== undefined &&
82
- + (asked.model === ref.modelId || (thinking !== undefined && asked.model === `${ref.modelId}:${thinking}`));
83
- + if (ref === undefined || ref.provider !== asked.provider || !registered || !named) {
84
- + const resolved =
85
- + ref === undefined
86
- + ? "none"
87
- + : `${ref.provider}/${ref.modelId}${thinking === undefined ? "" : `:${thinking}`}`;
88
- + throw new Error(
89
- + `explicit-model-not-exact: asked ${asked.provider}/${asked.model}, resolved ${resolved} (registered=${registered})`,
90
- + );
91
- + }
92
- + }
93
- const root = await harness.root(context, {
94
- agent: {
95
- cwd: location.cwd,
96
- @@ -179,6 +215,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
97
- conversations: summaries,
98
- models: models(),
99
- notices: [],
100
- + tasksPanel: true,
101
- };
102
- const listeners = new Set<() => void>();
103
- let notifying = false;
104
- @@ -296,17 +333,8 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
105
- const thinking: ModelThinkingLevel = agentOf(state.conversation).thinkingLevel ?? "off";
106
- await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
107
- }),
108
- - toggleTasks: () =>
109
- - command(async () => {
110
- - if (tasks !== undefined) {
111
- - closeTasks();
112
- - update({ tasks: undefined });
113
- - return;
114
- - }
115
- - const graph = await opened.taskGraph(context);
116
- - tasks = graph;
117
- - unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
118
- - }),
119
- + // Only the panel: the graph stays observed, so live background work is never out of view.
120
- + toggleTasks: () => command(async () => update({ tasksPanel: !state.tasksPanel })),
121
- switchConversation: (id) =>
122
- command(async () => {
123
- const next = await opened.conversation(id, context);
124
- @@ -326,8 +354,11 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
125
- notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
126
- }
127
- if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
128
- - // The task panel starts open; /tasks hides it.
129
- - await controller.toggleTasks();
130
- + // The task graph is observed until close; its panel starts open and /tasks hides it.
131
- + const graph = await opened.taskGraph(context);
132
- + tasks = graph;
133
- + update({ tasks: graph.value });
134
- + unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
135
- // Recovered work from an interrupted turn continues now.
136
- harness.resume();
137
-
138
- @@ -342,6 +373,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
139
- },
140
- controller,
141
- settings: settingsManager,
142
- + submitToRoot: (draft) => root.submit(draft, context),
143
- close() {
144
- closing ??= (async () => {
145
- unsubscribe();
146
- diff --git a/packages/coding-agent/src/experimental/durable/tui.ts b/packages/coding-agent/src/experimental/durable/tui.ts
147
- index 397b276c..6a8fce8f 100644
148
- --- a/packages/coding-agent/src/experimental/durable/tui.ts
149
- +++ b/packages/coding-agent/src/experimental/durable/tui.ts
150
- @@ -23,12 +23,14 @@ import {
151
- SelectList,
152
- type SelectListTheme,
153
- Spacer,
154
- + type StackEntry,
155
- setCapabilityOverrides,
156
- setKeybindings,
157
- Text,
158
- TruncatedText,
159
- TuiAltScreen,
160
- VStack,
161
- + visibleWidth,
162
- } from "@earendil-works/pi-tui";
163
- import { getAgentDir } from "../../config.ts";
164
- import { KeybindingsManager } from "../../core/keybindings.ts";
165
- @@ -150,10 +152,75 @@ interface Handlers {
166
- cycleThinking(): void;
167
- }
168
-
169
- +/**
170
- + * The live background-task badge: the dock's first row, never shrunk, so live background work stays on
171
- + * screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
172
- + * names the ones that are not running; with none it renders nothing and the dock is upstream's.
173
- + */
174
- +export class TaskBadge implements Component {
175
- + #count = 0;
176
- + #detail = "";
177
- +
178
- + get count(): number {
179
- + return this.#count;
180
- + }
181
- +
182
- + set(graph: TaskGraph | undefined): void {
183
- + const nodes = Object.values(graph?.tasks ?? {}).filter((node) => node.background);
184
- + const tally = (state: string, match: (node: TaskGraphNode) => boolean): string[] => {
185
- + const n = nodes.filter(match).length;
186
- + return n === 0 ? [] : [`${n} ${state}`];
187
- + };
188
- + this.#count = nodes.length;
189
- + this.#detail = [
190
- + ...tally("aborting", (node) => node.abortRequested),
191
- + ...tally("completing", (node) => !node.abortRequested && node.state.status === "completing"),
192
- + ...tally("waiting", (node) => !node.abortRequested && node.state.status === "waiting"),
193
- + ...tally("pending", (node) => !node.abortRequested && node.state.status === "pending"),
194
- + ].join(", ");
195
- + }
196
- +
197
- + /** The widest form that fits, padded when there is room; narrower terminals drop detail, then words. */
198
- + render(width: number): string[] {
199
- + const n = this.#count;
200
- + if (n === 0) return [];
201
- + const tasks = `⏳ ${n} ${n === 1 ? "task" : "tasks"}`;
202
- + const detailed = this.#detail ? [`${tasks} (${this.#detail})`] : [];
203
- + const forms = [...detailed, tasks, `⏳ ${n}`, `⏳${n}`, `*${n}`, "*+", "*"];
204
- + for (const form of forms) {
205
- + if (visibleWidth(form) < width) return [theme.fg("accent", ` ${form}`)];
206
- + if (visibleWidth(form) === width) return [theme.fg("accent", form)];
207
- + }
208
- + return [theme.fg("accent", "*")];
209
- + }
210
- +
211
- + invalidate(): void {}
212
- +}
213
- +
214
- +/**
215
- + * The screen: the transcript above the dock. The badge is the dock's first row and never shrinks, so the
216
- + * renderer, which clips from the bottom, drops the editor and the footer before it; a one-row terminal
217
- + * with live background work shows the badge instead of the transcript.
218
- + */
219
- +export function durableLayout(transcript: Component, badge: TaskBadge, dock: readonly StackEntry[]): VStack {
220
- + return new VStack([
221
- + {
222
- + component: transcript,
223
- + basis: 0,
224
- + grow: 1,
225
- + shrink: 1,
226
- + minSize: 1,
227
- + visible: (viewport) => badge.count === 0 || viewport.height > 1,
228
- + },
229
- + { component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
230
- + ]);
231
- +}
232
- +
233
- class DurableTui {
234
- static readonly #renderers: Record<string, ToolRenderers> = createAllToolRenderers();
235
- readonly #ui: TuiAltScreen;
236
- readonly #chat = new Container();
237
- + readonly #taskBadge = new TaskBadge();
238
- readonly #tasks = new Container();
239
- readonly #queue = new Container();
240
- readonly #notices = new Container();
241
- @@ -216,15 +283,9 @@ class DurableTui {
242
- content.addChild(new Spacer(1));
243
- const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
244
- this.#transcript = transcript;
245
- - const dock = new VStack([
246
- - { component: this.#tasks, shrink: 1, minSize: 0 },
247
- - { component: this.#queue, shrink: 1, minSize: 0 },
248
- - { component: this.#notices, shrink: 1, minSize: 0 },
249
- - { component: this.#editorContainer, shrink: 1, minSize: 3 },
250
- - { component: this.#footer, shrink: 1, minSize: 0 },
251
- - ]);
252
- for (const component of [
253
- this.#chat,
254
- + this.#taskBadge,
255
- this.#tasks,
256
- this.#queue,
257
- this.#notices,
258
- @@ -234,9 +295,12 @@ class DurableTui {
259
- this.#ui.addChild(component);
260
- }
261
- this.#ui.setLayoutRoot(
262
- - new VStack([
263
- - { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
264
- - { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
265
- + durableLayout(transcript, this.#taskBadge, [
266
- + { component: this.#tasks, shrink: 1, minSize: 0 },
267
- + { component: this.#queue, shrink: 1, minSize: 0 },
268
- + { component: this.#notices, shrink: 1, minSize: 0 },
269
- + { component: this.#editorContainer, shrink: 1, minSize: 3 },
270
- + { component: this.#footer, shrink: 1, minSize: 0 },
271
- ]),
272
- );
273
- this.#ui.setFocus(this.#editor);
274
- @@ -299,7 +363,8 @@ class DurableTui {
275
- );
276
- }
277
- }
278
- - this.#syncTasks(view.tasks);
279
- + this.#taskBadge.set(view.tasks);
280
- + this.#syncTasks(view.tasksPanel ? view.tasks : undefined);
281
- this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState);
282
- this.#syncNotices(view);
283
- this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");