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.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. 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 status = details.kind === "gone" ? "gone" : details.status;
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
+ }
@@ -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
+ }
@@ -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
+ }