pi-squire 0.15.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 +323 -1
- package/README.md +918 -117
- package/package.json +2 -1
- package/src/agents.ts +57 -36
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +35 -41
- 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 +358 -56
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- 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-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +174 -108
- package/src/resume-checks.ts +14 -2
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +62 -13
- 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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ChildOutcome } from "./child-tasks.ts";
|
|
3
|
+
import { cwdLine, modelLabel, workspaceLine } from "./render.ts";
|
|
4
|
+
import { resumeDetails } from "./result-message.ts";
|
|
5
|
+
import { finishedModel, locateSession, sessionIdOf } from "./result-sessions.ts";
|
|
6
|
+
import { recordedName, type StartedRecorded } from "./result-types.ts";
|
|
7
|
+
import type { MessageDetails } from "./results.ts";
|
|
8
|
+
|
|
9
|
+
/** A missing/canceled task, never a fabricated result envelope. */
|
|
10
|
+
export function closeOutcome(record: StartedRecorded, ctx: ExtensionContext | undefined): ChildOutcome {
|
|
11
|
+
const session: string | undefined = locateSession(record, ctx?.sessionManager.getSessionDir());
|
|
12
|
+
const finished: string | undefined = finishedModel(session);
|
|
13
|
+
const details: MessageDetails = {
|
|
14
|
+
kind: "gone",
|
|
15
|
+
reason: "explicit-close",
|
|
16
|
+
name: recordedName(record),
|
|
17
|
+
agent: record.agent,
|
|
18
|
+
model: record.model,
|
|
19
|
+
id: record.id,
|
|
20
|
+
...resumeDetails(record),
|
|
21
|
+
requestId: record.requestId,
|
|
22
|
+
windowName: record.windowName,
|
|
23
|
+
...(finished ? { finishedModel: finished } : {}),
|
|
24
|
+
...(session ? { sessionPath: session } : {}),
|
|
25
|
+
cwd: record.cwd,
|
|
26
|
+
sessionDir: record.sessionDir,
|
|
27
|
+
workspace: record.workspace,
|
|
28
|
+
};
|
|
29
|
+
const content: string = [
|
|
30
|
+
`[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, canceled by explicit close without a result. This is missing work, not successful completion.`,
|
|
31
|
+
`Working directory: ${cwdLine(record.cwd)}`,
|
|
32
|
+
...(record.workspace ? [`Workspace: ${workspaceLine(record.workspace)}`] : []),
|
|
33
|
+
`Delegate session: ${session ?? sessionIdOf(record)}`,
|
|
34
|
+
"A genuine reply that arrives later is still delivered. Only the direct parent may resume this session as a new delegation.",
|
|
35
|
+
].join("\n");
|
|
36
|
+
return { key: `closed:${record.id}`, content, details };
|
|
37
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Restore local semantic facts without trusting foreign transcript copies.
|
|
2
|
+
// Any incomplete authoritative read latches a failure until a fresh restore.
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { ChildTasks } from "./child-tasks.ts";
|
|
5
|
+
import { isStarted, type Recorded, type WorkspaceAttempt } from "./result-types.ts";
|
|
6
|
+
import type { Delegation, Result } from "./results.ts";
|
|
7
|
+
import { belongsTo, restoredStarts } from "./tree-view.ts";
|
|
8
|
+
|
|
9
|
+
export function restoreRecords(ctx: ExtensionContext, tasks: ChildTasks): Recorded[] {
|
|
10
|
+
const byId: Map<string, Recorded> = new Map();
|
|
11
|
+
let local: Recorded[] = [];
|
|
12
|
+
try {
|
|
13
|
+
local = tasks.restore(ctx.sessionManager.getSessionId());
|
|
14
|
+
} catch (error) {
|
|
15
|
+
tasks.blockRestore(error);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
transcript(ctx, tasks, byId);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
tasks.blockRestore(error);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
for (const record of local) if (accept(record, ctx, tasks, true)) byId.set(record.id, record);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
tasks.blockRestore(error);
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
for (const record of restoredStarts(ctx))
|
|
29
|
+
if (!byId.has(record.id) && accept(record, ctx, tasks, false)) byId.set(record.id, record);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
ctx.ui.notify(`delegate: shared launch recovery unavailable: ${String(error)}`, "warning");
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
interrupt([...byId.values()]);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
tasks.blockRestore(error);
|
|
37
|
+
}
|
|
38
|
+
return [...byId.values()];
|
|
39
|
+
}
|
|
40
|
+
function accept(record: Recorded, ctx: ExtensionContext, tasks: ChildTasks, local: boolean): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return belongsTo(record, ctx);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
tasks.blockRestore(error);
|
|
45
|
+
// Keep known own obligations while blocked, never make a foreign copy local.
|
|
46
|
+
if (!isStarted(record)) return record.parentId === ctx.sessionManager.getSessionId();
|
|
47
|
+
if (record.identity) return record.identity.parent === ctx.sessionManager.getSessionId();
|
|
48
|
+
return local;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function transcript(ctx: ExtensionContext, tasks: ChildTasks, byId: Map<string, Recorded>): void {
|
|
52
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
53
|
+
if (entry.type !== "custom" || entry.customType !== "delegate") continue;
|
|
54
|
+
const data = entry.data as
|
|
55
|
+
| Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
|
|
56
|
+
| undefined;
|
|
57
|
+
if (!data || typeof data.id !== "string") continue;
|
|
58
|
+
if (data.kind === "workspace-attempt" || typeof data.requestId === "string") {
|
|
59
|
+
restoreStart(data as Recorded, ctx, tasks, byId);
|
|
60
|
+
} else {
|
|
61
|
+
applyTerminal(byId.get(data.id), data);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function restoreStart(record: Recorded, ctx: ExtensionContext, tasks: ChildTasks, byId: Map<string, Recorded>): void {
|
|
66
|
+
const existing: Recorded | undefined = byId.get(record.id);
|
|
67
|
+
if (!isStarted(record) && existing && isStarted(existing)) return;
|
|
68
|
+
if (accept(record, ctx, tasks, false)) byId.set(record.id, { ...record });
|
|
69
|
+
}
|
|
70
|
+
function applyTerminal(
|
|
71
|
+
record: Recorded | undefined,
|
|
72
|
+
data: Partial<Delegation & { result: Result; closed: boolean; gone: boolean }>,
|
|
73
|
+
): void {
|
|
74
|
+
if (!record || !isStarted(record)) return;
|
|
75
|
+
if (typeof data.result?.replyId === "string") record.result = data.result;
|
|
76
|
+
else if (data.closed === true) record.closed = true;
|
|
77
|
+
else if (data.gone === true) {
|
|
78
|
+
record.gone = true;
|
|
79
|
+
if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function interrupt(records: Recorded[]): void {
|
|
83
|
+
for (const record of records) {
|
|
84
|
+
if (isStarted(record)) continue;
|
|
85
|
+
const attempt: WorkspaceAttempt = record;
|
|
86
|
+
const { phase, resources } = attempt.attempt;
|
|
87
|
+
if (phase === "planned" || phase === "created")
|
|
88
|
+
attempt.attempt = {
|
|
89
|
+
phase: "interrupted",
|
|
90
|
+
resources,
|
|
91
|
+
lastPhase: phase,
|
|
92
|
+
reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/result-types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Delegation } from "./results.ts";
|
|
1
|
+
import type { Delegation, DelegationState, WindowState } from "./results.ts";
|
|
2
2
|
import type { WorktreePlan } from "./worktree.ts";
|
|
3
3
|
|
|
4
4
|
/** One started delegation as it stands: what was recorded at its start, plus what came after. */
|
|
@@ -34,3 +34,31 @@ export type Recorded = StartedRecorded | WorkspaceAttempt;
|
|
|
34
34
|
export function isStarted(record: Recorded): record is StartedRecorded {
|
|
35
35
|
return !("kind" in record && record.kind === "workspace-attempt");
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** How much of a delegation id a delegate's session name carries. */
|
|
39
|
+
const SHORT_ID = 8;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A delegate's name, used for both its session and its window: its agent and
|
|
43
|
+
* the label, or the first 8 characters of its id when there is no label.
|
|
44
|
+
*/
|
|
45
|
+
export function delegateName(agent: string, id: string, label?: string): string {
|
|
46
|
+
return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The recorded name, or the pre-label name for records from before labels. */
|
|
50
|
+
export function recordedName(record: Recorded): string {
|
|
51
|
+
return record.name ?? delegateName(record.agent, record.id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
|
|
55
|
+
export function deriveState(record: Recorded, window: WindowState): DelegationState {
|
|
56
|
+
if (!isStarted(record)) return record.attempt.phase;
|
|
57
|
+
if (record.accountingOnly) return "unknown";
|
|
58
|
+
if (record.closed === true) return "closed";
|
|
59
|
+
if (record.result !== undefined) return "done";
|
|
60
|
+
if (record.gone === true) return "gone";
|
|
61
|
+
if (window === "open") return "running";
|
|
62
|
+
if (window === "gone") return "window-closed";
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Local presentation signals only; neither observers nor count visibility own task state. */
|
|
4
|
+
export class ResultsPresentation {
|
|
5
|
+
private readonly listeners = new Set<() => void>();
|
|
6
|
+
private readonly icon: string = Math.random() < 0.5 ? "👨" : "👩";
|
|
7
|
+
private visible = true;
|
|
8
|
+
private shown = false;
|
|
9
|
+
private ctx: ExtensionContext | undefined;
|
|
10
|
+
private count = 0;
|
|
11
|
+
|
|
12
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
13
|
+
this.listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
this.listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
setCountVisible = (visible: boolean): void => {
|
|
20
|
+
this.visible = visible;
|
|
21
|
+
this.footer();
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
update(ctx: ExtensionContext | undefined, count: number): void {
|
|
25
|
+
this.ctx = ctx;
|
|
26
|
+
this.count = count;
|
|
27
|
+
this.footer();
|
|
28
|
+
for (const listener of this.listeners) {
|
|
29
|
+
try {
|
|
30
|
+
listener();
|
|
31
|
+
} catch {
|
|
32
|
+
/* An optional view cannot interrupt task/result processing. */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private footer(): void {
|
|
38
|
+
if (this.ctx?.hasUI !== true) return;
|
|
39
|
+
const visible: boolean = this.visible && this.count > 0;
|
|
40
|
+
if (!visible && !this.shown) return;
|
|
41
|
+
this.shown = visible;
|
|
42
|
+
this.ctx.ui.setStatus(
|
|
43
|
+
"delegate",
|
|
44
|
+
visible ? `${this.icon} ${this.count} delegate${this.count === 1 ? "" : "s"} running` : undefined,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
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,6 +39,10 @@ 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;
|
|
38
48
|
/** The agent's `display_name` at launch, shown instead of `agent`; absent when it had none. */
|
|
@@ -129,7 +139,7 @@ export type MessageDetails =
|
|
|
129
139
|
/** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
|
|
130
140
|
workspaceSnapshot?: WorkspaceSnapshot;
|
|
131
141
|
})
|
|
132
|
-
| (CommonDetails & { kind: "gone"; windowName: string });
|
|
142
|
+
| (CommonDetails & { kind: "gone"; windowName: string; reason?: "explicit-close" });
|
|
133
143
|
|
|
134
144
|
/** The provider's payload on `message:inbound`. */
|
|
135
145
|
export type Inbound = {
|
|
@@ -140,6 +150,10 @@ export type Inbound = {
|
|
|
140
150
|
};
|
|
141
151
|
|
|
142
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;
|
|
143
157
|
/** Rebuild the records from the session's custom entries, as on a resume. */
|
|
144
158
|
restore(ctx: ExtensionContext): void;
|
|
145
159
|
/** Record a delegation and show it as running; it supersedes an attempt with the same id. */
|
|
@@ -148,6 +162,8 @@ export type Results = {
|
|
|
148
162
|
recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
|
|
149
163
|
/** Record a close; does nothing when there is no such delegation or it is already closed. */
|
|
150
164
|
recordClose(id: string): void;
|
|
165
|
+
/** Import only confirmed own direct-child closes; no foreign semantic writes. */
|
|
166
|
+
syncCloses(): void;
|
|
151
167
|
/** Delegations with no result, no recorded close and no gone-window record. */
|
|
152
168
|
running(): StartedRecorded[];
|
|
153
169
|
/** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
|
|
@@ -156,6 +172,10 @@ export type Results = {
|
|
|
156
172
|
list(): Recorded[];
|
|
157
173
|
/** One delegation by id. */
|
|
158
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;
|
|
159
179
|
};
|
|
160
180
|
|
|
161
181
|
/** What tmux reported for a delegation's window: listed, not listed, or not askable. */
|
|
@@ -175,46 +195,47 @@ export type DelegationState =
|
|
|
175
195
|
| "unknown"
|
|
176
196
|
| AttemptState["phase"];
|
|
177
197
|
|
|
178
|
-
|
|
179
|
-
const SHORT_ID = 8;
|
|
198
|
+
export { delegateName, deriveState, recordedName } from "./result-types.ts";
|
|
180
199
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
/** The recorded name, or the pre-label name for records from before labels. */
|
|
190
|
-
export function recordedName(record: Recorded): string {
|
|
191
|
-
return record.name ?? delegateName(record.agent, record.id);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
|
|
195
|
-
export function deriveState(record: Recorded, window: WindowState): DelegationState {
|
|
196
|
-
if (!isStarted(record)) return record.attempt.phase;
|
|
197
|
-
if (record.closed === true) return "closed";
|
|
198
|
-
if (record.result !== undefined) return "done";
|
|
199
|
-
if (record.gone === true) return "gone";
|
|
200
|
-
if (window === "open") return "running";
|
|
201
|
-
if (window === "gone") return "window-closed";
|
|
202
|
-
return "unknown";
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
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 {
|
|
206
207
|
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
207
208
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
208
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;
|
|
209
232
|
/** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
|
|
210
233
|
const byRequest = new Map<string, StartedRecorded>();
|
|
211
234
|
/** Reply envelope ids a result message has already been sent for. */
|
|
212
235
|
const shown = new Set<string>();
|
|
213
236
|
/** The latest session context, for the footer and the session directory. */
|
|
214
237
|
let ctx: ExtensionContext | undefined;
|
|
215
|
-
|
|
216
|
-
let footerShown = false;
|
|
217
|
-
const icon = Math.random() < 0.5 ? "👨" : "👩";
|
|
238
|
+
const presentation = new ResultsPresentation();
|
|
218
239
|
|
|
219
240
|
/**
|
|
220
241
|
* A delegation still going: no result, no recorded close, and not resumed.
|
|
@@ -242,64 +263,41 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
242
263
|
|
|
243
264
|
/** The footer counts the running delegations, and hides itself at zero. */
|
|
244
265
|
function updateFooter(): void {
|
|
245
|
-
|
|
246
|
-
const count = running().length;
|
|
247
|
-
const visible = count > 0;
|
|
248
|
-
if (!visible && !footerShown) return;
|
|
249
|
-
footerShown = visible;
|
|
250
|
-
ctx.ui.setStatus(STATUS_KEY, visible ? `${icon} ${count} delegate${count === 1 ? "" : "s"} running` : undefined);
|
|
266
|
+
presentation.update(ctx, running().length);
|
|
251
267
|
}
|
|
252
268
|
|
|
253
269
|
/** Rebuild the records from the session's entries, so a resume still recognises replies. */
|
|
254
270
|
function restore(context: ExtensionContext): void {
|
|
271
|
+
if (replay) clearImmediate(replay);
|
|
255
272
|
ctx = context;
|
|
256
273
|
byId.clear();
|
|
257
274
|
byRequest.clear();
|
|
258
275
|
shown.clear();
|
|
259
|
-
for (const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
| Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
|
|
263
|
-
| undefined;
|
|
264
|
-
if (data === undefined || typeof data.id !== "string") continue;
|
|
265
|
-
// An attempt may carry a real request id; it is checked before the legacy start rule below.
|
|
266
|
-
if (data.kind === "workspace-attempt") {
|
|
267
|
-
const existing = byId.get(data.id);
|
|
268
|
-
if (existing === undefined || !isStarted(existing)) {
|
|
269
|
-
byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
|
|
270
|
-
}
|
|
271
|
-
} else if (typeof data.requestId === "string") {
|
|
272
|
-
const record: StartedRecorded = { ...(data as Delegation) };
|
|
273
|
-
byId.set(record.id, record);
|
|
276
|
+
for (const record of restoreRecords(context, tasks)) {
|
|
277
|
+
byId.set(record.id, record);
|
|
278
|
+
if (isStarted(record)) {
|
|
274
279
|
byRequest.set(record.requestId, record);
|
|
275
|
-
|
|
276
|
-
shown.add(data.result.replyId);
|
|
277
|
-
const record = startedById(data.id);
|
|
278
|
-
if (record !== undefined) record.result = data.result;
|
|
279
|
-
} else if (data.closed === true) {
|
|
280
|
-
const record = startedById(data.id);
|
|
281
|
-
if (record !== undefined) record.closed = true;
|
|
282
|
-
} else if (data.gone === true) {
|
|
283
|
-
const record = startedById(data.id);
|
|
284
|
-
if (record === undefined) continue;
|
|
285
|
-
record.gone = true;
|
|
286
|
-
if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
// A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
|
|
290
|
-
for (const record of byId.values()) {
|
|
291
|
-
if (isStarted(record)) continue;
|
|
292
|
-
const { phase, resources } = record.attempt;
|
|
293
|
-
if (phase === "planned" || phase === "created") {
|
|
294
|
-
record.attempt = {
|
|
295
|
-
phase: "interrupted",
|
|
296
|
-
resources,
|
|
297
|
-
lastPhase: phase,
|
|
298
|
-
reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
|
|
299
|
-
};
|
|
280
|
+
if (record.result) shown.add(record.result.replyId);
|
|
300
281
|
}
|
|
301
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);
|
|
302
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
|
+
}
|
|
303
301
|
}
|
|
304
302
|
|
|
305
303
|
function recordStart(delegation: Delegation, context: ExtensionContext): void {
|
|
@@ -308,6 +306,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
308
306
|
const record: StartedRecorded = { ...delegation };
|
|
309
307
|
byId.set(record.id, record);
|
|
310
308
|
byRequest.set(record.requestId, record);
|
|
309
|
+
save();
|
|
311
310
|
updateFooter();
|
|
312
311
|
}
|
|
313
312
|
|
|
@@ -315,16 +314,43 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
315
314
|
ctx = context;
|
|
316
315
|
pi.appendEntry(CUSTOM_TYPE, attempt);
|
|
317
316
|
byId.set(attempt.id, { ...attempt });
|
|
317
|
+
save();
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
function recordClose(id: string): void {
|
|
320
|
+
function recordClose(id: string, wake = true): void {
|
|
321
321
|
const record = startedById(id);
|
|
322
322
|
if (record === undefined || record.closed === true) return;
|
|
323
|
-
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
324
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
|
+
}
|
|
325
339
|
updateFooter();
|
|
326
340
|
}
|
|
327
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
|
+
|
|
328
354
|
function recordGone(id: string): boolean {
|
|
329
355
|
const record = startedById(id);
|
|
330
356
|
if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
|
|
@@ -348,21 +374,22 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
348
374
|
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
349
375
|
windowName: record.windowName,
|
|
350
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();
|
|
351
387
|
pi.sendMessage(
|
|
352
|
-
{
|
|
353
|
-
|
|
354
|
-
details,
|
|
355
|
-
content: [
|
|
356
|
-
`[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.`,
|
|
357
|
-
`Working directory: ${cwdLine(record.cwd)}`,
|
|
358
|
-
...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
|
|
359
|
-
`Delegate session: ${session ?? sessionIdOf(record)}`,
|
|
360
|
-
"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.",
|
|
361
|
-
].join("\n"),
|
|
362
|
-
display: true,
|
|
363
|
-
},
|
|
364
|
-
{ triggerTurn: true, deliverAs: "followUp" },
|
|
388
|
+
{ customType: CUSTOM_TYPE, details, content, display: true },
|
|
389
|
+
{ triggerTurn: true, deliverAs: processesChildren() ? "steer" : "followUp" },
|
|
365
390
|
);
|
|
391
|
+
tasks.delivered(`gone:${record.id}`);
|
|
392
|
+
save();
|
|
366
393
|
updateFooter();
|
|
367
394
|
return true;
|
|
368
395
|
}
|
|
@@ -372,25 +399,22 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
372
399
|
const reply = payload?.envelope;
|
|
373
400
|
if (reply === undefined || !Array.isArray(reply.in_reply_to) || reply.in_reply_to.length === 0) return;
|
|
374
401
|
// Only the delegate's own reply counts: `from` must be the delegation's session id.
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
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;
|
|
380
406
|
payload.handled = true;
|
|
381
|
-
|
|
382
|
-
|
|
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;
|
|
383
412
|
shown.add(reply.id);
|
|
384
413
|
const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
|
|
385
414
|
const finished = finishedModel(session);
|
|
386
415
|
// After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
|
|
387
416
|
const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
|
|
388
417
|
const details = resultDetails(delegation, recordedName(delegation), payload, session, finished, snapshot);
|
|
389
|
-
// Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
|
|
390
|
-
pi.sendMessage(
|
|
391
|
-
{ customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
|
|
392
|
-
{ triggerTurn: true, deliverAs: "steer" },
|
|
393
|
-
);
|
|
394
418
|
const result: Result = {
|
|
395
419
|
status: reply.status,
|
|
396
420
|
replyId: reply.id,
|
|
@@ -399,21 +423,63 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
399
423
|
...(finished === undefined ? {} : { finishedModel: finished }),
|
|
400
424
|
workspaceSnapshot: snapshot,
|
|
401
425
|
};
|
|
402
|
-
|
|
403
|
-
|
|
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();
|
|
404
446
|
updateFooter();
|
|
405
447
|
}
|
|
406
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
|
+
});
|
|
407
464
|
pi.events.on(INBOUND, (data) => takeReply(data as Inbound));
|
|
408
465
|
|
|
409
466
|
return {
|
|
467
|
+
subscribe: presentation.subscribe,
|
|
468
|
+
setCountVisible: presentation.setCountVisible,
|
|
410
469
|
restore,
|
|
411
470
|
recordStart,
|
|
412
471
|
recordAttempt,
|
|
413
472
|
recordClose,
|
|
473
|
+
syncCloses,
|
|
414
474
|
running,
|
|
415
475
|
recordGone,
|
|
416
476
|
list: () => [...byId.values()],
|
|
417
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
|
+
},
|
|
418
484
|
};
|
|
419
485
|
}
|