pi-squire 0.15.0 → 0.17.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 (61) hide show
  1. package/CHANGELOG.md +343 -1
  2. package/README.md +931 -118
  3. package/package.json +2 -1
  4. package/src/agents.ts +73 -43
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +35 -41
  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 +342 -56
  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/poll.ts +1 -0
  16. package/src/render.ts +4 -1
  17. package/src/result-close.ts +37 -0
  18. package/src/result-restore.ts +95 -0
  19. package/src/result-types.ts +29 -1
  20. package/src/results-presentation.ts +47 -0
  21. package/src/results.ts +174 -108
  22. package/src/resume-checks.ts +14 -2
  23. package/src/resume-picker.ts +9 -2
  24. package/src/resume.ts +62 -13
  25. package/src/session-owner.ts +279 -0
  26. package/src/task-replies.ts +247 -0
  27. package/src/task-settlement.ts +81 -0
  28. package/src/task-store.ts +26 -0
  29. package/src/tmux.ts +134 -54
  30. package/src/tool-bundles.ts +84 -0
  31. package/src/tool-names.ts +29 -0
  32. package/src/tracking.ts +72 -65
  33. package/src/tree-close.ts +236 -0
  34. package/src/tree-fence.ts +51 -0
  35. package/src/tree-lock.ts +77 -0
  36. package/src/tree-notice.ts +124 -0
  37. package/src/tree-registry.ts +365 -0
  38. package/src/tree-settings.ts +31 -0
  39. package/src/tree-types.ts +32 -0
  40. package/src/tree-view.ts +246 -0
  41. package/src/trust-control.ts +214 -0
  42. package/src/trust-prompt.ts +163 -0
  43. package/src/trust-wire.ts +203 -0
  44. package/src/widget-activity-join.ts +24 -0
  45. package/src/widget-activity-runtime.ts +104 -0
  46. package/src/widget-activity.ts +104 -0
  47. package/src/widget-ancestors.ts +142 -0
  48. package/src/widget-current.ts +41 -0
  49. package/src/widget-data.ts +147 -0
  50. package/src/widget-focus.ts +133 -0
  51. package/src/widget-live-process.ts +31 -0
  52. package/src/widget-live.ts +356 -0
  53. package/src/widget-render.ts +181 -0
  54. package/src/widget-selection.ts +58 -0
  55. package/src/widget-settings.ts +29 -0
  56. package/src/widget-summary.ts +51 -0
  57. package/src/widget-title-file.ts +127 -0
  58. package/src/widget-title.ts +118 -0
  59. package/src/widget.ts +272 -0
  60. package/src/window-placement.ts +30 -5
  61. package/src/workspace.ts +38 -90
@@ -0,0 +1,104 @@
1
+ // Process-local completion composition. Providers own hold readiness; Squire
2
+ // advertises only its active exact-session runtime and never owns provider rows.
3
+ import { randomUUID } from "node:crypto";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+
6
+ const KEY: symbol = Symbol.for("pi.completion-holds.v1");
7
+ type Hold = { provider: string; ready: boolean };
8
+ type Table = {
9
+ version: 1;
10
+ hosts: Map<string, Map<string, string>>;
11
+ holds: Map<string, Map<string, Hold>>;
12
+ };
13
+ const shared: Record<symbol, unknown> = globalThis as unknown as Record<symbol, unknown>;
14
+
15
+ function table(create: boolean): Table | undefined {
16
+ const value: unknown = shared[KEY];
17
+ if (value === undefined) {
18
+ if (!create) return undefined;
19
+ const fresh: Table = { version: 1, hosts: new Map(), holds: new Map() };
20
+ shared[KEY] = fresh;
21
+ return fresh;
22
+ }
23
+ if (
24
+ typeof value !== "object" ||
25
+ value === null ||
26
+ !("version" in value) ||
27
+ value.version !== 1 ||
28
+ !("hosts" in value) ||
29
+ !(value.hosts instanceof Map) ||
30
+ !("holds" in value) ||
31
+ !(value.holds instanceof Map)
32
+ )
33
+ throw new Error("invalid pi.completion-holds.v1 table; completion is blocked");
34
+ return value as Table;
35
+ }
36
+
37
+ function validHosts(hosts: unknown): hosts is Map<string, string> {
38
+ return (
39
+ hosts instanceof Map &&
40
+ [...hosts].every(
41
+ ([name, runtime]) => typeof name === "string" && !!name && typeof runtime === "string" && !!runtime,
42
+ )
43
+ );
44
+ }
45
+
46
+ /** Missing table/no holds preserves normal completion; malformed data refuses. */
47
+ export function externalHoldsReady(sessionId: string): boolean {
48
+ try {
49
+ const current: Table | undefined = table(false);
50
+ if (!current) return true;
51
+ const hosts: unknown = current.hosts.get(sessionId);
52
+ if (hosts !== undefined && !validHosts(hosts)) return false;
53
+ const holds: Map<string, Hold> | undefined = current.holds.get(sessionId);
54
+ if (holds === undefined) return true;
55
+ if (!(holds instanceof Map)) return false;
56
+ for (const [id, hold] of holds) {
57
+ if (
58
+ typeof id !== "string" ||
59
+ !id ||
60
+ typeof hold !== "object" ||
61
+ hold === null ||
62
+ typeof hold.provider !== "string" ||
63
+ !hold.provider ||
64
+ hold.ready !== true
65
+ )
66
+ return false;
67
+ }
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function removeHost(sessionId: string, token: string): void {
75
+ // Clean our own token even if another field/version has become malformed.
76
+ const value: unknown = shared[KEY];
77
+ if (typeof value !== "object" || value === null || !("hosts" in value) || !(value.hosts instanceof Map)) return;
78
+ const hosts: unknown = value.hosts.get(sessionId);
79
+ if (!(hosts instanceof Map) || hosts.get("pi-squire") !== token) return;
80
+ hosts.delete("pi-squire");
81
+ if (hosts.size === 0) value.hosts.delete(sessionId);
82
+ }
83
+
84
+ /** Call with ownedAPI: a losing duplicate runtime must never advertise support. */
85
+ export function registerCompletionHost(pi: ExtensionAPI): () => boolean {
86
+ const token: string = randomUUID();
87
+ let sessionId: string | undefined;
88
+ pi.on("session_start", (_event, ctx) => {
89
+ if (sessionId !== undefined) removeHost(sessionId, token);
90
+ sessionId = ctx.sessionManager.getSessionId();
91
+ const current: Table | undefined = table(true);
92
+ if (!current) throw new Error("completion hold table is unavailable");
93
+ const saved: Map<string, string> | undefined = current.hosts.get(sessionId);
94
+ if (saved !== undefined && !validHosts(saved)) throw new Error("invalid completion hold hosts");
95
+ const hosts: Map<string, string> = saved ?? new Map();
96
+ hosts.set("pi-squire", token);
97
+ current.hosts.set(sessionId, hosts);
98
+ });
99
+ pi.on("session_shutdown", () => {
100
+ if (sessionId !== undefined) removeHost(sessionId, token);
101
+ sessionId = undefined;
102
+ });
103
+ return (): boolean => sessionId !== undefined && externalHoldsReady(sessionId);
104
+ }
@@ -0,0 +1,144 @@
1
+ // Presentation only. The navigator resolves these opaque ids and owns every action/refusal.
2
+ import { type Component, isKeyRelease, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { type WidgetTheme, widgetText } from "./widget-render.ts";
4
+
5
+ export type DashboardRow = { id: string; label: string; details: readonly string[]; depth?: number };
6
+ export type DashboardIntent =
7
+ | { action: "switch" | "close" | "peek" | "resume"; id: string }
8
+ | { action: "new" }
9
+ | { action: "close-all" }
10
+ | undefined;
11
+ export type DashboardPosition = { id?: string; detail: boolean; offset: number; branchList?: boolean };
12
+ type DashboardOptions = {
13
+ items: readonly DashboardRow[];
14
+ root?: boolean;
15
+ branch?: string;
16
+ position: DashboardPosition;
17
+ detailBack: "list" | "exit";
18
+ theme: WidgetTheme;
19
+ done(result: DashboardIntent): void;
20
+ redraw(): void;
21
+ rows(): number;
22
+ };
23
+
24
+ function label(row: DashboardRow): string {
25
+ return " ".repeat(Math.max(0, Math.min(64, row.depth ?? 0))) + widgetText(row.label);
26
+ }
27
+
28
+ /** One screen used by command and widget entry, with durable position across action dialogs. */
29
+ export class Dashboard implements Component {
30
+ private readonly options: DashboardOptions;
31
+
32
+ constructor(options: DashboardOptions) {
33
+ this.options = options;
34
+ const found: boolean = options.items.some((row) => row.id === options.position.id);
35
+ if (!found) {
36
+ options.position.id = options.items[0]?.id;
37
+ options.position.offset = 0;
38
+ options.position.detail = false;
39
+ }
40
+ }
41
+
42
+ invalidate(): void {}
43
+
44
+ private selected(): DashboardRow | undefined {
45
+ return this.options.items.find((row) => row.id === this.options.position.id);
46
+ }
47
+
48
+ handleInput(data: string): void {
49
+ if (isKeyRelease(data)) return;
50
+ const { position, done } = this.options;
51
+ if (matchesKey(data, "escape")) {
52
+ if (position.detail && (this.options.detailBack === "list" || position.branchList)) {
53
+ position.detail = false;
54
+ position.offset = 0;
55
+ } else done(undefined);
56
+ } else if (matchesKey(data, "b") && this.options.branch !== undefined) {
57
+ position.branchList = true;
58
+ position.detail = false;
59
+ position.offset = 0;
60
+ } else if (matchesKey(data, "n")) done({ action: "new" });
61
+ else if (matchesKey(data, "a") && this.options.root) done({ action: "close-all" });
62
+ else if (matchesKey(data, "up")) this.move(-1);
63
+ else if (matchesKey(data, "down")) this.move(1);
64
+ else this.action(data);
65
+ this.options.redraw();
66
+ }
67
+
68
+ private action(data: string): void {
69
+ const item: DashboardRow | undefined = this.selected();
70
+ if (!item) return;
71
+ const { position, done } = this.options;
72
+ if (matchesKey(data, "enter")) {
73
+ if (position.detail) done({ action: "switch", id: item.id });
74
+ else {
75
+ position.detail = true;
76
+ position.offset = 0;
77
+ }
78
+ } else if (matchesKey(data, "p")) done({ action: "peek", id: item.id });
79
+ else if (matchesKey(data, "x")) done({ action: "close", id: item.id });
80
+ else if (matchesKey(data, "r")) done({ action: "resume", id: item.id });
81
+ }
82
+
83
+ private move(direction: -1 | 1): void {
84
+ const { items, position } = this.options;
85
+ if (position.detail) {
86
+ const max: number = Math.max(0, (this.selected()?.details.length ?? 0) - this.height());
87
+ position.offset = Math.max(0, Math.min(max, position.offset + direction));
88
+ return;
89
+ }
90
+ const index: number = items.findIndex((row) => row.id === position.id);
91
+ position.id = items[Math.max(0, Math.min(items.length - 1, index + direction))]?.id;
92
+ }
93
+
94
+ private height(): number {
95
+ return Math.max(1, this.options.rows() - 6);
96
+ }
97
+
98
+ render(width: number): string[] {
99
+ const { items, position, theme } = this.options;
100
+ const fit = (text: string): string => truncateToWidth(text, Math.max(0, width));
101
+ const item: DashboardRow | undefined = this.selected();
102
+ if (!item)
103
+ return [theme.bold(this.heading()), "", `No open delegates · n new · esc close${this.closeAllHint()}`].map(fit);
104
+ if (position.detail) return this.details(item).map(fit);
105
+ const index: number = items.indexOf(item);
106
+ const start: number = Math.max(0, index - this.height() + 1);
107
+ const rows: string[] = items
108
+ .slice(start, start + this.height())
109
+ .map((row) => (row.id === item.id ? theme.fg("accent", `▸ ${label(row)}`) : ` ${label(row)}`));
110
+ return [
111
+ theme.bold(this.heading()),
112
+ "",
113
+ ...rows,
114
+ `${index + 1}/${items.length}`,
115
+ theme.fg(
116
+ "dim",
117
+ `↑↓ select · enter details · p peek · x close · r resume · n new · esc leave${this.closeAllHint()}`,
118
+ ),
119
+ ].map(fit);
120
+ }
121
+
122
+ private heading(): string {
123
+ return this.options.branch === undefined ? "Delegates" : `Delegate branch: ${widgetText(this.options.branch)}`;
124
+ }
125
+
126
+ private closeAllHint(): string {
127
+ if (!this.options.root) return "";
128
+ return this.options.branch === undefined ? " · a close all subtrees" : " · a close ALL ROOT subtrees";
129
+ }
130
+
131
+ private details(item: DashboardRow): string[] {
132
+ const { position, theme } = this.options;
133
+ return [
134
+ theme.bold(`Delegate: ${widgetText(item.label)}`),
135
+ "",
136
+ ...item.details.slice(position.offset, position.offset + this.height()).map(widgetText),
137
+ "",
138
+ theme.fg(
139
+ "dim",
140
+ `↑↓ scroll · enter switch · p peek · x close · r resume · n new · esc back${this.options.branch === undefined ? "" : " · b branch list"}${this.closeAllHint()}`,
141
+ ),
142
+ ];
143
+ }
144
+ }
package/src/git.ts CHANGED
@@ -126,7 +126,12 @@ function identify(cwd: string): { identity: GitIdentity } | { launch: GitLaunch
126
126
  // One question per path, so no output is split on a newline a path may contain.
127
127
  const top = askGit(cwd, ["rev-parse", "--show-toplevel"]);
128
128
  if (!top.ok) {
129
- if (top.status === 128 && /not a git repository/i.test(top.message)) return { launch: { kind: "non-git" } };
129
+ if (
130
+ top.status === 128 &&
131
+ /not a git repository [(]or any (?:of the parent directories|parent up to mount point)/i.test(top.message)
132
+ ) {
133
+ return { launch: { kind: "non-git" } };
134
+ }
130
135
  return { launch: { kind: "unknown", reason: `git rev-parse failed: ${top.message}` } };
131
136
  }
132
137
  const common = askGit(cwd, ["rev-parse", "--git-common-dir"]);
@@ -0,0 +1,152 @@
1
+ // Launch identity is session-local durable state, not the shared tree registry.
2
+ // Readers (trust routing, caps and views) use session ids for routing; chain is
3
+ // ordered roster names for labels only. A resume retains the original identity.
4
+ import { readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
7
+ import type { Agent, Roster } from "./agents.ts";
8
+ import { readState, writeState } from "./local-state.ts";
9
+ import { assertRootPlacement } from "./window-placement.ts";
10
+
11
+ export const DELEGATION_TOOLS = ["delegate", "delegation_status", "delegation_close", "delegation_resume"] as const;
12
+ export type Identity = {
13
+ parent: string;
14
+ root: string;
15
+ /** Original root project's preferred companion base, inherited unchanged. */
16
+ rootPlacement: string;
17
+ depth: number;
18
+ agent: string;
19
+ chain: string[];
20
+ autoExit: boolean;
21
+ };
22
+ /** Public, immutable exact-session observation; no internal placement or exit state. */
23
+ export type IdentityNotification =
24
+ | Readonly<{ sessionId: string; status: "root" | "invalid" }>
25
+ | Readonly<{
26
+ sessionId: string;
27
+ status: "delegate";
28
+ identity: Readonly<{
29
+ parent: string;
30
+ root: string;
31
+ agent: string;
32
+ chain: readonly string[];
33
+ depth: number;
34
+ }>;
35
+ }>;
36
+ const pathFor = (session: string): string =>
37
+ join(getAgentDir(), "squire", "identity", `${encodeURIComponent(session)}.json`);
38
+ export function readIdentity(session: string): Identity | undefined {
39
+ const value = readState<Identity>(pathFor(session));
40
+ if (value === undefined) return undefined;
41
+ if (
42
+ !value ||
43
+ typeof value.parent !== "string" ||
44
+ !value.parent ||
45
+ typeof value.root !== "string" ||
46
+ !value.root ||
47
+ !Number.isSafeInteger(value.depth) ||
48
+ value.depth < 1 ||
49
+ typeof value.agent !== "string" ||
50
+ !Array.isArray(value.chain) ||
51
+ !value.chain.every((name) => typeof name === "string") ||
52
+ typeof value.autoExit !== "boolean"
53
+ )
54
+ throw new Error(`invalid delegation identity for ${session}; repair its squire/identity record before reopening`);
55
+ assertRootPlacement(value.rootPlacement);
56
+ return value;
57
+ }
58
+ export const writeIdentity = (session: string, identity: Identity): void => writeState(pathFor(session), identity);
59
+
60
+ export function maxDepth(): number {
61
+ const file = join(getAgentDir(), "squire", "settings.json");
62
+ let settings: { maxDepth?: unknown };
63
+ try {
64
+ settings = JSON.parse(readFileSync(file, "utf8")) as typeof settings;
65
+ } catch {
66
+ // Match existing settings readers: an unreadable file grants only the
67
+ // safe single-level default; a readable invalid maxDepth refuses.
68
+ return 1;
69
+ }
70
+ const value = settings?.maxDepth ?? 1;
71
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
72
+ throw new Error(`invalid maxDepth in ${file}: expected a positive integer`);
73
+ return value;
74
+ }
75
+
76
+ export function grantsNesting(agent: Agent, depth: number): boolean {
77
+ return (agent.allowNested === true || (agent.allowNested?.length ?? 0) > 0) && depth < maxDepth();
78
+ }
79
+
80
+ export function createIdentity(pi: ExtensionAPI): {
81
+ current(): Identity | undefined;
82
+ child(agent: Agent, ctx: ExtensionContext, rootPlacement: string): Identity;
83
+ admit(agent: string, roster: Roster): void;
84
+ } {
85
+ // Launch writes exact-id identity before spawn. Inherited environment is
86
+ // labels for integrations, never authority for a new/forked session.
87
+ let current: Identity | undefined;
88
+ let problem: unknown;
89
+ pi.on("session_start", (_event, ctx) => {
90
+ const sessionId = ctx.sessionManager.getSessionId();
91
+ current = undefined;
92
+ problem = undefined;
93
+ try {
94
+ const restored = readIdentity(sessionId);
95
+ if (restored) writeIdentity(sessionId, restored);
96
+ current = restored;
97
+ } catch (error) {
98
+ problem = error;
99
+ }
100
+ const notification: IdentityNotification = current
101
+ ? Object.freeze({
102
+ sessionId,
103
+ status: "delegate",
104
+ identity: Object.freeze({
105
+ parent: current.parent,
106
+ root: current.root,
107
+ agent: current.agent,
108
+ chain: Object.freeze([...current.chain]),
109
+ depth: current.depth,
110
+ }),
111
+ })
112
+ : Object.freeze({ sessionId, status: problem ? "invalid" : "root" });
113
+ // Synchronous listeners run before later session_start handlers or async work.
114
+ // This is a notification, not a gate or listener-success acknowledgement.
115
+ pi.events.emit("squire:identity", notification);
116
+ if (problem)
117
+ ctx.ui.notify(
118
+ `delegate: could not restore delegation identity: ${String(problem)}; launches and completion are blocked.`,
119
+ "error",
120
+ );
121
+ });
122
+ return {
123
+ current: () => {
124
+ if (problem) throw problem;
125
+ return current;
126
+ },
127
+ child: (agent: Agent, ctx: ExtensionContext, rootPlacement: string): Identity => ({
128
+ parent: ctx.sessionManager.getSessionId(),
129
+ root: current?.root ?? ctx.sessionManager.getSessionId(),
130
+ rootPlacement: current?.rootPlacement ?? rootPlacement,
131
+ depth: (current?.depth ?? 0) + 1,
132
+ agent: agent.name,
133
+ chain: [...(current?.chain ?? []), agent.name],
134
+ autoExit: agent.autoExit,
135
+ }),
136
+ admit: (agent: string, roster: Roster): void => {
137
+ if (problem) throw problem;
138
+ const limit = maxDepth();
139
+ if (!current) return;
140
+ if (!Number.isSafeInteger(current.depth) || current.depth < 1 || current.depth >= limit)
141
+ throw new Error(
142
+ `nested delegation refused at depth ${current.depth}: maxDepth is ${limit} in squire/settings.json`,
143
+ );
144
+ const own = roster.agents.find((item) => item.name === current?.agent);
145
+ const grant = own?.allowNested;
146
+ if (grant === true || (Array.isArray(grant) && grant.includes(agent))) return;
147
+ throw new Error(
148
+ `agent ${JSON.stringify(current.agent)} does not permit nested agent ${JSON.stringify(agent)}; permitted agents: ${Array.isArray(grant) && grant.length ? grant.join(", ") : "none"}. Set allow_nested_delegation in the parent's current agent definition.`,
149
+ );
150
+ },
151
+ };
152
+ }