@junghanacs/entwurf 0.33.0 → 0.34.1
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 +4 -4
- package/CHANGELOG.md +162 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -6
- package/VERIFY.md +20 -7
- package/docs/setup-clean-host.md +43 -4
- package/package.json +2 -2
- package/pi/pi-durable/bootstrap.mjs +111 -11
- package/pi/pi-durable/carrier/runtime.js +11 -14
- package/pi/pi-durable/carrier/tui.js +76 -11
- package/pi/pi-durable/overlay/runtime-contacts.patch +199 -7
- package/pi/pi-durable/overlay/upstream-pin.json +3 -3
- package/run.sh +42 -10
- package/scripts/check-gate-qualification.ts +62 -5
- package/scripts/check-herdr-activation.ts +130 -10
- package/scripts/check-herdr-plugin-build.ts +235 -6
- package/scripts/check-release-gate-outcomes.ts +1 -1
- package/scripts/emit-pi-durable-carrier.ts +61 -21
- package/scripts/herdr-activation.mjs +16 -0
- package/scripts/herdr-plugin-activate.mjs +112 -76
- package/scripts/herdr-plugin-deactivate.mjs +41 -2
- package/scripts/mutants/gate-qualification.json +24 -0
- package/scripts/mutants/herdr-activation.json +35 -0
- package/scripts/mutants/herdr-plugin-build.json +66 -0
- package/scripts/mutants/herdr-runtime-bootstrap.json +3 -3
- package/scripts/mutants/pi-durable.json +178 -11
|
@@ -50,18 +50,27 @@
|
|
|
50
50
|
// its initialization, and its default export is a native durable Extension, handed to `openDurable`
|
|
51
51
|
// by reference after the contact. Initialization runs before the TUI or the runtime is imported —
|
|
52
52
|
// the runtime creates the provider runtime before it installs any extension (carrier/runtime.js
|
|
53
|
-
//
|
|
53
|
+
// 142-148), so an extension object cannot carry an environment phase. Entwurf checks only what the
|
|
54
54
|
// ingress needs: a default export object with a name, no extension name the app installs itself (a
|
|
55
55
|
// same-name install REPLACES), and no tool name of the contact. Every other field is the native
|
|
56
56
|
// registry's to validate. The module is trusted operator code: the identity carriers and the
|
|
57
57
|
// process directory are compared across its initialization and a change is refused by name — a
|
|
58
|
-
// mistake guard, not a sandbox, and nothing is rolled back. Nothing is discovered: no
|
|
59
|
-
// setting, environment variable or profile names a module.
|
|
58
|
+
// mistake guard, not a sandbox, and nothing is rolled back. Nothing is discovered: no ambient or
|
|
59
|
+
// default directory, setting, environment variable or profile names a module.
|
|
60
|
+
//
|
|
61
|
+
// `--native-module-dir <absolute directory>` (#134) is the paired form, exclusive with the file
|
|
62
|
+
// ingress: the operator-named directory is read ONCE, top level only, and a name is a module exactly
|
|
63
|
+
// when it matches `*.extension.mjs` and does not start with a dot. Every other name (helpers, tests,
|
|
64
|
+
// subdirectories, directory symlinks) is ignored unopened; a matching name must resolve to a regular
|
|
65
|
+
// file. The modules initialize one by one in byte-wise name order, each under the same guards; two
|
|
66
|
+
// modules naming one extension or one tool are refused by name, because the native registry would
|
|
67
|
+
// REPLACE the first extension or keep both tools. A refusal at module k leaves modules before it
|
|
68
|
+
// evaluated: nothing is rolled back. No watch, no recursion, nothing persisted.
|
|
60
69
|
//
|
|
61
70
|
// Without that resolver the carrier's first pi-coding-agent internal import fails by name (an
|
|
62
71
|
// unsupported entwurf-pi-dist: URL scheme), and that failure is the honest one.
|
|
63
|
-
import { realpathSync, statSync } from "node:fs";
|
|
64
|
-
import { isAbsolute } from "node:path";
|
|
72
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
73
|
+
import { isAbsolute, join } from "node:path";
|
|
65
74
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
66
75
|
import { decodeOmpBootstrapPayload } from "../../mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js";
|
|
67
76
|
import {
|
|
@@ -83,7 +92,14 @@ export const PACKAGED_BRIDGE_ENTRY = fileURLToPath(
|
|
|
83
92
|
|
|
84
93
|
/** Flags that take a value. Anything else but `--continue`/`-c` is an unknown argument — a bridge
|
|
85
94
|
* entry included: the launch argv cannot point the contact at another bridge. */
|
|
86
|
-
const VALUE_FLAGS = [
|
|
95
|
+
const VALUE_FLAGS = [
|
|
96
|
+
"--provider",
|
|
97
|
+
"--model",
|
|
98
|
+
"--width",
|
|
99
|
+
"--entwurf-bootstrap",
|
|
100
|
+
"--native-module",
|
|
101
|
+
"--native-module-dir",
|
|
102
|
+
];
|
|
87
103
|
/** Flags only a NEW session can honour. */
|
|
88
104
|
const FRESH_ONLY_FLAGS = ["--provider", "--model", "--width", "--entwurf-bootstrap"];
|
|
89
105
|
|
|
@@ -93,6 +109,8 @@ const FRESH_ONLY_FLAGS = ["--provider", "--model", "--width", "--entwurf-bootstr
|
|
|
93
109
|
* carrier/subagent.js:25). The native registry REPLACES an installed extension of the same name.
|
|
94
110
|
*/
|
|
95
111
|
const RESERVED_EXTENSION_NAMES = [PI_DURABLE_EXTENSION_NAME, "coding-tools", "pi-prompt", "subagent"];
|
|
112
|
+
/** A directory entry that is an operator module: `*.extension.mjs`, not a dotfile. */
|
|
113
|
+
const MODULE_ENTRY = /^[^.].*\.extension\.mjs$/;
|
|
96
114
|
/** The identity carriers a module's initialization must leave as it found them, every ENTWURF_* included. */
|
|
97
115
|
const IDENTITY_KEYS = ["PI_SESSION_ID", "PI_CODING_AGENT_DIR", "HOME"];
|
|
98
116
|
|
|
@@ -131,6 +149,51 @@ function nativeModuleFile(value) {
|
|
|
131
149
|
return file;
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
/**
|
|
153
|
+
* The operator's module directory: absolute, resolving to a directory, read once at the top level. The
|
|
154
|
+
* matching entries in byte-wise name order, each resolved to a regular file and none twice. Decided
|
|
155
|
+
* before anything loads.
|
|
156
|
+
*/
|
|
157
|
+
function nativeModuleDirFiles(named) {
|
|
158
|
+
if (!isAbsolute(named))
|
|
159
|
+
throw refuse("native-module-path-not-absolute", `--native-module-dir takes an absolute path, got ${named}`);
|
|
160
|
+
let dir;
|
|
161
|
+
try {
|
|
162
|
+
dir = realpathSync(named);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw Object.assign(refuse("native-module-path-invalid", `${named} does not resolve`), { cause: error });
|
|
165
|
+
}
|
|
166
|
+
if (!statSync(dir).isDirectory()) throw refuse("native-module-path-invalid", `${named} is not a directory`);
|
|
167
|
+
const names = readdirSync(dir)
|
|
168
|
+
.filter((name) => MODULE_ENTRY.test(name))
|
|
169
|
+
.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
170
|
+
if (names.length === 0) {
|
|
171
|
+
throw refuse("native-module-dir-empty", `${named} holds no *.extension.mjs module at its top level`);
|
|
172
|
+
}
|
|
173
|
+
const seen = new Map();
|
|
174
|
+
return names.map((name) => {
|
|
175
|
+
let file;
|
|
176
|
+
try {
|
|
177
|
+
file = realpathSync(join(dir, name));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw Object.assign(refuse("native-module-path-invalid", `${name} in ${named} does not resolve`), {
|
|
180
|
+
cause: error,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!statSync(file).isFile()) {
|
|
184
|
+
throw refuse(
|
|
185
|
+
"native-module-path-invalid",
|
|
186
|
+
`${name} in ${named} matches *.extension.mjs but is not a regular file`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (seen.has(file)) {
|
|
190
|
+
throw refuse("native-module-dir-duplicate-target", `${seen.get(file)} and ${name} in ${named} are the same file`);
|
|
191
|
+
}
|
|
192
|
+
seen.set(file, name);
|
|
193
|
+
return file;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
134
197
|
/** The module's default export, checked only for what the ingress needs and returned BY REFERENCE. */
|
|
135
198
|
function nativeExtensionOf(extension, file) {
|
|
136
199
|
if (typeof extension === "function") {
|
|
@@ -187,6 +250,31 @@ async function loadNativeModule(file) {
|
|
|
187
250
|
return nativeExtensionOf(namespace.default, file);
|
|
188
251
|
}
|
|
189
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Initialize the operator's modules in order. A later module that names an extension or a tool an
|
|
255
|
+
* earlier one already did is refused by name: the native registry would replace that extension, or keep
|
|
256
|
+
* both tools, without a word.
|
|
257
|
+
*/
|
|
258
|
+
async function loadNativeModules(files) {
|
|
259
|
+
const extensions = [];
|
|
260
|
+
const owners = new Map();
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
const extension = await loadNativeModule(file);
|
|
263
|
+
const names = [
|
|
264
|
+
`extension ${extension.name}`,
|
|
265
|
+
...(Array.isArray(extension.tools) ? extension.tools : []).map((tool) => `tool ${tool?.name}`),
|
|
266
|
+
];
|
|
267
|
+
for (const name of names) {
|
|
268
|
+
if (owners.has(name)) {
|
|
269
|
+
throw refuse("native-module-name-duplicate", `${file} and ${owners.get(name)} both name ${name}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
for (const name of names) owners.set(name, file);
|
|
273
|
+
extensions.push(extension);
|
|
274
|
+
}
|
|
275
|
+
return extensions;
|
|
276
|
+
}
|
|
277
|
+
|
|
190
278
|
/**
|
|
191
279
|
* Open the durable app with the contact installed, then birth the citizen and arm its doorbell on
|
|
192
280
|
* the root conversation, then admit the first input (when given) to the root once. On any failure
|
|
@@ -198,8 +286,8 @@ async function loadNativeModule(file) {
|
|
|
198
286
|
* roots; tests also pass a connector). `open` replaces the runtime's `openDurable` for tests.
|
|
199
287
|
* `bridgeEntry` defaults to this package's own compiled bridge; only a programmatic caller (the
|
|
200
288
|
* checkout-only gates, which drive a private bundle) passes another one — never the launch argv.
|
|
201
|
-
* `extensions` are the operator's native extensions (`--native-module`),
|
|
202
|
-
* contact, by reference.
|
|
289
|
+
* `extensions` are the operator's native extensions (`--native-module` or `--native-module-dir`),
|
|
290
|
+
* installed after the contact, by reference.
|
|
203
291
|
*
|
|
204
292
|
* @param {{
|
|
205
293
|
* bridgeEntry?: string,
|
|
@@ -320,7 +408,19 @@ function parseArgs(argv) {
|
|
|
320
408
|
);
|
|
321
409
|
}
|
|
322
410
|
const moduleArg = values.get("--native-module");
|
|
323
|
-
const
|
|
411
|
+
const dirArg = values.get("--native-module-dir");
|
|
412
|
+
if (moduleArg !== undefined && dirArg !== undefined) {
|
|
413
|
+
throw refuse(
|
|
414
|
+
"native-module-ingress-exclusive",
|
|
415
|
+
"--native-module and --native-module-dir are two forms of one ingress; name the file or the directory",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
const nativeModule =
|
|
419
|
+
moduleArg !== undefined
|
|
420
|
+
? { nativeModules: [nativeModuleFile(moduleArg)] }
|
|
421
|
+
: dirArg !== undefined
|
|
422
|
+
? { nativeModules: nativeModuleDirFiles(dirArg) }
|
|
423
|
+
: {};
|
|
324
424
|
if (continueSession) return { continueSession, ...nativeModule };
|
|
325
425
|
const provider = values.get("--provider");
|
|
326
426
|
if (provider === undefined) {
|
|
@@ -368,10 +468,10 @@ function parseArgs(argv) {
|
|
|
368
468
|
*/
|
|
369
469
|
export async function main(argv, { loadTui = () => import(TUI), open, contact, bridgeEntry } = {}) {
|
|
370
470
|
// Every argument refusal is decided here, before any module, the TUI or the app loads.
|
|
371
|
-
const {
|
|
471
|
+
const { nativeModules = [], ...args } = parseArgs(argv);
|
|
372
472
|
// The caller's directory, fixed before any operator code runs and handed to the app explicitly.
|
|
373
473
|
const cwd = process.cwd();
|
|
374
|
-
const extensions =
|
|
474
|
+
const extensions = await loadNativeModules(nativeModules);
|
|
375
475
|
// Loaded before anything opens, as upstream main.ts imports it statically: a TUI that cannot
|
|
376
476
|
// load opens no session, mints no citizen and leaves nothing to clean up.
|
|
377
477
|
const { runDurableTui } = await loadTui();
|
|
@@ -58,8 +58,10 @@ const context = BACKGROUND_CONTEXT;
|
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
|
|
62
62
|
|
|
63
|
+
|
|
64
|
+
|
|
63
65
|
|
|
64
66
|
|
|
65
67
|
|
|
@@ -213,6 +215,7 @@ export async function openDurable(options = {})
|
|
|
213
215
|
conversations: summaries,
|
|
214
216
|
models: models(),
|
|
215
217
|
notices: [],
|
|
218
|
+
tasksPanel: true,
|
|
216
219
|
};
|
|
217
220
|
const listeners = new Set ();
|
|
218
221
|
let notifying = false;
|
|
@@ -330,17 +333,8 @@ export async function openDurable(options = {})
|
|
|
330
333
|
const thinking = agentOf(state.conversation).thinkingLevel ?? "off";
|
|
331
334
|
await current.configure({ model: ref, thinkingLevel: clampThinkingLevel(model, thinking) }, context);
|
|
332
335
|
}),
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
}),
|
|
336
|
+
// Only the panel: the graph stays observed, so live background work is never out of view.
|
|
337
|
+
toggleTasks: () => command(async () => update({ tasksPanel: !state.tasksPanel })),
|
|
344
338
|
switchConversation: (id) =>
|
|
345
339
|
command(async () => {
|
|
346
340
|
const next = await opened.conversation(id, context);
|
|
@@ -360,8 +354,11 @@ export async function openDurable(options = {})
|
|
|
360
354
|
notice("warning", `Saved model is unavailable: ${saved.provider}/${saved.modelId}`);
|
|
361
355
|
}
|
|
362
356
|
if (initial?.fallbackMessage !== undefined) notice("info", initial.fallbackMessage);
|
|
363
|
-
// The task panel starts open
|
|
364
|
-
await
|
|
357
|
+
// The task graph is observed until close; its panel starts open and /tasks hides it.
|
|
358
|
+
const graph = await opened.taskGraph(context);
|
|
359
|
+
tasks = graph;
|
|
360
|
+
update({ tasks: graph.value });
|
|
361
|
+
unsubscribeTasks = graph.subscribe((value) => update({ tasks: value }));
|
|
365
362
|
// Recovered work from an interrupted turn continues now.
|
|
366
363
|
harness.resume();
|
|
367
364
|
|
|
@@ -23,12 +23,14 @@ import {
|
|
|
23
23
|
SelectList,
|
|
24
24
|
|
|
25
25
|
Spacer,
|
|
26
|
+
|
|
26
27
|
setCapabilityOverrides,
|
|
27
28
|
setKeybindings,
|
|
28
29
|
Text,
|
|
29
30
|
TruncatedText,
|
|
30
31
|
TuiAltScreen,
|
|
31
32
|
VStack,
|
|
33
|
+
visibleWidth,
|
|
32
34
|
} from "@earendil-works/pi-tui";
|
|
33
35
|
import { getAgentDir } from "entwurf-pi-dist:config.js";
|
|
34
36
|
import { KeybindingsManager } from "entwurf-pi-dist:core/keybindings.js";
|
|
@@ -150,10 +152,75 @@ class CompactionComponent extends Box {
|
|
|
150
152
|
|
|
151
153
|
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The live background-task badge: the dock's first row, never shrunk, so live background work stays on
|
|
157
|
+
* screen however small the terminal gets. It counts the graph's background tasks whatever their kind and
|
|
158
|
+
* names the ones that are not running; with none it renders nothing and the dock is upstream's.
|
|
159
|
+
*/
|
|
160
|
+
export class TaskBadge {
|
|
161
|
+
#count = 0;
|
|
162
|
+
#detail = "";
|
|
163
|
+
|
|
164
|
+
get count() {
|
|
165
|
+
return this.#count;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
set(graph ) {
|
|
169
|
+
const nodes = Object.values(graph?.tasks ?? {}).filter((node) => node.background);
|
|
170
|
+
const tally = (state , match ) => {
|
|
171
|
+
const n = nodes.filter(match).length;
|
|
172
|
+
return n === 0 ? [] : [`${n} ${state}`];
|
|
173
|
+
};
|
|
174
|
+
this.#count = nodes.length;
|
|
175
|
+
this.#detail = [
|
|
176
|
+
...tally("aborting", (node) => node.abortRequested),
|
|
177
|
+
...tally("completing", (node) => !node.abortRequested && node.state.status === "completing"),
|
|
178
|
+
...tally("waiting", (node) => !node.abortRequested && node.state.status === "waiting"),
|
|
179
|
+
...tally("pending", (node) => !node.abortRequested && node.state.status === "pending"),
|
|
180
|
+
].join(", ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The widest form that fits, padded when there is room; narrower terminals drop detail, then words. */
|
|
184
|
+
render(width ) {
|
|
185
|
+
const n = this.#count;
|
|
186
|
+
if (n === 0) return [];
|
|
187
|
+
const tasks = `⏳ ${n} ${n === 1 ? "task" : "tasks"}`;
|
|
188
|
+
const detailed = this.#detail ? [`${tasks} (${this.#detail})`] : [];
|
|
189
|
+
const forms = [...detailed, tasks, `⏳ ${n}`, `⏳${n}`, `*${n}`, "*+", "*"];
|
|
190
|
+
for (const form of forms) {
|
|
191
|
+
if (visibleWidth(form) < width) return [theme.fg("accent", ` ${form}`)];
|
|
192
|
+
if (visibleWidth(form) === width) return [theme.fg("accent", form)];
|
|
193
|
+
}
|
|
194
|
+
return [theme.fg("accent", "*")];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
invalidate() {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The screen: the transcript above the dock. The badge is the dock's first row and never shrinks, so the
|
|
202
|
+
* renderer, which clips from the bottom, drops the editor and the footer before it; a one-row terminal
|
|
203
|
+
* with live background work shows the badge instead of the transcript.
|
|
204
|
+
*/
|
|
205
|
+
export function durableLayout(transcript , badge , dock ) {
|
|
206
|
+
return new VStack([
|
|
207
|
+
{
|
|
208
|
+
component: transcript,
|
|
209
|
+
basis: 0,
|
|
210
|
+
grow: 1,
|
|
211
|
+
shrink: 1,
|
|
212
|
+
minSize: 1,
|
|
213
|
+
visible: (viewport) => badge.count === 0 || viewport.height > 1,
|
|
214
|
+
},
|
|
215
|
+
{ component: new VStack([{ component: badge, shrink: 0 }, ...dock]), basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
|
216
|
+
]);
|
|
217
|
+
}
|
|
218
|
+
|
|
153
219
|
class DurableTui {
|
|
154
220
|
static #renderers = createAllToolRenderers();
|
|
155
221
|
#ui ;
|
|
156
222
|
#chat = new Container();
|
|
223
|
+
#taskBadge = new TaskBadge();
|
|
157
224
|
#tasks = new Container();
|
|
158
225
|
#queue = new Container();
|
|
159
226
|
#notices = new Container();
|
|
@@ -216,15 +283,9 @@ class DurableTui {
|
|
|
216
283
|
content.addChild(new Spacer(1));
|
|
217
284
|
const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
|
|
218
285
|
this.#transcript = transcript;
|
|
219
|
-
const dock = new VStack([
|
|
220
|
-
{ component: this.#tasks, shrink: 1, minSize: 0 },
|
|
221
|
-
{ component: this.#queue, shrink: 1, minSize: 0 },
|
|
222
|
-
{ component: this.#notices, shrink: 1, minSize: 0 },
|
|
223
|
-
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
|
|
224
|
-
{ component: this.#footer, shrink: 1, minSize: 0 },
|
|
225
|
-
]);
|
|
226
286
|
for (const component of [
|
|
227
287
|
this.#chat,
|
|
288
|
+
this.#taskBadge,
|
|
228
289
|
this.#tasks,
|
|
229
290
|
this.#queue,
|
|
230
291
|
this.#notices,
|
|
@@ -234,9 +295,12 @@ class DurableTui {
|
|
|
234
295
|
this.#ui.addChild(component);
|
|
235
296
|
}
|
|
236
297
|
this.#ui.setLayoutRoot(
|
|
237
|
-
|
|
238
|
-
{ component:
|
|
239
|
-
{ component:
|
|
298
|
+
durableLayout(transcript, this.#taskBadge, [
|
|
299
|
+
{ component: this.#tasks, shrink: 1, minSize: 0 },
|
|
300
|
+
{ component: this.#queue, shrink: 1, minSize: 0 },
|
|
301
|
+
{ component: this.#notices, shrink: 1, minSize: 0 },
|
|
302
|
+
{ component: this.#editorContainer, shrink: 1, minSize: 3 },
|
|
303
|
+
{ component: this.#footer, shrink: 1, minSize: 0 },
|
|
240
304
|
]),
|
|
241
305
|
);
|
|
242
306
|
this.#ui.setFocus(this.#editor);
|
|
@@ -299,7 +363,8 @@ class DurableTui {
|
|
|
299
363
|
);
|
|
300
364
|
}
|
|
301
365
|
}
|
|
302
|
-
this.#
|
|
366
|
+
this.#taskBadge.set(view.tasks);
|
|
367
|
+
this.#syncTasks(view.tasksPanel ? view.tasks : undefined);
|
|
303
368
|
this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) );
|
|
304
369
|
this.#syncNotices(view);
|
|
305
370
|
this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
diff --git a/packages/coding-agent/src/experimental/durable/runtime.ts b/packages/coding-agent/src/experimental/durable/runtime.ts
|
|
2
|
-
index fcd6e523..
|
|
2
|
+
index fcd6e523..7f00e27f 100644
|
|
3
3
|
--- a/packages/coding-agent/src/experimental/durable/runtime.ts
|
|
4
4
|
+++ b/packages/coding-agent/src/experimental/durable/runtime.ts
|
|
5
5
|
@@ -8,7 +8,9 @@ import {
|
|
@@ -12,7 +12,19 @@ index fcd6e523..929dbc79 100644
|
|
|
12
12
|
type ModelRef,
|
|
13
13
|
ROOT_CONVERSATION_ID,
|
|
14
14
|
type Submission,
|
|
15
|
-
@@ -
|
|
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 {
|
|
16
28
|
export interface OpenDurableOptions {
|
|
17
29
|
readonly cwd?: string;
|
|
18
30
|
readonly continueSession?: boolean;
|
|
@@ -23,7 +35,7 @@ index fcd6e523..929dbc79 100644
|
|
|
23
35
|
}
|
|
24
36
|
|
|
25
37
|
export interface OpenDurableResult {
|
|
26
|
-
@@ -88,6 +
|
|
38
|
+
@@ -88,6 +96,8 @@ export interface OpenDurableResult {
|
|
27
39
|
readonly controller: DurableController;
|
|
28
40
|
/** pi's settings, for the TUI's theme and terminal capabilities. */
|
|
29
41
|
readonly settings: SettingsManager;
|
|
@@ -32,7 +44,7 @@ index fcd6e523..929dbc79 100644
|
|
|
32
44
|
close(): Promise<void>;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
@@ -122,6 +
|
|
47
|
+
@@ -122,6 +132,9 @@ function titleOf(entry: EntryRecord | undefined): { title?: string } {
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export async function openDurable(options: OpenDurableOptions = {}): Promise<OpenDurableResult> {
|
|
@@ -42,7 +54,7 @@ index fcd6e523..929dbc79 100644
|
|
|
42
54
|
const location = await selectSession(options.cwd ?? process.cwd(), options.continueSession ?? false);
|
|
43
55
|
const envs = new ExecutionEnvs(location.cwd);
|
|
44
56
|
let harness: Harness | undefined;
|
|
45
|
-
@@ -132,6 +
|
|
57
|
+
@@ -132,6 +145,7 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
|
|
46
58
|
const settings = createHarnessSettings(settingsManager);
|
|
47
59
|
const registry = createCodingRegistry(settingsManager, location.cwd);
|
|
48
60
|
registry.install(Subagent);
|
|
@@ -50,7 +62,7 @@ index fcd6e523..929dbc79 100644
|
|
|
50
62
|
|
|
51
63
|
const pendingReports: unknown[] = [];
|
|
52
64
|
let report: (error: unknown) => void = (error) => pendingReports.push(error);
|
|
53
|
-
@@ -146,7 +
|
|
65
|
+
@@ -146,7 +160,29 @@ export async function openDurable(options: OpenDurableOptions = {}): Promise<Ope
|
|
54
66
|
},
|
|
55
67
|
context,
|
|
56
68
|
);
|
|
@@ -81,7 +93,49 @@ index fcd6e523..929dbc79 100644
|
|
|
81
93
|
const root = await harness.root(context, {
|
|
82
94
|
agent: {
|
|
83
95
|
cwd: location.cwd,
|
|
84
|
-
@@ -
|
|
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
|
|
85
139
|
},
|
|
86
140
|
controller,
|
|
87
141
|
settings: settingsManager,
|
|
@@ -89,3 +143,141 @@ index fcd6e523..929dbc79 100644
|
|
|
89
143
|
close() {
|
|
90
144
|
closing ??= (async () => {
|
|
91
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");
|
|
@@ -23,14 +23,14 @@
|
|
|
23
23
|
"source": "packages/coding-agent/src/experimental/durable/runtime.ts",
|
|
24
24
|
"patched": true,
|
|
25
25
|
"blob": "fcd6e5239e999ebf17dea0335fb3dc9cd7f7ce45",
|
|
26
|
-
"sha256": "
|
|
26
|
+
"sha256": "bfd88844fae61e8bd32d57b5b986f48056c64eebb5466d9826318a0783ac9005"
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
"name": "tui.js",
|
|
30
30
|
"source": "packages/coding-agent/src/experimental/durable/tui.ts",
|
|
31
|
-
"patched":
|
|
31
|
+
"patched": true,
|
|
32
32
|
"blob": "397b276cbeb1c90e1fbc96c3cc59e6a25f92dad7",
|
|
33
|
-
"sha256": "
|
|
33
|
+
"sha256": "f61cc68e5d32552a8196785093ae9e2f4a946088fe71582bb05690517db8c5b4"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
36
|
"name": "harness-setup.js",
|