@zhuxixi/pi-agent-board 0.3.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/IMPLEMENTATION_PLAN.md +920 -0
- package/LICENSE +21 -0
- package/PRD.md +484 -0
- package/PROGRESS.md +127 -0
- package/README.md +131 -0
- package/VERIFY.md +113 -0
- package/docs/BATCH_SELECTION_READ_FLOW.md +277 -0
- package/docs/EXPLORATION.md +187 -0
- package/docs/PTY_ATTACH_IMPLEMENTATION_PLAN.md +579 -0
- package/docs/superpowers/plans/2026-08-15-screenlog-gc.md +704 -0
- package/docs/superpowers/plans/2026-08-16-attach-double-cursor-jiggle-retry.md +499 -0
- package/docs/superpowers/plans/2026-08-21-dashboard-keypress-lag.md +366 -0
- package/docs/superpowers/specs/2026-08-15-screenlog-gc-design.md +105 -0
- package/docs/superpowers/specs/2026-08-16-attach-double-cursor-jiggle-retry-design.md +142 -0
- package/docs/superpowers/specs/2026-08-21-dashboard-keypress-lag-design.md +59 -0
- package/index.ts +6 -0
- package/package.json +81 -0
- package/runner/job-runner.mjs +420 -0
- package/runner/pty-runner.mjs +310 -0
- package/runner/state-runner.mjs +120 -0
- package/runner/title-runner.mjs +80 -0
- package/scripts/patch-vulns.mjs +59 -0
- package/src/commands/agent-board.ts +318 -0
- package/src/commands/attach-flow.ts +231 -0
- package/src/commands/bg.ts +70 -0
- package/src/core/atomic.mjs +145 -0
- package/src/core/auto-state.mjs +320 -0
- package/src/core/dashboard-render.mjs +10 -0
- package/src/core/derive.mjs +114 -0
- package/src/core/diagnostics.mjs +109 -0
- package/src/core/events.mjs +268 -0
- package/src/core/evidence.mjs +242 -0
- package/src/core/follow-up-queue.mjs +193 -0
- package/src/core/heuristics.mjs +240 -0
- package/src/core/ids.mjs +35 -0
- package/src/core/invocation.mjs +43 -0
- package/src/core/launch-options.mjs +317 -0
- package/src/core/launch.mjs +116 -0
- package/src/core/locks.mjs +80 -0
- package/src/core/paths.mjs +86 -0
- package/src/core/pid.mjs +42 -0
- package/src/core/prewarm-schedule.mjs +41 -0
- package/src/core/prompt-transport.mjs +13 -0
- package/src/core/pty-attach-jiggle-retry.mjs +90 -0
- package/src/core/pty-attach-render.mjs +51 -0
- package/src/core/pty-input.mjs +15 -0
- package/src/core/pty-links.mjs +71 -0
- package/src/core/pty-scroll.mjs +155 -0
- package/src/core/pty-support.mjs +327 -0
- package/src/core/repo.mjs +47 -0
- package/src/core/rows.mjs +290 -0
- package/src/core/screen-log-gc.mjs +198 -0
- package/src/core/screen-log.mjs +160 -0
- package/src/core/session-view.mjs +174 -0
- package/src/core/steering-prompts.mjs +34 -0
- package/src/core/steering.mjs +133 -0
- package/src/core/store.mjs +308 -0
- package/src/core/title.mjs +43 -0
- package/src/core/types.mjs +380 -0
- package/src/core/worktree.mjs +64 -0
- package/src/index.ts +109 -0
- package/src/runtime/service.mjs +1194 -0
- package/src/ui/dashboard-evidence.mjs +85 -0
- package/src/ui/dashboard.ts +1952 -0
- package/src/ui/pty-attach.ts +1378 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/agent-board` command: opens the dashboard, runs its action loop, and attaches through
|
|
3
|
+
* agent-board PTY hosts for fast switching. `ctx.switchSession` is only a no-PTY fallback.
|
|
4
|
+
* Also wires dispatch+attach and stale-row recovery on open.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { requestDashboardRender } from "../core/dashboard-render.mjs";
|
|
11
|
+
import { createService } from "../runtime/service.mjs";
|
|
12
|
+
import { screenLogPath } from "../core/paths.mjs";
|
|
13
|
+
import { DashboardComponent, type DashboardResult } from "../ui/dashboard.js";
|
|
14
|
+
import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
|
|
15
|
+
|
|
16
|
+
const POLL_MS = 700;
|
|
17
|
+
|
|
18
|
+
export interface AgentBoardCommandOptions {
|
|
19
|
+
root: string;
|
|
20
|
+
runnerScript: string;
|
|
21
|
+
ptyRunnerScript?: string;
|
|
22
|
+
titleRunnerScript?: string;
|
|
23
|
+
piCommand: string;
|
|
24
|
+
piArgsPrefix: string[];
|
|
25
|
+
getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
29
|
+
|
|
30
|
+
export function registerAgentBoardCommand(pi: ExtensionAPI, opts: AgentBoardCommandOptions): void {
|
|
31
|
+
pi.registerCommand("agent-board", {
|
|
32
|
+
description: "Open the background agent-board dashboard",
|
|
33
|
+
handler: async (args, ctx) => {
|
|
34
|
+
const attachMatch = /(?:^|\s)--attach\s+(\S+)/.exec(args);
|
|
35
|
+
const stopFirst = /(^|\s)--stop(\s|$)/.test(args);
|
|
36
|
+
const service = createService({
|
|
37
|
+
root: opts.root,
|
|
38
|
+
runnerScript: opts.runnerScript,
|
|
39
|
+
ptyRunnerScript: opts.ptyRunnerScript,
|
|
40
|
+
titleRunnerScript: opts.titleRunnerScript,
|
|
41
|
+
piCommand: opts.piCommand,
|
|
42
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
43
|
+
defaultCwd: ctx.cwd,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (!ctx.hasUI) {
|
|
47
|
+
ctx.ui.notify("The agent-board dashboard requires interactive mode.", "warning");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (attachMatch) {
|
|
52
|
+
const outcome = await attach(ctx, service, opts.root, attachMatch[1], stopFirst);
|
|
53
|
+
if (outcome.action !== "switched") await dashboardAttachLoop(ctx, service, opts.root, attachMatch[1], opts.getThinkingLevel);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await dashboardAttachLoop(ctx, service, opts.root, null, opts.getThinkingLevel);
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function openDashboard(
|
|
63
|
+
ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model">,
|
|
64
|
+
service: ReturnType<typeof createService>,
|
|
65
|
+
options: {
|
|
66
|
+
initialSelectedId?: string | null;
|
|
67
|
+
currentThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
68
|
+
} = {},
|
|
69
|
+
): Promise<DashboardResult> {
|
|
70
|
+
ctx.ui.setWorkingVisible(false);
|
|
71
|
+
ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
|
|
72
|
+
ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
|
|
73
|
+
ctx.ui.setTitle("agent-board");
|
|
74
|
+
let availableModels: any[] = [];
|
|
75
|
+
try {
|
|
76
|
+
availableModels = ctx.modelRegistry.getAvailable();
|
|
77
|
+
} catch {
|
|
78
|
+
availableModels = [];
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await ctx.ui.custom<DashboardResult>(
|
|
82
|
+
(tui, theme, keybindings, done) => {
|
|
83
|
+
let interval: ReturnType<typeof setInterval> | null = null;
|
|
84
|
+
const wrappedDone = (result: DashboardResult) => {
|
|
85
|
+
if (interval) clearInterval(interval);
|
|
86
|
+
interval = null;
|
|
87
|
+
done(result);
|
|
88
|
+
};
|
|
89
|
+
const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
|
|
90
|
+
service,
|
|
91
|
+
defaultCwd: ctx.cwd,
|
|
92
|
+
initialSelectedId: options.initialSelectedId,
|
|
93
|
+
availableModels,
|
|
94
|
+
currentModel: ctx.model ?? null,
|
|
95
|
+
currentThinkingLevel: options.currentThinkingLevel ?? "off",
|
|
96
|
+
});
|
|
97
|
+
interval = setInterval(() => {
|
|
98
|
+
service.reconcile();
|
|
99
|
+
comp.refresh();
|
|
100
|
+
requestDashboardRender(tui);
|
|
101
|
+
}, POLL_MS);
|
|
102
|
+
const withDispose = comp as DashboardComponent & { dispose: () => void };
|
|
103
|
+
// Chain the component's own dispose so its cleanup (prewarm scheduler
|
|
104
|
+
// cancel) still runs; a plain assignment would shadow the prototype method.
|
|
105
|
+
const origDispose = comp.dispose.bind(comp);
|
|
106
|
+
withDispose.dispose = () => {
|
|
107
|
+
origDispose();
|
|
108
|
+
if (interval) clearInterval(interval);
|
|
109
|
+
interval = null;
|
|
110
|
+
};
|
|
111
|
+
return comp;
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
overlay: true,
|
|
115
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
} finally {
|
|
119
|
+
ctx.ui.setHeader(undefined);
|
|
120
|
+
ctx.ui.setFooter(undefined);
|
|
121
|
+
ctx.ui.setWorkingVisible(true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type AttachOutcome = { action: "detached" | "closed" | "switched" | "none" };
|
|
126
|
+
|
|
127
|
+
export async function dashboardAttachLoop(
|
|
128
|
+
ctx: ExtensionCommandContext,
|
|
129
|
+
service: ReturnType<typeof createService>,
|
|
130
|
+
root: string,
|
|
131
|
+
initialSelectedId: string | null,
|
|
132
|
+
getThinkingLevel?: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max",
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
let selectedId = initialSelectedId;
|
|
135
|
+
let again = true;
|
|
136
|
+
while (again) {
|
|
137
|
+
const currentId = currentViewId(ctx, service);
|
|
138
|
+
if (currentId) service.markVisited?.(currentId);
|
|
139
|
+
service.reconcile();
|
|
140
|
+
const result = await openDashboard(ctx, service, {
|
|
141
|
+
initialSelectedId: selectedId,
|
|
142
|
+
currentThinkingLevel: getThinkingLevel?.(),
|
|
143
|
+
});
|
|
144
|
+
if (result.action !== "attach") return;
|
|
145
|
+
selectedId = result.viewId;
|
|
146
|
+
const outcome = await attach(ctx, service, root, result.viewId, result.stopFirst);
|
|
147
|
+
again = outcome.action === "detached" || outcome.action === "closed" || outcome.action === "none";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function attach(
|
|
152
|
+
ctx: ExtensionCommandContext,
|
|
153
|
+
service: ReturnType<typeof createService>,
|
|
154
|
+
root: string,
|
|
155
|
+
viewId: string,
|
|
156
|
+
stopFirst: boolean,
|
|
157
|
+
): Promise<AttachOutcome> {
|
|
158
|
+
const row = service.row(viewId);
|
|
159
|
+
if (!row) {
|
|
160
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
161
|
+
return { action: "none" };
|
|
162
|
+
}
|
|
163
|
+
if (stopFirst && row.alive && !row.hostAlive) {
|
|
164
|
+
service.stop(viewId);
|
|
165
|
+
// Give the runner a moment to terminate the worker and release the session file.
|
|
166
|
+
await sleep(500);
|
|
167
|
+
} else if (row.alive && !row.hostAlive) {
|
|
168
|
+
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
169
|
+
return { action: "none" };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const target = service.attachTarget(viewId);
|
|
173
|
+
if (target.kind === "pty" && target.socketPath) {
|
|
174
|
+
service.markVisited?.(viewId);
|
|
175
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, target.socketPath);
|
|
176
|
+
service.markVisited?.(viewId);
|
|
177
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ensured = service.ensureHost(viewId);
|
|
181
|
+
if (ensured.ok && ensured.socketPath) {
|
|
182
|
+
service.markVisited?.(viewId);
|
|
183
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, ensured.socketPath);
|
|
184
|
+
service.markVisited?.(viewId);
|
|
185
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const latest = service.row(viewId) ?? row;
|
|
189
|
+
if (!existsSync(latest.meta.sessionFile)) {
|
|
190
|
+
ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
|
|
191
|
+
return { action: "none" };
|
|
192
|
+
}
|
|
193
|
+
const name = latest.meta.name;
|
|
194
|
+
service.markVisited?.(viewId);
|
|
195
|
+
const switchingOverlay = await showSwitchingOverlay(ctx, name, ensured.fallbackReason ?? ensured.error ?? "PTY unavailable");
|
|
196
|
+
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
197
|
+
withSession: async (replaced) => {
|
|
198
|
+
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
199
|
+
installBackToDashboard(replaced, service);
|
|
200
|
+
},
|
|
201
|
+
}).finally(() => {
|
|
202
|
+
try {
|
|
203
|
+
switchingOverlay?.hide();
|
|
204
|
+
} catch {}
|
|
205
|
+
});
|
|
206
|
+
if (result.cancelled) {
|
|
207
|
+
ctx.ui.notify("Attach cancelled.", "warning");
|
|
208
|
+
return { action: "none" };
|
|
209
|
+
}
|
|
210
|
+
return { action: "switched" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function openPtyAttach(
|
|
214
|
+
ctx: ExtensionCommandContext,
|
|
215
|
+
root: string,
|
|
216
|
+
viewId: string,
|
|
217
|
+
name: string,
|
|
218
|
+
socketPath: string,
|
|
219
|
+
): Promise<PtyAttachResult> {
|
|
220
|
+
ctx.ui.setWorkingVisible(false);
|
|
221
|
+
ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
|
|
222
|
+
ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
|
|
223
|
+
ctx.ui.setTitle(`agent-board: ${name}`);
|
|
224
|
+
try {
|
|
225
|
+
return await ctx.ui.custom<PtyAttachResult>(
|
|
226
|
+
(tui, theme, keybindings, done) =>
|
|
227
|
+
new PtyAttachComponent(tui, theme as never, keybindings, done, {
|
|
228
|
+
socketPath,
|
|
229
|
+
screenLogPath: root ? screenLogPath(root, viewId) : undefined,
|
|
230
|
+
title: name,
|
|
231
|
+
}),
|
|
232
|
+
{
|
|
233
|
+
overlay: true,
|
|
234
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
} finally {
|
|
238
|
+
ctx.ui.setHeader(undefined);
|
|
239
|
+
ctx.ui.setFooter(undefined);
|
|
240
|
+
ctx.ui.setWorkingVisible(true);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function showSwitchingOverlay(ctx: ExtensionCommandContext, name: string, reason: string): Promise<{ hide(): void } | null> {
|
|
245
|
+
let handle: { hide(): void } | null = null;
|
|
246
|
+
void ctx.ui.custom<null>(
|
|
247
|
+
(tui, theme) => ({
|
|
248
|
+
render(width: number): string[] {
|
|
249
|
+
const height = tui.terminal?.rows ?? 24;
|
|
250
|
+
const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => "");
|
|
251
|
+
out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width));
|
|
252
|
+
out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width));
|
|
253
|
+
while (out.length < height) out.push("");
|
|
254
|
+
return out;
|
|
255
|
+
},
|
|
256
|
+
invalidate() {},
|
|
257
|
+
}),
|
|
258
|
+
{
|
|
259
|
+
overlay: true,
|
|
260
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
261
|
+
onHandle: (h) => {
|
|
262
|
+
handle = h;
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
await sleep(50);
|
|
267
|
+
return handle;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function clipLine(text: string, width: number): string {
|
|
271
|
+
return truncateToWidth(text, width);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function installBackToDashboard(ctx: ExtensionCommandContext, service: ReturnType<typeof createService>): void {
|
|
275
|
+
ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board"));
|
|
276
|
+
let opening = false;
|
|
277
|
+
ctx.ui.onTerminalInput((data: string) => {
|
|
278
|
+
if (opening || !matchesKey(data, Key.left)) return undefined;
|
|
279
|
+
// Do not steal normal cursor-left while the user is composing a message.
|
|
280
|
+
if (ctx.ui.getEditorText().length > 0) return undefined;
|
|
281
|
+
opening = true;
|
|
282
|
+
void (async () => {
|
|
283
|
+
try {
|
|
284
|
+
let selectedId = currentViewId(ctx, service);
|
|
285
|
+
while (true) {
|
|
286
|
+
if (selectedId) service.markVisited?.(selectedId);
|
|
287
|
+
service.reconcile();
|
|
288
|
+
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
289
|
+
if (result.action !== "attach") return;
|
|
290
|
+
selectedId = result.viewId;
|
|
291
|
+
const target = service.row(result.viewId);
|
|
292
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
293
|
+
if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) {
|
|
294
|
+
ctx.ui.notify("Already attached to this session.", "info");
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst);
|
|
298
|
+
if (outcome.action === "switched") return;
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
302
|
+
} finally {
|
|
303
|
+
opening = false;
|
|
304
|
+
}
|
|
305
|
+
})();
|
|
306
|
+
return { consume: true };
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function currentViewId(ctx: ExtensionCommandContext, service: ReturnType<typeof createService>): string | null {
|
|
311
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
312
|
+
if (!currentSessionFile) return null;
|
|
313
|
+
return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function samePath(a: string, b: string): boolean {
|
|
317
|
+
return resolve(a) === resolve(b);
|
|
318
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable attach-flow helpers shared by the `agent-board` and `bg` commands.
|
|
3
|
+
*
|
|
4
|
+
* These are pure logic extractions — no runtime behaviour changes. Every
|
|
5
|
+
* function here was previously private in agent-board.ts.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
import { createService } from "../runtime/service.mjs";
|
|
12
|
+
import { screenLogPath } from "../core/paths.mjs";
|
|
13
|
+
import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
|
|
14
|
+
import type { DashboardResult } from "../ui/dashboard.js";
|
|
15
|
+
|
|
16
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
17
|
+
|
|
18
|
+
export type AttachOutcome = { action: "detached" | "closed" | "switched" | "none" };
|
|
19
|
+
|
|
20
|
+
/** Open the PTY-attach UI for a live agent session. */
|
|
21
|
+
export async function openPtyAttach(
|
|
22
|
+
ctx: ExtensionCommandContext,
|
|
23
|
+
root: string,
|
|
24
|
+
viewId: string,
|
|
25
|
+
name: string,
|
|
26
|
+
socketPath: string,
|
|
27
|
+
): Promise<PtyAttachResult> {
|
|
28
|
+
ctx.ui.setWorkingVisible(false);
|
|
29
|
+
ctx.ui.setHeader(() => ({ render: () => [], invalidate() {} }));
|
|
30
|
+
ctx.ui.setFooter(() => ({ render: () => [], invalidate() {} }));
|
|
31
|
+
ctx.ui.setTitle(`agent-board: ${name}`);
|
|
32
|
+
try {
|
|
33
|
+
return await ctx.ui.custom<PtyAttachResult>(
|
|
34
|
+
(tui, theme, keybindings, done) =>
|
|
35
|
+
new PtyAttachComponent(tui, theme as never, keybindings, done, {
|
|
36
|
+
socketPath,
|
|
37
|
+
screenLogPath: root ? screenLogPath(root, viewId) : undefined,
|
|
38
|
+
title: name,
|
|
39
|
+
}),
|
|
40
|
+
{
|
|
41
|
+
overlay: true,
|
|
42
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
} finally {
|
|
46
|
+
ctx.ui.setHeader(undefined);
|
|
47
|
+
ctx.ui.setFooter(undefined);
|
|
48
|
+
ctx.ui.setWorkingVisible(true);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Show a brief overlay while the session switch is being initiated. */
|
|
53
|
+
export async function showSwitchingOverlay(
|
|
54
|
+
ctx: ExtensionCommandContext,
|
|
55
|
+
name: string,
|
|
56
|
+
reason: string,
|
|
57
|
+
): Promise<{ hide(): void } | null> {
|
|
58
|
+
let handle: { hide(): void } | null = null;
|
|
59
|
+
void ctx.ui.custom<null>(
|
|
60
|
+
(tui, theme) => ({
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
const height = tui.terminal?.rows ?? 24;
|
|
63
|
+
const out = Array.from({ length: Math.max(0, Math.floor(height / 2) - 2) }, () => "");
|
|
64
|
+
out.push(clipLine(theme.fg("accent", theme.bold(`Switching to "${name}"…`)), width));
|
|
65
|
+
out.push(clipLine(theme.fg("dim", `Starting fallback session switch (${reason})`), width));
|
|
66
|
+
while (out.length < height) out.push("");
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
invalidate() {},
|
|
70
|
+
}),
|
|
71
|
+
{
|
|
72
|
+
overlay: true,
|
|
73
|
+
overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%", margin: 0 },
|
|
74
|
+
onHandle: (h) => {
|
|
75
|
+
handle = h;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
await sleep(50);
|
|
80
|
+
return handle;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clipLine(text: string, width: number): string {
|
|
84
|
+
return truncateToWidth(text, width);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Return the viewId of whichever agent session matches the current pi session file. */
|
|
88
|
+
export function currentViewId(
|
|
89
|
+
ctx: ExtensionCommandContext,
|
|
90
|
+
service: ReturnType<typeof createService>,
|
|
91
|
+
): string | null {
|
|
92
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
93
|
+
if (!currentSessionFile) return null;
|
|
94
|
+
return service.rows().find((r) => samePath(r.meta.sessionFile, currentSessionFile))?.meta.id ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function samePath(a: string, b: string): boolean {
|
|
98
|
+
return resolve(a) === resolve(b);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Attach to an agent session, preferring PTY if available and falling back to
|
|
103
|
+
* a session-switch overlay.
|
|
104
|
+
*/
|
|
105
|
+
export async function attach(
|
|
106
|
+
ctx: ExtensionCommandContext,
|
|
107
|
+
service: ReturnType<typeof createService>,
|
|
108
|
+
root: string,
|
|
109
|
+
viewId: string,
|
|
110
|
+
stopFirst: boolean,
|
|
111
|
+
): Promise<AttachOutcome> {
|
|
112
|
+
const row = service.row(viewId);
|
|
113
|
+
if (!row) {
|
|
114
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
115
|
+
return { action: "none" };
|
|
116
|
+
}
|
|
117
|
+
if (stopFirst && row.alive && !row.hostAlive) {
|
|
118
|
+
service.stop(viewId);
|
|
119
|
+
// Give the runner a moment to terminate the worker and release the session file.
|
|
120
|
+
await sleep(500);
|
|
121
|
+
} else if (row.alive && !row.hostAlive) {
|
|
122
|
+
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
123
|
+
return { action: "none" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const target = service.attachTarget(viewId);
|
|
127
|
+
if (target.kind === "pty" && target.socketPath) {
|
|
128
|
+
service.markVisited?.(viewId);
|
|
129
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, target.socketPath);
|
|
130
|
+
service.markVisited?.(viewId);
|
|
131
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ensured = service.ensureHost(viewId);
|
|
135
|
+
if (ensured.ok && ensured.socketPath) {
|
|
136
|
+
service.markVisited?.(viewId);
|
|
137
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, ensured.socketPath);
|
|
138
|
+
service.markVisited?.(viewId);
|
|
139
|
+
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const latest = service.row(viewId) ?? row;
|
|
143
|
+
if (!existsSync(latest.meta.sessionFile)) {
|
|
144
|
+
ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
|
|
145
|
+
return { action: "none" };
|
|
146
|
+
}
|
|
147
|
+
const name = latest.meta.name;
|
|
148
|
+
service.markVisited?.(viewId);
|
|
149
|
+
const switchingOverlay = await showSwitchingOverlay(ctx, name, ensured.fallbackReason ?? ensured.error ?? "PTY unavailable");
|
|
150
|
+
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
151
|
+
withSession: async (replaced) => {
|
|
152
|
+
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
153
|
+
installBackToDashboard(replaced, service, openDashboardFn);
|
|
154
|
+
},
|
|
155
|
+
}).finally(() => {
|
|
156
|
+
try {
|
|
157
|
+
switchingOverlay?.hide();
|
|
158
|
+
} catch {}
|
|
159
|
+
});
|
|
160
|
+
if (result.cancelled) {
|
|
161
|
+
ctx.ui.notify("Attach cancelled.", "warning");
|
|
162
|
+
return { action: "none" };
|
|
163
|
+
}
|
|
164
|
+
return { action: "switched" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The openDashboard function reference used by installBackToDashboard when called
|
|
169
|
+
* from attach(). This is set at module initialisation time by agent-board.ts via
|
|
170
|
+
* setOpenDashboardFn so that attach-flow.ts does not need to import from agent-board.ts
|
|
171
|
+
* (which would create a circular dependency).
|
|
172
|
+
*/
|
|
173
|
+
type OpenDashboardFn = (
|
|
174
|
+
ctx: Pick<ExtensionCommandContext, "ui" | "cwd" | "modelRegistry" | "model">,
|
|
175
|
+
service: ReturnType<typeof createService>,
|
|
176
|
+
options?: { initialSelectedId?: string | null },
|
|
177
|
+
) => Promise<DashboardResult>;
|
|
178
|
+
|
|
179
|
+
let openDashboardFn: OpenDashboardFn = async () => {
|
|
180
|
+
throw new Error("openDashboardFn not initialised — call setOpenDashboardFn first");
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Called once by agent-board.ts to wire up the openDashboard dependency. */
|
|
184
|
+
export function setOpenDashboardFn(fn: OpenDashboardFn): void {
|
|
185
|
+
openDashboardFn = fn;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Install the ← keybinding that opens the dashboard from inside an attached session.
|
|
190
|
+
* `openDashboard` is accepted as a parameter to avoid a circular import between
|
|
191
|
+
* agent-board.ts and attach-flow.ts.
|
|
192
|
+
*/
|
|
193
|
+
export function installBackToDashboard(
|
|
194
|
+
ctx: ExtensionCommandContext,
|
|
195
|
+
service: ReturnType<typeof createService>,
|
|
196
|
+
openDashboard: OpenDashboardFn,
|
|
197
|
+
): void {
|
|
198
|
+
ctx.ui.setStatus("agent-board.back", ctx.ui.theme.fg("muted", "← board"));
|
|
199
|
+
let opening = false;
|
|
200
|
+
ctx.ui.onTerminalInput((data: string) => {
|
|
201
|
+
if (opening || !matchesKey(data, Key.left)) return undefined;
|
|
202
|
+
// Do not steal normal cursor-left while the user is composing a message.
|
|
203
|
+
if (ctx.ui.getEditorText().length > 0) return undefined;
|
|
204
|
+
opening = true;
|
|
205
|
+
void (async () => {
|
|
206
|
+
try {
|
|
207
|
+
let selectedId = currentViewId(ctx, service);
|
|
208
|
+
while (true) {
|
|
209
|
+
if (selectedId) service.markVisited?.(selectedId);
|
|
210
|
+
service.reconcile();
|
|
211
|
+
const result = await openDashboard(ctx, service, { initialSelectedId: selectedId });
|
|
212
|
+
if (result.action !== "attach") return;
|
|
213
|
+
selectedId = result.viewId;
|
|
214
|
+
const target = service.row(result.viewId);
|
|
215
|
+
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
216
|
+
if (target && currentSessionFile && samePath(target.meta.sessionFile, currentSessionFile)) {
|
|
217
|
+
ctx.ui.notify("Already attached to this session.", "info");
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const outcome = await attach(ctx, service, service.root, result.viewId, result.stopFirst);
|
|
221
|
+
if (outcome.action === "switched") return;
|
|
222
|
+
}
|
|
223
|
+
} catch (err) {
|
|
224
|
+
ctx.ui.notify(`Couldn't open agent board: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
225
|
+
} finally {
|
|
226
|
+
opening = false;
|
|
227
|
+
}
|
|
228
|
+
})();
|
|
229
|
+
return { consume: true };
|
|
230
|
+
});
|
|
231
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** `/bg` command: adopt the current interactive Pi session into Agent Board. */
|
|
2
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createService } from "../runtime/service.mjs";
|
|
4
|
+
import { dashboardAttachLoop } from "./agent-board.js";
|
|
5
|
+
|
|
6
|
+
export interface BgCommandOptions {
|
|
7
|
+
root: string;
|
|
8
|
+
runnerScript: string;
|
|
9
|
+
ptyRunnerScript?: string;
|
|
10
|
+
titleRunnerScript?: string;
|
|
11
|
+
piCommand: string;
|
|
12
|
+
piArgsPrefix: string[];
|
|
13
|
+
getThinkingLevel: () => "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerBgCommand(pi: ExtensionAPI, opts: BgCommandOptions): void {
|
|
17
|
+
pi.registerCommand("bg", {
|
|
18
|
+
description: "Adopt the current session into Agent Board and optionally queue a prompt",
|
|
19
|
+
handler: async (args, ctx) => {
|
|
20
|
+
await handleBgCommand(args, ctx, opts);
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleBgCommand(args: string, ctx: ExtensionCommandContext, opts: BgCommandOptions): Promise<void> {
|
|
26
|
+
if (!ctx.hasUI) {
|
|
27
|
+
ctx.ui.notify("/bg requires interactive mode.", "warning");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
31
|
+
if (!sessionFile) {
|
|
32
|
+
ctx.ui.notify("No current session file is available to background.", "warning");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const service = createService({
|
|
36
|
+
root: opts.root,
|
|
37
|
+
runnerScript: opts.runnerScript,
|
|
38
|
+
ptyRunnerScript: opts.ptyRunnerScript,
|
|
39
|
+
titleRunnerScript: opts.titleRunnerScript,
|
|
40
|
+
piCommand: opts.piCommand,
|
|
41
|
+
piArgsPrefix: opts.piArgsPrefix,
|
|
42
|
+
defaultCwd: ctx.cwd,
|
|
43
|
+
});
|
|
44
|
+
const model = modelRef(ctx.model as any);
|
|
45
|
+
const adopted = service.adoptSession({
|
|
46
|
+
sessionFile,
|
|
47
|
+
cwd: ctx.cwd,
|
|
48
|
+
model,
|
|
49
|
+
thinkingLevel: opts.getThinkingLevel(),
|
|
50
|
+
name: "background-session",
|
|
51
|
+
});
|
|
52
|
+
if (!adopted.ok || !adopted.viewId) {
|
|
53
|
+
ctx.ui.notify(adopted.error ?? "Could not background current session.", "warning");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const prompt = String(args || "").trim();
|
|
57
|
+
if (prompt) {
|
|
58
|
+
const reply = service.queueFollowUp(adopted.viewId, prompt, { delivery: "queue", source: "bg-command" }) as { ok: boolean; error?: string };
|
|
59
|
+
if (!reply.ok) ctx.ui.notify(reply.error ?? "Could not queue background prompt.", "warning");
|
|
60
|
+
else ctx.ui.notify("Prompt queued for background session.", "info");
|
|
61
|
+
}
|
|
62
|
+
await dashboardAttachLoop(ctx, service, opts.root, adopted.viewId, opts.getThinkingLevel);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function modelRef(model: any): string | null {
|
|
66
|
+
if (!model || typeof model !== "object") return null;
|
|
67
|
+
if (typeof model.provider === "string" && typeof model.id === "string") return `${model.provider}/${model.id}`;
|
|
68
|
+
if (typeof model.id === "string") return model.id;
|
|
69
|
+
return null;
|
|
70
|
+
}
|