pi-squire 0.14.0 → 0.16.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/CHANGELOG.md +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
package/src/results.ts
CHANGED
|
@@ -2,21 +2,27 @@
|
|
|
2
2
|
// the parent session when it starts, and again when it is closed; when the
|
|
3
3
|
// provider claims a reply that answers a recorded task, this listener takes
|
|
4
4
|
// the message over, shows the parent one delegate result and records the
|
|
5
|
-
// result.
|
|
6
|
-
//
|
|
5
|
+
// result. Local records own result processing; the shared tree registry owns
|
|
6
|
+
// cross-process admission accounting.
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
|
|
10
|
+
import { ChildTasks } from "./child-tasks.ts";
|
|
11
|
+
import type { Identity } from "./identity.ts";
|
|
10
12
|
import { cwdLine, modelLabel, renderMessage, workspaceLine } from "./render.ts";
|
|
13
|
+
import { closeOutcome } from "./result-close.ts";
|
|
11
14
|
import { resultDetails, resultText, resumeDetails } from "./result-message.ts";
|
|
15
|
+
import { restoreRecords } from "./result-restore.ts";
|
|
12
16
|
import { finishedModel, locateSession, sessionIdOf } from "./result-sessions.ts";
|
|
13
17
|
import {
|
|
14
18
|
type AttemptState,
|
|
15
19
|
isStarted,
|
|
16
20
|
type Recorded,
|
|
21
|
+
recordedName,
|
|
17
22
|
type StartedRecorded,
|
|
18
23
|
type WorkspaceAttempt,
|
|
19
24
|
} from "./result-types.ts";
|
|
25
|
+
import { ResultsPresentation } from "./results-presentation.ts";
|
|
20
26
|
import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
|
|
21
27
|
import type { Workspace } from "./workspace.ts";
|
|
22
28
|
|
|
@@ -33,8 +39,14 @@ const INBOUND = "message:inbound";
|
|
|
33
39
|
|
|
34
40
|
/** One delegation, as recorded and rebuilt on resume. It holds no task text. */
|
|
35
41
|
export type Delegation = {
|
|
42
|
+
/** Read-only registry projection with no authoritative launch/result record. Never local tracking. */
|
|
43
|
+
accountingOnly?: boolean;
|
|
44
|
+
/** Durable launch identity; older single-level records may lack it. */
|
|
45
|
+
identity?: Identity;
|
|
36
46
|
id: string;
|
|
37
47
|
agent: string;
|
|
48
|
+
/** The agent's `display_name` at launch, shown instead of `agent`; absent when it had none. */
|
|
49
|
+
displayName?: string;
|
|
38
50
|
model: string;
|
|
39
51
|
thinking: string;
|
|
40
52
|
/** The delegate's session and window name; absent in records from before labels, where it was `<agent>-<8 id characters>`. */
|
|
@@ -127,7 +139,7 @@ export type MessageDetails =
|
|
|
127
139
|
/** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
|
|
128
140
|
workspaceSnapshot?: WorkspaceSnapshot;
|
|
129
141
|
})
|
|
130
|
-
| (CommonDetails & { kind: "gone"; windowName: string });
|
|
142
|
+
| (CommonDetails & { kind: "gone"; windowName: string; reason?: "explicit-close" });
|
|
131
143
|
|
|
132
144
|
/** The provider's payload on `message:inbound`. */
|
|
133
145
|
export type Inbound = {
|
|
@@ -138,6 +150,10 @@ export type Inbound = {
|
|
|
138
150
|
};
|
|
139
151
|
|
|
140
152
|
export type Results = {
|
|
153
|
+
/** Presentation-only observers; never part of task accounting or the completion barrier. */
|
|
154
|
+
subscribe(listener: () => void): () => void;
|
|
155
|
+
/** Keep the legacy count for non-TUI/cap-zero views without replacing any footer. */
|
|
156
|
+
setCountVisible(visible: boolean): void;
|
|
141
157
|
/** Rebuild the records from the session's custom entries, as on a resume. */
|
|
142
158
|
restore(ctx: ExtensionContext): void;
|
|
143
159
|
/** Record a delegation and show it as running; it supersedes an attempt with the same id. */
|
|
@@ -146,6 +162,8 @@ export type Results = {
|
|
|
146
162
|
recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
|
|
147
163
|
/** Record a close; does nothing when there is no such delegation or it is already closed. */
|
|
148
164
|
recordClose(id: string): void;
|
|
165
|
+
/** Import only confirmed own direct-child closes; no foreign semantic writes. */
|
|
166
|
+
syncCloses(): void;
|
|
149
167
|
/** Delegations with no result, no recorded close and no gone-window record. */
|
|
150
168
|
running(): StartedRecorded[];
|
|
151
169
|
/** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
|
|
@@ -154,6 +172,10 @@ export type Results = {
|
|
|
154
172
|
list(): Recorded[];
|
|
155
173
|
/** One delegation by id. */
|
|
156
174
|
find(id: string): Recorded | undefined;
|
|
175
|
+
/** No unfinished direct-child task or unprocessed terminal outcome. */
|
|
176
|
+
completionReady(): boolean;
|
|
177
|
+
/** Refuse unhealthy local facts; optionally persist a preflight snapshot before launch effects. */
|
|
178
|
+
checkStorage(write?: boolean): void;
|
|
157
179
|
};
|
|
158
180
|
|
|
159
181
|
/** What tmux reported for a delegation's window: listed, not listed, or not askable. */
|
|
@@ -173,46 +195,47 @@ export type DelegationState =
|
|
|
173
195
|
| "unknown"
|
|
174
196
|
| AttemptState["phase"];
|
|
175
197
|
|
|
176
|
-
|
|
177
|
-
const SHORT_ID = 8;
|
|
198
|
+
export { delegateName, deriveState, recordedName } from "./result-types.ts";
|
|
178
199
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
/** The recorded name, or the pre-label name for records from before labels. */
|
|
188
|
-
export function recordedName(record: Recorded): string {
|
|
189
|
-
return record.name ?? delegateName(record.agent, record.id);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
|
|
193
|
-
export function deriveState(record: Recorded, window: WindowState): DelegationState {
|
|
194
|
-
if (!isStarted(record)) return record.attempt.phase;
|
|
195
|
-
if (record.closed === true) return "closed";
|
|
196
|
-
if (record.result !== undefined) return "done";
|
|
197
|
-
if (record.gone === true) return "gone";
|
|
198
|
-
if (window === "open") return "running";
|
|
199
|
-
if (window === "gone") return "window-closed";
|
|
200
|
-
return "unknown";
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function createResults(pi: ExtensionAPI): Results {
|
|
200
|
+
export function createResults(
|
|
201
|
+
pi: ExtensionAPI,
|
|
202
|
+
processesChildren: () => boolean = () => false,
|
|
203
|
+
publish: (ctx: ExtensionContext, records: Recorded[]) => void = () => {},
|
|
204
|
+
active: () => boolean = () => true,
|
|
205
|
+
closedChildren: (ctx: ExtensionContext) => string[] = () => [],
|
|
206
|
+
): Results {
|
|
204
207
|
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
205
208
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
206
209
|
const byId = new Map<string, Recorded>();
|
|
210
|
+
const tasks = new ChildTasks();
|
|
211
|
+
const save = (): void => {
|
|
212
|
+
try {
|
|
213
|
+
tasks.save([...byId.values()]);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
tasks.failed = true;
|
|
216
|
+
ctx?.ui.notify(
|
|
217
|
+
`delegate: could not persist child tasks: ${String(error)}; keep this session open, repair storage and run again. Completion is blocked.`,
|
|
218
|
+
"error",
|
|
219
|
+
);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
if (ctx) publish(ctx, [...byId.values()]);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
ctx?.ui.notify(
|
|
226
|
+
`delegate: could not publish shared accounting: ${String(error)}. Local child facts are saved; the next admission reconciles them before checking caps.`,
|
|
227
|
+
"warning",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
let replay: ReturnType<typeof setImmediate> | undefined;
|
|
207
232
|
/** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
|
|
208
233
|
const byRequest = new Map<string, StartedRecorded>();
|
|
209
234
|
/** Reply envelope ids a result message has already been sent for. */
|
|
210
235
|
const shown = new Set<string>();
|
|
211
236
|
/** The latest session context, for the footer and the session directory. */
|
|
212
237
|
let ctx: ExtensionContext | undefined;
|
|
213
|
-
|
|
214
|
-
let footerShown = false;
|
|
215
|
-
const icon = Math.random() < 0.5 ? "👨" : "👩";
|
|
238
|
+
const presentation = new ResultsPresentation();
|
|
216
239
|
|
|
217
240
|
/**
|
|
218
241
|
* A delegation still going: no result, no recorded close, and not resumed.
|
|
@@ -240,64 +263,41 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
240
263
|
|
|
241
264
|
/** The footer counts the running delegations, and hides itself at zero. */
|
|
242
265
|
function updateFooter(): void {
|
|
243
|
-
|
|
244
|
-
const count = running().length;
|
|
245
|
-
const visible = count > 0;
|
|
246
|
-
if (!visible && !footerShown) return;
|
|
247
|
-
footerShown = visible;
|
|
248
|
-
ctx.ui.setStatus(STATUS_KEY, visible ? `${icon} ${count} delegate${count === 1 ? "" : "s"} running` : undefined);
|
|
266
|
+
presentation.update(ctx, running().length);
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
/** Rebuild the records from the session's entries, so a resume still recognises replies. */
|
|
252
270
|
function restore(context: ExtensionContext): void {
|
|
271
|
+
if (replay) clearImmediate(replay);
|
|
253
272
|
ctx = context;
|
|
254
273
|
byId.clear();
|
|
255
274
|
byRequest.clear();
|
|
256
275
|
shown.clear();
|
|
257
|
-
for (const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
| Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
|
|
261
|
-
| undefined;
|
|
262
|
-
if (data === undefined || typeof data.id !== "string") continue;
|
|
263
|
-
// An attempt may carry a real request id; it is checked before the legacy start rule below.
|
|
264
|
-
if (data.kind === "workspace-attempt") {
|
|
265
|
-
const existing = byId.get(data.id);
|
|
266
|
-
if (existing === undefined || !isStarted(existing)) {
|
|
267
|
-
byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
|
|
268
|
-
}
|
|
269
|
-
} else if (typeof data.requestId === "string") {
|
|
270
|
-
const record: StartedRecorded = { ...(data as Delegation) };
|
|
271
|
-
byId.set(record.id, record);
|
|
276
|
+
for (const record of restoreRecords(context, tasks)) {
|
|
277
|
+
byId.set(record.id, record);
|
|
278
|
+
if (isStarted(record)) {
|
|
272
279
|
byRequest.set(record.requestId, record);
|
|
273
|
-
|
|
274
|
-
shown.add(data.result.replyId);
|
|
275
|
-
const record = startedById(data.id);
|
|
276
|
-
if (record !== undefined) record.result = data.result;
|
|
277
|
-
} else if (data.closed === true) {
|
|
278
|
-
const record = startedById(data.id);
|
|
279
|
-
if (record !== undefined) record.closed = true;
|
|
280
|
-
} else if (data.gone === true) {
|
|
281
|
-
const record = startedById(data.id);
|
|
282
|
-
if (record === undefined) continue;
|
|
283
|
-
record.gone = true;
|
|
284
|
-
if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
// A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
|
|
288
|
-
for (const record of byId.values()) {
|
|
289
|
-
if (isStarted(record)) continue;
|
|
290
|
-
const { phase, resources } = record.attempt;
|
|
291
|
-
if (phase === "planned" || phase === "created") {
|
|
292
|
-
record.attempt = {
|
|
293
|
-
phase: "interrupted",
|
|
294
|
-
resources,
|
|
295
|
-
lastPhase: phase,
|
|
296
|
-
reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
|
|
297
|
-
};
|
|
280
|
+
if (record.result) shown.add(record.result.replyId);
|
|
298
281
|
}
|
|
299
282
|
}
|
|
283
|
+
if (tasks.storageProblem())
|
|
284
|
+
context.ui.notify(
|
|
285
|
+
`delegate: could not restore child tasks: ${tasks.storageProblem()}; task completion is blocked.`,
|
|
286
|
+
"error",
|
|
287
|
+
);
|
|
288
|
+
syncCloses(false);
|
|
300
289
|
updateFooter();
|
|
290
|
+
// Mail is already claimed; replay only outcomes not processed by a
|
|
291
|
+
// settled coordinator run. No live worker is recreated.
|
|
292
|
+
if (!tasks.failed && tasks.outcomes.length) {
|
|
293
|
+
const session = context.sessionManager.getSessionId();
|
|
294
|
+
replay = setImmediate(() => {
|
|
295
|
+
replay = undefined;
|
|
296
|
+
if (!active() || ctx?.sessionManager.getSessionId() !== session) return;
|
|
297
|
+
tasks.replay(pi, processesChildren());
|
|
298
|
+
save();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
303
|
function recordStart(delegation: Delegation, context: ExtensionContext): void {
|
|
@@ -306,6 +306,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
306
306
|
const record: StartedRecorded = { ...delegation };
|
|
307
307
|
byId.set(record.id, record);
|
|
308
308
|
byRequest.set(record.requestId, record);
|
|
309
|
+
save();
|
|
309
310
|
updateFooter();
|
|
310
311
|
}
|
|
311
312
|
|
|
@@ -313,16 +314,43 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
313
314
|
ctx = context;
|
|
314
315
|
pi.appendEntry(CUSTOM_TYPE, attempt);
|
|
315
316
|
byId.set(attempt.id, { ...attempt });
|
|
317
|
+
save();
|
|
316
318
|
}
|
|
317
319
|
|
|
318
|
-
function recordClose(id: string): void {
|
|
320
|
+
function recordClose(id: string, wake = true): void {
|
|
319
321
|
const record = startedById(id);
|
|
320
322
|
if (record === undefined || record.closed === true) return;
|
|
321
|
-
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
322
323
|
record.closed = true;
|
|
324
|
+
const outcome = record.result || record.gone ? undefined : closeOutcome(record, ctx);
|
|
325
|
+
if (outcome) {
|
|
326
|
+
tasks.outcomes.push(outcome);
|
|
327
|
+
if (wake) tasks.paused = false;
|
|
328
|
+
}
|
|
329
|
+
save(); // Close and its processing obligation persist together, before transcript/display effects.
|
|
330
|
+
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
331
|
+
if (outcome && wake) {
|
|
332
|
+
pi.sendMessage(
|
|
333
|
+
{ customType: CUSTOM_TYPE, content: outcome.content, details: outcome.details, display: true },
|
|
334
|
+
{ triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
|
|
335
|
+
);
|
|
336
|
+
tasks.delivered(outcome.key);
|
|
337
|
+
save();
|
|
338
|
+
}
|
|
323
339
|
updateFooter();
|
|
324
340
|
}
|
|
325
341
|
|
|
342
|
+
function syncCloses(wake = true): void {
|
|
343
|
+
if (!ctx || !active() || tasks.failed) return;
|
|
344
|
+
try {
|
|
345
|
+
for (const id of closedChildren(ctx)) recordClose(id, wake);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
ctx.ui.notify(
|
|
348
|
+
`delegate: confirmed close import unavailable: ${String(error)}; pending children remain unresolved.`,
|
|
349
|
+
"warning",
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
326
354
|
function recordGone(id: string): boolean {
|
|
327
355
|
const record = startedById(id);
|
|
328
356
|
if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
|
|
@@ -346,21 +374,22 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
346
374
|
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
347
375
|
windowName: record.windowName,
|
|
348
376
|
};
|
|
377
|
+
const content = [
|
|
378
|
+
`[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
|
|
379
|
+
`Working directory: ${cwdLine(record.cwd)}`,
|
|
380
|
+
...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
|
|
381
|
+
`Delegate session: ${session ?? sessionIdOf(record)}`,
|
|
382
|
+
"A reply that arrives later is still delivered. To continue the delegate's session in a new window, call delegation_resume with this delegation's id.",
|
|
383
|
+
].join("\n");
|
|
384
|
+
tasks.paused = false; // A fresh outcome is new work, not replay of the stopped turn.
|
|
385
|
+
tasks.outcomes.push({ key: `gone:${record.id}`, content, details });
|
|
386
|
+
save();
|
|
349
387
|
pi.sendMessage(
|
|
350
|
-
{
|
|
351
|
-
|
|
352
|
-
details,
|
|
353
|
-
content: [
|
|
354
|
-
`[delegate] Delegation ${record.id}${record.resumedFrom === undefined ? "" : ` (resumed from ${record.resumedFrom})`} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
|
|
355
|
-
`Working directory: ${cwdLine(record.cwd)}`,
|
|
356
|
-
...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
|
|
357
|
-
`Delegate session: ${session ?? sessionIdOf(record)}`,
|
|
358
|
-
"A reply that arrives later is still delivered. To continue the delegate's session in a new window, call delegation_resume with this delegation's id.",
|
|
359
|
-
].join("\n"),
|
|
360
|
-
display: true,
|
|
361
|
-
},
|
|
362
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
388
|
+
{ customType: CUSTOM_TYPE, details, content, display: true },
|
|
389
|
+
{ triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
|
|
363
390
|
);
|
|
391
|
+
tasks.delivered(`gone:${record.id}`);
|
|
392
|
+
save();
|
|
364
393
|
updateFooter();
|
|
365
394
|
return true;
|
|
366
395
|
}
|
|
@@ -370,25 +399,22 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
370
399
|
const reply = payload?.envelope;
|
|
371
400
|
if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
|
|
372
401
|
// Only the delegate's own reply counts: `from` must be the delegation's session id.
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (requestId === undefined) return;
|
|
402
|
+
const matches = reply.in_reply_to
|
|
403
|
+
.map((id) => byRequest.get(id))
|
|
404
|
+
.filter((record): record is StartedRecorded => record !== undefined && sessionIdOf(record) === reply.from);
|
|
405
|
+
if (matches.length === 0) return;
|
|
378
406
|
payload.handled = true;
|
|
379
|
-
|
|
380
|
-
|
|
407
|
+
// Transport retries have fresh envelope ids. Correlate on every task id,
|
|
408
|
+
// not just the envelope, and never present an already answered task again.
|
|
409
|
+
const pending = matches.filter((record) => record.result === undefined);
|
|
410
|
+
if (shown.has(reply.id) || pending.length === 0) return;
|
|
411
|
+
const delegation = pending.at(-1) as StartedRecorded;
|
|
381
412
|
shown.add(reply.id);
|
|
382
413
|
const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
|
|
383
414
|
const finished = finishedModel(session);
|
|
384
415
|
// After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
|
|
385
416
|
const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
|
|
386
417
|
const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
|
|
387
|
-
// Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
|
|
388
|
-
pi.sendMessage(
|
|
389
|
-
{ customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
|
|
390
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
391
|
-
);
|
|
392
418
|
const result: Result = {
|
|
393
419
|
status: reply.status,
|
|
394
420
|
replyId: reply.id,
|
|
@@ -397,21 +423,63 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
397
423
|
...(finished === undefined ? {} : { finishedModel: finished }),
|
|
398
424
|
workspaceSnapshot: snapshot,
|
|
399
425
|
};
|
|
400
|
-
|
|
401
|
-
|
|
426
|
+
for (const record of pending) {
|
|
427
|
+
pi.appendEntry(CUSTOM_TYPE, { id: record.id, result });
|
|
428
|
+
record.result = result;
|
|
429
|
+
}
|
|
430
|
+
const content = resultText(details);
|
|
431
|
+
const answered = new Set(pending.map((record) => record.id));
|
|
432
|
+
tasks.outcomes = tasks.outcomes.filter(
|
|
433
|
+
(outcome) => outcome.details.kind !== "gone" || !answered.has(outcome.details.id),
|
|
434
|
+
);
|
|
435
|
+
tasks.paused = false; // Only old replay is held by a root stop, not a new live result.
|
|
436
|
+
tasks.outcomes.push({ key: reply.id, content, details });
|
|
437
|
+
save();
|
|
438
|
+
// Persist first, then steer. A crash before injection is recovered from
|
|
439
|
+
// the processing inbox, not from the provider's already-claimed mail.
|
|
440
|
+
pi.sendMessage(
|
|
441
|
+
{ customType: CUSTOM_TYPE, content, details, display: true },
|
|
442
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
443
|
+
);
|
|
444
|
+
tasks.delivered(reply.id);
|
|
445
|
+
save();
|
|
402
446
|
updateFooter();
|
|
403
447
|
}
|
|
404
448
|
|
|
449
|
+
pi.on("context", () => syncCloses());
|
|
450
|
+
pi.on("agent_before_settle", () => syncCloses());
|
|
451
|
+
pi.on("agent_settled", () => syncCloses());
|
|
452
|
+
tasks.register(
|
|
453
|
+
pi,
|
|
454
|
+
() => [...byId.values()],
|
|
455
|
+
() => ctx,
|
|
456
|
+
processesChildren,
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
pi.on("session_shutdown", () => {
|
|
460
|
+
if (replay) clearImmediate(replay);
|
|
461
|
+
replay = undefined;
|
|
462
|
+
ctx = undefined;
|
|
463
|
+
});
|
|
405
464
|
pi.events.on(INBOUND, (data) => takeReply(data as Inbound));
|
|
406
465
|
|
|
407
466
|
return {
|
|
467
|
+
subscribe: presentation.subscribe,
|
|
468
|
+
setCountVisible: presentation.setCountVisible,
|
|
408
469
|
restore,
|
|
409
470
|
recordStart,
|
|
410
471
|
recordAttempt,
|
|
411
472
|
recordClose,
|
|
473
|
+
syncCloses,
|
|
412
474
|
running,
|
|
413
475
|
recordGone,
|
|
414
476
|
list: () => [...byId.values()],
|
|
415
477
|
find: (id) => byId.get(id),
|
|
478
|
+
completionReady: () => !tasks.failed && running().length === 0 && tasks.outcomes.length === 0,
|
|
479
|
+
checkStorage: (write = false) => {
|
|
480
|
+
const problem = tasks.storageProblem();
|
|
481
|
+
if (problem) throw new Error(problem);
|
|
482
|
+
if (write) save();
|
|
483
|
+
},
|
|
416
484
|
};
|
|
417
485
|
}
|
package/src/resume-checks.ts
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
// prerequisites once, then per record the window, session file, agent, thinking
|
|
4
4
|
// level and model. None of them changes anything; killing an exited window and
|
|
5
5
|
// deciding trust stay with the resume itself.
|
|
6
|
+
|
|
6
7
|
import { statSync } from "node:fs";
|
|
7
8
|
import { isAbsolute } from "node:path";
|
|
8
|
-
|
|
9
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
|
|
11
|
-
import
|
|
12
|
-
import { sessionHeaderCwd, sessionIdOf } from "./result-sessions.ts";
|
|
10
|
+
import { type Agent, disabledProblem, isThinking, withoutLocked } from "./agents.ts";
|
|
11
|
+
import { sessionHeaderCwd, sessionIdOf, sessionThinking } from "./result-sessions.ts";
|
|
13
12
|
import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
|
|
14
13
|
import type { ResumeDeps, ResumeRequest } from "./resume.ts";
|
|
14
|
+
import { ownerDetails, sessionIsOwned } from "./session-owner.ts";
|
|
15
15
|
import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
|
|
16
16
|
import { ownWindow, windowState } from "./tracking.ts";
|
|
17
|
+
import { assertResumeAuthority } from "./tree-view.ts";
|
|
17
18
|
import type { WindowPlacement } from "./window-placement.ts";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -79,10 +80,42 @@ export function sessionFor(record: StartedRecorded, ctx: ExtensionContext): Sess
|
|
|
79
80
|
return { path, cwd, dir };
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
/**
|
|
84
|
+
* What a resume runs with when the call passes nothing. A field the agent
|
|
85
|
+
* locks takes the agent file's current value, or the parent's current one when
|
|
86
|
+
* the file sets none, even when the delegate last ran on another (a fallback
|
|
87
|
+
* after a usage limit, or a value from before the file changed). A field it
|
|
88
|
+
* does not lock keeps what the delegate last ran on: the model of its last
|
|
89
|
+
* answer, and the thinking level its session last changed to, or the
|
|
90
|
+
* recorded ones when the session does not say. The models are in order, the
|
|
91
|
+
* agent's fallbacks after the first.
|
|
92
|
+
*/
|
|
93
|
+
export function resumeDefaults(
|
|
94
|
+
record: StartedRecorded,
|
|
95
|
+
agent: Agent,
|
|
96
|
+
sessionPath: string,
|
|
97
|
+
parent: { model?: string; thinking: string },
|
|
98
|
+
): { models: string[]; thinking: string } {
|
|
99
|
+
const first = agent.locked.includes("model")
|
|
100
|
+
? (agent.model ?? parent.model)
|
|
101
|
+
: (finishedModel(sessionPath) ?? record.result?.finishedModel ?? record.model);
|
|
102
|
+
const thinking = agent.locked.includes("thinking")
|
|
103
|
+
? (agent.thinking ?? parent.thinking)
|
|
104
|
+
: lastThinking(record, sessionPath);
|
|
105
|
+
return { models: [...new Set([...(first === undefined ? [] : [first]), ...agent.fallback])], thinking };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The thinking level the delegate last ran on: its session's last change, else the level recorded at its start. */
|
|
109
|
+
function lastThinking(record: StartedRecorded, sessionPath: string): string {
|
|
110
|
+
const level = sessionThinking(sessionPath);
|
|
111
|
+
return level !== undefined && isThinking(level) ? level : record.thinking;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function assertNoOwner(record: StartedRecorded): void {
|
|
115
|
+
if (sessionIsOwned(sessionIdOf(record)))
|
|
116
|
+
throw new Error(
|
|
117
|
+
`delegation ${record.id} is already open in another process: ${ownerDetails(sessionIdOf(record))}; send it a message instead of resuming it`,
|
|
118
|
+
);
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
/** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
|
|
@@ -91,8 +124,15 @@ export function recordCheck(
|
|
|
91
124
|
record: StartedRecorded,
|
|
92
125
|
ctx: ExtensionContext,
|
|
93
126
|
): { session: SessionFile; agent: Agent } {
|
|
127
|
+
assertResumeAuthority(record, ctx);
|
|
128
|
+
assertNoOwner(record);
|
|
129
|
+
deps.authorize?.(record.id, ctx);
|
|
130
|
+
deps.admit?.(record.agent, ctx);
|
|
94
131
|
const session = sessionFor(record, ctx);
|
|
95
|
-
const
|
|
132
|
+
const roster = deps.roster(ctx);
|
|
133
|
+
const disabled = disabledProblem(roster, record.agent);
|
|
134
|
+
if (disabled !== undefined) throw new Error(`${disabled}, so delegation ${record.id} is not resumed`);
|
|
135
|
+
const agent = roster.agents.find((item) => item.name === record.agent);
|
|
96
136
|
if (agent === undefined) {
|
|
97
137
|
throw new Error(
|
|
98
138
|
`agent ${JSON.stringify(record.agent)} is no longer in the roster, so delegation ${record.id} is not resumed`,
|
|
@@ -113,12 +153,17 @@ export async function settleModel(
|
|
|
113
153
|
},
|
|
114
154
|
ctx: ExtensionContext,
|
|
115
155
|
): Promise<{ thinking: string; chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>> }> {
|
|
116
|
-
const { record, agent, session
|
|
117
|
-
const
|
|
156
|
+
const { record, agent, session } = input;
|
|
157
|
+
const defaults = resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx));
|
|
158
|
+
const { request, notice } = withoutLocked(agent, input.request, {
|
|
159
|
+
model: defaults.models[0],
|
|
160
|
+
thinking: defaults.thinking,
|
|
161
|
+
});
|
|
162
|
+
const thinking = request.thinking ?? defaults.thinking;
|
|
118
163
|
const badThinking = deps.thinkingProblem(thinking);
|
|
119
164
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
120
|
-
const
|
|
121
|
-
return { thinking, chosen:
|
|
165
|
+
const chosen = await deps.chooseModel(request.model, agent, ctx.modelRegistry, defaults.models);
|
|
166
|
+
return { thinking, chosen: notice === undefined ? chosen : { ...chosen, notices: [notice, ...chosen.notices] } };
|
|
122
167
|
}
|
|
123
168
|
|
|
124
169
|
/** The call-wide prerequisites of a resume: inside tmux, a new enough tmux, a valid placement setting (read once). */
|
|
@@ -143,7 +188,7 @@ export async function passes(
|
|
|
143
188
|
windowCheck(record, windows);
|
|
144
189
|
const { session, agent } = recordCheck(deps, record, ctx);
|
|
145
190
|
// Model choice depends on the agent and where the delegate ended, so records alike share one answer.
|
|
146
|
-
const key = JSON.stringify([
|
|
191
|
+
const key = JSON.stringify([agent.name, resumeDefaults(record, agent, session.path, deps.parentDefaults(ctx))]);
|
|
147
192
|
let ok = models.get(key);
|
|
148
193
|
if (ok === undefined) {
|
|
149
194
|
ok = await settleModel(deps, { record, agent, session, request: {} }, ctx).then(
|
package/src/resume-picker.ts
CHANGED
|
@@ -19,7 +19,7 @@ export type ResumePickerDeps = {
|
|
|
19
19
|
const optionFor = (record: StartedRecorded): string =>
|
|
20
20
|
`${recordedName(record)} · ${record.agent} · ${record.id.slice(0, 8)}`;
|
|
21
21
|
|
|
22
|
-
export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps): Promise<void> {
|
|
22
|
+
export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps, id?: string): Promise<void> {
|
|
23
23
|
const unavailable = deps.unavailable();
|
|
24
24
|
if (unavailable !== undefined) {
|
|
25
25
|
ctx.ui.notify(`delegate: ${unavailable}`, "error");
|
|
@@ -37,7 +37,14 @@ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps):
|
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
39
|
const options = records.map(optionFor);
|
|
40
|
-
const picked =
|
|
40
|
+
const picked =
|
|
41
|
+
id === undefined
|
|
42
|
+
? await ctx.ui.select("Resume which delegation?", options)
|
|
43
|
+
: options[records.findIndex((record) => record.id === id)];
|
|
44
|
+
if (id !== undefined && picked === undefined) {
|
|
45
|
+
ctx.ui.notify("delegate: selected delegation cannot be resumed now", "warning");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
41
48
|
const record = records[options.indexOf(picked ?? "")];
|
|
42
49
|
if (record === undefined) return;
|
|
43
50
|
const task = await ctx.ui.editor(`Follow-up for ${recordedName(record)}`);
|