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/poll.ts
CHANGED
|
@@ -26,6 +26,7 @@ function declareGone(pi: ExtensionAPI, results: Results, windows: TmuxWindow[] |
|
|
|
26
26
|
/** One sweep: claim waiting replies, declare gone every delegation whose window is missing or exited with still no result. */
|
|
27
27
|
async function sweep(pi: ExtensionAPI, results: Results, tmux: TmuxClient, live: () => boolean): Promise<void> {
|
|
28
28
|
if (results.running().length > 0) {
|
|
29
|
+
results.syncCloses();
|
|
29
30
|
const scan: { scanned?: boolean } = {};
|
|
30
31
|
pi.events.emit(SCAN, scan);
|
|
31
32
|
// No message:* provider: a waiting reply cannot be ruled out, so nothing is declared gone.
|
package/src/render.ts
CHANGED
|
@@ -131,6 +131,7 @@ const ENDINGS: Record<string, { icon: string; color: Color; word: string; wordCo
|
|
|
131
131
|
failed: { icon: "✗", color: "error", word: "failed", wordColor: "error" },
|
|
132
132
|
stopped: { icon: "✗", color: "warning", word: "stopped", wordColor: "warning" },
|
|
133
133
|
gone: { icon: "✗", color: "warning", word: "closed without a result", wordColor: "warning" },
|
|
134
|
+
"canceled without a result": { icon: "✗", color: "warning", word: "canceled without a result", wordColor: "warning" },
|
|
134
135
|
};
|
|
135
136
|
|
|
136
137
|
/** `path` with the home directory shown as `~`. */
|
|
@@ -182,7 +183,9 @@ export function renderMessage(
|
|
|
182
183
|
): Component | undefined {
|
|
183
184
|
const details = message.details as MessageDetails | undefined;
|
|
184
185
|
if (details?.kind !== "result" && details?.kind !== "gone") return undefined;
|
|
185
|
-
const
|
|
186
|
+
const goneStatus =
|
|
187
|
+
details.kind === "gone" && details.reason === "explicit-close" ? "canceled without a result" : "gone";
|
|
188
|
+
const status = details.kind === "gone" ? goneStatus : details.status;
|
|
186
189
|
const background = status === "done" ? "toolSuccessBg" : "toolErrorBg";
|
|
187
190
|
const box = new Box(outputPad, 1, (text) => theme.bg(background, text));
|
|
188
191
|
const header = headerLine(details, status, theme);
|
|
@@ -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-sessions.ts
CHANGED
|
@@ -77,3 +77,42 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
|
|
|
77
77
|
}
|
|
78
78
|
return undefined;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/** One session-file line, as much of it as the branch walk reads. */
|
|
82
|
+
type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The thinking level a delegate's session last changed to on its active
|
|
86
|
+
* branch: Pi records one `thinking_level_change` when a session starts and
|
|
87
|
+
* one per later change, the clamp to a new model's levels included. The
|
|
88
|
+
* branch is the leaf, Pi's last entry, back through each `parentId`.
|
|
89
|
+
* Undefined when the file cannot be read or holds no change on that branch.
|
|
90
|
+
*/
|
|
91
|
+
export function sessionThinking(sessionPath: string): string | undefined {
|
|
92
|
+
let lines: string[];
|
|
93
|
+
try {
|
|
94
|
+
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const byId = new Map<string, BranchEntry>();
|
|
99
|
+
let leaf: BranchEntry | undefined;
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
let entry: BranchEntry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry?.type === "session" || typeof entry?.id !== "string") continue;
|
|
108
|
+
byId.set(entry.id, entry);
|
|
109
|
+
leaf = entry;
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set<BranchEntry>();
|
|
112
|
+
for (let entry = leaf; entry !== undefined && !seen.has(entry); ) {
|
|
113
|
+
seen.add(entry);
|
|
114
|
+
if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") return entry.thinkingLevel;
|
|
115
|
+
entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
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
|
+
}
|