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/dashboard.ts
ADDED
|
@@ -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 (
|
|
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"]);
|
package/src/identity.ts
ADDED
|
@@ -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
|
+
}
|