@junghanacs/entwurf 0.31.0 → 0.33.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 (58) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +128 -0
  3. package/README.md +62 -117
  4. package/VERIFY.md +40 -21
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/setup-clean-host.md +112 -95
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +8 -10
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +153 -205
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +6 -4
  14. package/mcp/entwurf-bridge/src/index.ts +1 -1
  15. package/package.json +14 -17
  16. package/pi/pi-durable/bootstrap.mjs +142 -19
  17. package/pi/pi-durable/carrier/LICENSE +21 -0
  18. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  19. package/pi/pi-durable/carrier/prompt.js +74 -0
  20. package/pi/pi-durable/carrier/runtime.js +402 -0
  21. package/pi/pi-durable/carrier/sessions.js +56 -0
  22. package/pi/pi-durable/carrier/subagent.js +55 -0
  23. package/pi/pi-durable/carrier/tui.js +655 -0
  24. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  25. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +103 -11
  27. package/pi-extensions/lib/fresh-call-composition.ts +1 -1
  28. package/pi-extensions/lib/mux-fresh-call.ts +8 -10
  29. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +3 -3
  30. package/pi-extensions/lib/pi-durable-runtime.ts +197 -263
  31. package/pi-extensions/meta-bridge-pi-durable.ts +13 -7
  32. package/run.sh +519 -248
  33. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  34. package/scripts/check-fresh-call-dispatch.ts +1 -1
  35. package/scripts/check-gate-qualification.ts +86 -34
  36. package/scripts/check-herdr-activation.ts +5 -4
  37. package/scripts/check-pi-durable-contact.ts +26 -93
  38. package/scripts/check-pi-durable-receive.ts +22 -59
  39. package/scripts/check-pi-durable-send.ts +235 -75
  40. package/scripts/check-release-gate-outcomes.ts +21 -17
  41. package/scripts/emit-pi-durable-carrier.ts +355 -0
  42. package/scripts/lib/mutation-qualify.ts +21 -45
  43. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  44. package/scripts/lib/qualification-receipt.ts +7 -11
  45. package/scripts/mutants/control-send-receipt.json +1 -1
  46. package/scripts/mutants/gate-qualification.json +13 -0
  47. package/scripts/mutants/mux-fresh-call.json +13 -13
  48. package/scripts/mutants/pack-install.json +3 -3
  49. package/scripts/mutants/pi-durable.json +442 -109
  50. package/scripts/mutants/pi-floor.json +24 -2
  51. package/scripts/mutants/release-gate.json +5 -3
  52. package/scripts/pi-durable-contact-host.mjs +3 -3
  53. package/scripts/pi-durable-receive-host.mjs +3 -3
  54. package/scripts/pi-durable-send-host.mjs +76 -5
  55. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  56. package/scripts/smoke-pi-durable-fresh-live.ts +3 -3
  57. package/scripts/smoke-setup-verdict.sh +15 -15
  58. package/scripts/smoke-user-scope-citizen.sh +1 -1
@@ -0,0 +1,402 @@
1
+
2
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
+ import { clampThinkingLevel, getSupportedThinkingLevels, } from "@earendil-works/pi-ai";
4
+ import {
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+ Harness,
13
+
14
+
15
+ ROOT_CONVERSATION_ID,
16
+
17
+
18
+ } from "@earendil-works/pi-durable";
19
+ import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
20
+ import { ModelRuntime } from "entwurf-pi-dist:core/model-runtime.js";
21
+ import { SettingsManager } from "entwurf-pi-dist:core/settings-manager.js";
22
+ import {
23
+ configureHarnessHttp,
24
+ createCodingRegistry,
25
+ createHarnessSettings,
26
+ ExecutionEnvs,
27
+ findInitialAgentModel,
28
+ } from "./harness-setup.js";
29
+ import { selectSession } from "./sessions.js";
30
+ import { Subagent } from "./subagent.js";
31
+
32
+ const context = BACKGROUND_CONTEXT;
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+ /** A conversation the user can switch to: the main one, or a subagent's. */
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+ /** Everything the TUI renders. Plain values; no Harness objects cross this boundary. */
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+ /** What the TUI may ask for. */
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+ /** The agent document of a view; absent while the conversation has none. */
103
+ export function agentOf(view ) {
104
+ return (view.docs["pi.agent"] ?? {}) ;
105
+ }
106
+
107
+ /** A subagent's task: the oldest user message of its conversation. The main conversation needs no title. */
108
+ async function firstInput(harness , id ) {
109
+ if (id === ROOT_CONVERSATION_ID) return {};
110
+ const conversation = (await harness.conversation(id, context)) ;
111
+ let first ;
112
+ let cursor ;
113
+ do {
114
+ const page = await conversation.entries({}, 256, cursor, context);
115
+ first = page.items.findLast((entry) => entry.kind === "pi.user") ?? first;
116
+ cursor = page.next;
117
+ } while (cursor !== undefined);
118
+ return titleOf(first);
119
+ }
120
+
121
+ /** The text of a user entry, as a one-line title. */
122
+ function titleOf(entry ) {
123
+ const message = entry?.model?.[0];
124
+ if (message?.role !== "user") return {};
125
+ const text =
126
+ typeof message.content === "string"
127
+ ? message.content
128
+ : message.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(" ");
129
+ return { title: text.replace(/\s+/g, " ").trim() };
130
+ }
131
+
132
+ export async function openDurable(options = {}) {
133
+ if (options.model !== undefined && options.continueSession) {
134
+ throw new Error("An explicit model applies only to a new session, not to a continued one");
135
+ }
136
+ const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
137
+ const envs = new ExecutionEnvs(location.cwd);
138
+ let harness ;
139
+ try {
140
+ const modelRuntime = await ModelRuntime.create();
141
+ const settingsManager = SettingsManager.create(location.cwd);
142
+ configureHarnessHttp(settingsManager);
143
+ const settings = createHarnessSettings(settingsManager);
144
+ const registry = createCodingRegistry(settingsManager, location.cwd);
145
+ registry.install(Subagent);
146
+ for (const extension of options.extensions ?? []) registry.install(extension);
147
+
148
+ const pendingReports = [];
149
+ let report = (error) => pendingReports.push(error);
150
+ harness = await Harness.open(
151
+ await openNodeSqliteStorage(location.database),
152
+ {
153
+ models: modelRuntime,
154
+ registry,
155
+ settings,
156
+ env: envs.env,
157
+ onReport: (error) => report(error),
158
+ },
159
+ context,
160
+ );
161
+ const initial = location.created
162
+ ? await findInitialAgentModel(settingsManager, modelRuntime, options.model)
163
+ : undefined;
164
+ if (options.model !== undefined) {
165
+ // pi resolves case-insensitive, partial and unknown ids too (a custom-id fallback with only a warning);
166
+ // an explicit model must be exactly one registered catalog model, named by its canonical id.
167
+ const asked = options.model;
168
+ const ref = initial?.model;
169
+ const thinking = initial?.thinkingLevel;
170
+ const registered = ref !== undefined && modelRuntime.getModel(ref.provider, ref.modelId) !== undefined;
171
+ const named =
172
+ ref !== undefined &&
173
+ (asked.model === ref.modelId || (thinking !== undefined && asked.model === `${ref.modelId}:${thinking}`));
174
+ if (ref === undefined || ref.provider !== asked.provider || !registered || !named) {
175
+ const resolved =
176
+ ref === undefined
177
+ ? "none"
178
+ : `${ref.provider}/${ref.modelId}${thinking === undefined ? "" : `:${thinking}`}`;
179
+ throw new Error(
180
+ `explicit-model-not-exact: asked ${asked.provider}/${asked.model}, resolved ${resolved} (registered=${registered})`,
181
+ );
182
+ }
183
+ }
184
+ const root = await harness.root(context, {
185
+ agent: {
186
+ cwd: location.cwd,
187
+ ...(initial?.model === undefined ? {} : { model: initial.model }),
188
+ ...(initial?.thinkingLevel === undefined ? {} : { thinkingLevel: initial.thinkingLevel }),
189
+ },
190
+ });
191
+ const label = (id ) => (id === root.id ? "main" : `subagent ${id}`);
192
+ const opened = harness;
193
+ const summaries = [];
194
+ let cursor ;
195
+ do {
196
+ const page = await opened.commit((tx) => tx.scanConversations({}, 256, cursor), context);
197
+ for (const { id } of page.items) summaries.push({ id, label: label(id), ...(await firstInput(opened, id)) });
198
+ cursor = page.next;
199
+ } while (cursor !== undefined);
200
+ let current = root;
201
+ let conversation = await root.viewState(context);
202
+ const models = () =>
203
+ modelRuntime.getAvailableSnapshot().map((model) => ({
204
+ provider: model.provider,
205
+ modelId: model.id,
206
+ name: model.name,
207
+ contextWindow: model.contextWindow,
208
+ }));
209
+
210
+ let state = {
211
+ session: { id: location.id, directory: location.directory, cwd: location.cwd },
212
+ conversation: conversation.value,
213
+ conversations: summaries,
214
+ models: models(),
215
+ notices: [],
216
+ };
217
+ const listeners = new Set ();
218
+ let notifying = false;
219
+ // Commit listeners and Chord frames call this on the Session line; rendering runs afterwards, once per burst.
220
+ const update = (patch ) => {
221
+ state = { ...state, ...patch };
222
+ if (notifying) return;
223
+ notifying = true;
224
+ setImmediate(() => {
225
+ notifying = false;
226
+ for (const listener of listeners) listener();
227
+ });
228
+ };
229
+ let nextNotice = 1;
230
+ const notice = (level , message ) => {
231
+ update({ notices: [...state.notices, { id: nextNotice++, level, message }].slice(-20) });
232
+ };
233
+ const fail = (error ) => notice("error", error instanceof Error ? error.message : String(error));
234
+ report = (error) => notice("warning", error instanceof Error ? error.message : String(error));
235
+ for (const error of pendingReports) report(error);
236
+ let unsubscribe = conversation.subscribe((value) => update({ conversation: value }));
237
+ // Subagents appear as their conversations are created. A commit listener only records; it calls no Session API.
238
+ const unsubscribeCommits = harness.subscribeCommits((publication) => {
239
+ let conversations = state.conversations;
240
+ for (const change of publication.changes) {
241
+ if (change.type === "conversation") {
242
+ conversations = [...conversations, { id: change.value.id, label: label(change.value.id) }];
243
+ } else if (change.type === "entry" && change.value.kind === "pi.user") {
244
+ const id = change.value.conversationId;
245
+ conversations = conversations.map((summary) =>
246
+ summary.id === id && summary.title === undefined ? { ...summary, ...titleOf(change.value) } : summary,
247
+ );
248
+ }
249
+ }
250
+ if (conversations !== state.conversations) update({ conversations });
251
+ });
252
+
253
+ let tasks ;
254
+ let unsubscribeTasks = () => {};
255
+ const closeTasks = () => {
256
+ unsubscribeTasks();
257
+ tasks?.dispose();
258
+ tasks = undefined;
259
+ };
260
+
261
+ let queue = Promise.resolve();
262
+ // One at a time, so toggles, switches, and key presses apply in order.
263
+ const command = (operation ) => {
264
+ queue = queue.then(operation).catch(fail);
265
+ return queue;
266
+ };
267
+ const watchAnswer = (submission ) => {
268
+ void submission.wait(context).then((settled) => {
269
+ if (settled.status === "unanswered" && settled.reason !== "aborted") {
270
+ notice(
271
+ "error",
272
+ `No answer: ${settled.reason}${settled.detail === undefined ? "" : ` ${JSON.stringify(settled.detail)}`}`,
273
+ );
274
+ }
275
+ }, fail);
276
+ };
277
+ const agentModel = () => {
278
+ const ref = agentOf(state.conversation).model;
279
+ const model = ref === undefined ? undefined : modelRuntime.getModel(ref.provider, ref.modelId);
280
+ if (model === undefined)
281
+ throw new Error(ref === undefined ? "No model selected" : "Current model is unavailable");
282
+ return model;
283
+ };
284
+ const controller = {
285
+ submit: (text, whenBusy) =>
286
+ command(async () => watchAnswer(await current.submit({ type: "input", content: text, whenBusy }, context))),
287
+ compact: (instructions) =>
288
+ command(async () => {
289
+ const id = await current.compact(instructions, context);
290
+ // Report the outcome once it is known; the status line shows the compaction meanwhile.
291
+ void opened.waitForTask(id, context).then(async (receipt) => {
292
+ const outcome = receipt.state.outcome;
293
+ if (outcome.status === "completed") {
294
+ const { entryId, submissionId } = outcome.result;
295
+ // A summary written while busy is a submission: placed now, queued, or dropped as stale.
296
+ const status =
297
+ submissionId === undefined
298
+ ? undefined
299
+ : (await (await opened.submission(submissionId, context))?.status(context))?.status;
300
+ notice(
301
+ "info",
302
+ entryId !== undefined || status === "done"
303
+ ? "Compacted."
304
+ : status === "queued"
305
+ ? "Compaction summary queued; it is placed at the next turn boundary."
306
+ : status === "unanswered"
307
+ ? "Compaction summary dropped: the context changed under it."
308
+ : "Nothing to compact: the context fits in the recent window that is kept verbatim.",
309
+ );
310
+ } else if (outcome.status === "aborted") notice("info", "Compaction aborted.");
311
+ else
312
+ notice("error", `Compaction ${outcome.status}: ${outcome.error?.message ?? outcome.reason ?? ""}`);
313
+ }, fail);
314
+ }),
315
+ // Not queued: it waits until the conversation is idle.
316
+ abort: () => current.abort(context).catch(fail),
317
+ cycleThinking: () =>
318
+ command(async () => {
319
+ const model = agentModel();
320
+ if (!model.reasoning) throw new Error("Current model does not support thinking");
321
+ const levels = getSupportedThinkingLevels(model);
322
+ const level = agentOf(state.conversation).thinkingLevel ?? "off";
323
+ const next = levels[(levels.indexOf(level) + 1) % levels.length] ?? "off";
324
+ await current.configure({ thinkingLevel: next }, context);
325
+ }),
326
+ setModel: (ref) =>
327
+ command(async () => {
328
+ const model = modelRuntime.getModel(ref.provider, ref.modelId);
329
+ if (model === undefined) throw new Error(`Unknown model: ${ref.provider}/${ref.modelId}`);
330
+ const thinking = agentOf(state.conversation).thinkingLevel ?? "off";
331
+ await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
332
+ }),
333
+ toggleTasks: () =>
334
+ command(async () => {
335
+ if (tasks !== undefined) {
336
+ closeTasks();
337
+ update({ tasks: undefined });
338
+ return;
339
+ }
340
+ const graph = await opened.taskGraph(context);
341
+ tasks = graph;
342
+ unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
343
+ }),
344
+ switchConversation: (id) =>
345
+ command(async () => {
346
+ const next = await opened.conversation(id, context);
347
+ if (next === undefined) throw new Error(`Conversation ${id} does not exist`);
348
+ const nextState = await next.viewState(context);
349
+ unsubscribe();
350
+ conversation.dispose();
351
+ current = next;
352
+ conversation = nextState;
353
+ unsubscribe = nextState.subscribe((value) => update({ conversation: value }));
354
+ }),
355
+ };
356
+
357
+ const saved = agentOf(state.conversation).model;
358
+ if (saved === undefined) notice("warning", "No model configured; select one with /model.");
359
+ else if (modelRuntime.getModel(saved.provider, saved.modelId) === undefined) {
360
+ notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
361
+ }
362
+ if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
363
+ // The task panel starts open; /tasks hides it.
364
+ await controller.toggleTasks();
365
+ // Recovered work from an interrupted turn continues now.
366
+ harness.resume();
367
+
368
+ let closing ;
369
+ return {
370
+ view: {
371
+ current: () => state,
372
+ subscribe: (listener) => {
373
+ listeners.add(listener);
374
+ return () => listeners.delete(listener);
375
+ },
376
+ },
377
+ controller,
378
+ settings: settingsManager,
379
+ submitToRoot: (draft) => root.submit(draft, context),
380
+ close() {
381
+ closing ??= (async () => {
382
+ unsubscribe();
383
+ unsubscribeCommits();
384
+ conversation.dispose();
385
+ closeTasks();
386
+ try {
387
+ // Close writes no outcome: a running turn resumes with --continue.
388
+ await opened.close(context);
389
+ await envs.cleanup(context);
390
+ } finally {
391
+ await location.release();
392
+ }
393
+ })();
394
+ return closing;
395
+ },
396
+ };
397
+ } catch (error) {
398
+ await harness?.close(context).catch(() => {});
399
+ await location.release().catch(() => {});
400
+ throw error;
401
+ }
402
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, realpath } from "node:fs/promises";
3
+ import { basename, join, resolve } from "node:path";
4
+ import lockfile from "proper-lockfile";
5
+ import { getAgentDir } from "entwurf-pi-dist:config.js";
6
+
7
+ /** One session directory holding `session.sqlite`, locked by this process. */
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+ /** A new session for `cwd`, or its newest one with `continueSession`. */
18
+ export async function selectSession(cwdInput , continueSession ) {
19
+ const cwd = await realpath(resolve(cwdInput));
20
+ const root = join(
21
+ getAgentDir(),
22
+ "experimental",
23
+ "durable-sessions",
24
+ createHash("sha256").update(cwd).digest("hex").slice(0, 24),
25
+ );
26
+ await mkdir(root, { recursive: true });
27
+
28
+ let directory ;
29
+ let created = false;
30
+ if (continueSession) {
31
+ const entries = await readdir(root, { withFileTypes: true });
32
+ const newest = entries
33
+ .filter((entry) => entry.isDirectory() && /^\d{13}-[0-9a-f-]{36}$/u.test(entry.name))
34
+ .map((entry) => entry.name)
35
+ .sort()
36
+ .at(-1);
37
+ if (!newest) throw new Error(`No durable session exists for ${cwd}`);
38
+ directory = join(root, newest);
39
+ } else {
40
+ directory = join(root, `${String(Date.now()).padStart(13, "0")}-${randomUUID()}`);
41
+ await mkdir(directory);
42
+ created = true;
43
+ }
44
+
45
+ let release ;
46
+ try {
47
+ // A lock left by a crashed process goes stale after 10 s; wait that long before giving up.
48
+ release = await lockfile.lock(directory, {
49
+ realpath: false,
50
+ retries: { retries: 12, minTimeout: 1000, maxTimeout: 1000 },
51
+ });
52
+ } catch (error) {
53
+ throw new Error(`Session is already open in another process: ${directory}`, { cause: error });
54
+ }
55
+ return { id: basename(directory), directory, database: join(directory, "session.sqlite"), cwd, created, release };
56
+ }
@@ -0,0 +1,55 @@
1
+
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import {
4
+ AssistantEntry,
5
+ configure,
6
+ defineExtension,
7
+ defineTool,
8
+
9
+
10
+
11
+ } from "@earendil-works/pi-durable";
12
+
13
+ async function answerText(api , answer , context ) {
14
+ const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), context);
15
+ const message = entry?.model?.[0] ;
16
+ return message?.content.flatMap((content) => (content.type === "text" ? [content.text] : [])).join("") ?? "";
17
+ }
18
+
19
+ /**
20
+ * A foreground subagent: each call creates a child conversation owned by the call's task, runs the task there, and
21
+ * returns the child's answer. Aborting the call aborts the child. The child outlives the call, so the user can switch
22
+ * to it and keep talking.
23
+ */
24
+ export const Subagent = defineExtension({
25
+ name: "subagent",
26
+ tools: [
27
+ defineTool({
28
+ name: "subagent",
29
+ description:
30
+ "Delegate a self-contained task to a subagent with the same tools and get its answer back. Give it everything it needs to know; it does not see this conversation.",
31
+ parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),
32
+ // A rerun after a crash finds the child it created and the submission it made.
33
+ replay: "safe",
34
+ execute: async (args, api, context) => {
35
+ const child = await api.commit(async (tx) => {
36
+ const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
37
+ if (existing !== undefined) return existing.id;
38
+ // Starts as a copy of this conversation's agent; without this extension it cannot delegate further.
39
+ const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
40
+ await configure(tx, created.id, { extensions: { remove: [Subagent] } });
41
+ return created.id;
42
+ }, context);
43
+ await api.details({ conversationId: child }, context);
44
+ const handle = (await api.conversation(child, context)) ;
45
+ const request = { type: "input", content: args.task, requestId: `subagent:${api.taskId}` } ;
46
+ const settled = await (await handle.submit(request, context)).wait(context);
47
+ if (settled.status !== "done" || settled.type !== "input") {
48
+ throw new Error(`Subagent ${child} failed: ${settled.status}`);
49
+ }
50
+ const text = await answerText(api, settled.answer, context);
51
+ return { content: [{ type: "text", text }], details: { conversationId: child } };
52
+ },
53
+ }),
54
+ ],
55
+ });