pi-optchat 0.6.2 → 0.6.4
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/README.md +3 -0
- package/package.json +1 -1
- package/src/agent-view.ts +2 -1
- package/src/agents.ts +1 -1
- package/src/connected-window.ts +102 -17
- package/src/index.ts +40 -8
- package/src/report-message.ts +34 -0
- package/src/title.ts +31 -0
- package/src/transcript.ts +8 -1
- package/src/window-bridge.ts +51 -9
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
69
69
|
|
|
70
70
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
71
71
|
- Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
|
|
72
|
+
- In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
|
|
72
73
|
- The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
|
|
73
74
|
- Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
|
|
74
75
|
- At most 8 agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
|
|
@@ -139,6 +140,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
|
|
|
139
140
|
Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent.
|
|
140
141
|
|
|
141
142
|
- Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
|
|
143
|
+
- The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
|
|
142
144
|
- The subagent runs inside the original process, which stays the only writer of memory. It appears in the original window's inspector and uses one of the 8 agent slots. While it is open, the original window can't switch profile or import.
|
|
143
145
|
- The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent can use `tell_parent`, and the main agent replies with `tell`. Routine turns don't wake the main agent.
|
|
144
146
|
- Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
|
|
@@ -173,6 +175,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
173
175
|
## Good to know
|
|
174
176
|
|
|
175
177
|
- **Profiles separate memory and instructions only.** Agents keep full filesystem access and share provider credentials.
|
|
178
|
+
- **Tab title**: the terminal tab shows the profile and what it is doing: `π personal` while waiting for you, `● π personal` while the agent works, plus `· 2 agents` while subagents run. A connected window shows `↳ personal`, `● ↳ personal`, then `↳ personal · done` or `↳ personal · disconnected`. OptChat replaces Pi's default title and puts its own back when Pi resets it (new session, reload, rename).
|
|
176
179
|
- **Use worktrees** when parallel agents edit the same repository; they share the filesystem.
|
|
177
180
|
- **Instructions**: the main agent and subagents get Pi's usual global and repository `AGENTS.md` files and your skills, followed by the profile's `AGENTS.md`, which comes last and wins. OptChat replaces only Pi's opening prompt. Prompt templates work in the main session only.
|
|
178
181
|
- **Images** are available during the current run but stored in memory as text placeholders.
|
package/package.json
CHANGED
package/src/agent-view.ts
CHANGED
|
@@ -60,7 +60,8 @@ export class TranscriptView {
|
|
|
60
60
|
private streaming?: AssistantMessageComponent;
|
|
61
61
|
private expanded = false;
|
|
62
62
|
constructor(private readonly tui: TUI, private readonly cwd: string, private readonly definition: (name: string) => Renderers = () => undefined) {}
|
|
63
|
-
toggleTools() { this.
|
|
63
|
+
toggleTools() { this.setExpanded(!this.expanded); }
|
|
64
|
+
setExpanded(expanded: boolean) { if (expanded === this.expanded) return; this.expanded = expanded; for (const tool of this.tools.values()) tool.setExpanded(expanded); }
|
|
64
65
|
private tool(id: string, name: string, args: unknown) {
|
|
65
66
|
let tool = this.tools.get(id);
|
|
66
67
|
if (!tool) {
|
package/src/agents.ts
CHANGED
|
@@ -203,7 +203,7 @@ export class Children {
|
|
|
203
203
|
private async execute(live: LiveRun, view: string) {
|
|
204
204
|
const { session, info } = live;
|
|
205
205
|
try {
|
|
206
|
-
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. Initial
|
|
206
|
+
if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
|
|
207
207
|
if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
|
|
208
208
|
await session.prompt(`${view}\n\nYour task:\n${info.task}`);
|
|
209
209
|
while (info.state !== 'stopping') {
|
package/src/connected-window.ts
CHANGED
|
@@ -1,42 +1,127 @@
|
|
|
1
|
-
import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
-
import { Markdown } from '@earendil-works/pi-tui';
|
|
3
|
-
import {
|
|
1
|
+
import { getMarkdownTheme, UserMessageComponent, type ExtensionAPI, type ExtensionContext, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Container, Loader, Markdown, type Component, type TUI } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
4
|
+
import { connectWindow, type LiveState, type WindowEvent } from './window-bridge.ts';
|
|
4
5
|
import { profilePath } from './profiles.ts';
|
|
6
|
+
import { TranscriptView } from './agent-view.ts';
|
|
7
|
+
import { isReport, reportBox } from './report-message.ts';
|
|
8
|
+
import { statusState, windowTitle, type WindowState } from './title.ts';
|
|
5
9
|
|
|
6
|
-
|
|
10
|
+
/** `turn` is one finished reply of the agent with the results of its tool calls, drawn like Pi draws its own turns. */
|
|
11
|
+
type Details = { from?: WindowEvent['from']; turn?: AgentMessage[] };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One transcript for the window, shared by the chat and the live widget, so tool calls keep their timings when they settle into the chat.
|
|
15
|
+
* Pi's built-in tools keep their own renderers. Other tools' definitions live in the owner's process, out of reach here, so they get the
|
|
16
|
+
* look Pi gives any registered tool without a renderer (`spawn tasks=[…]`) instead of the raw-JSON look of an unknown one.
|
|
17
|
+
*/
|
|
18
|
+
let transcript: TranscriptView | undefined;
|
|
19
|
+
const transcriptOn = (tui: TUI) => new TranscriptView(tui, process.cwd(), () => ({}));
|
|
20
|
+
const view = (tui: TUI) => transcript ??= transcriptOn(tui);
|
|
21
|
+
/** Turns drawn before this window has a live view (after /reload) still get their tool boxes; there is just no live screen to redraw. */
|
|
22
|
+
let detached: TranscriptView | undefined;
|
|
23
|
+
const NO_SCREEN = { requestRender() {} } as unknown as TUI;
|
|
7
24
|
|
|
8
25
|
/** The conversation itself renders like a normal chat; everything else keeps Pi's boxed custom-message look. */
|
|
9
26
|
export function registerConnectedRenderer(pi: ExtensionAPI) {
|
|
10
|
-
pi.registerMessageRenderer<Details>('optchat-connected', (message, { outputPad }) => {
|
|
27
|
+
pi.registerMessageRenderer<Details>('optchat-connected', (message, { expanded, outputPad }, theme) => {
|
|
11
28
|
const text = typeof message.content === 'string' ? message.content : '';
|
|
12
29
|
if (message.details?.from === 'user') return new UserMessageComponent(text, getMarkdownTheme(), outputPad);
|
|
13
|
-
|
|
30
|
+
const turn = message.details?.turn;
|
|
31
|
+
if (turn) {
|
|
32
|
+
const shown = transcript ?? (detached ??= transcriptOn(NO_SCREEN));
|
|
33
|
+
shown.setExpanded(expanded);
|
|
34
|
+
const container = new Container();
|
|
35
|
+
for (const part of shown.build('', turn)) container.addChild(part);
|
|
36
|
+
return container;
|
|
37
|
+
}
|
|
38
|
+
if (message.details?.from === 'agent') return new Markdown(text.trim(), outputPad, 0, getMarkdownTheme());
|
|
39
|
+
// Reports from the agent's own subagents get the same dark box as in the main window.
|
|
40
|
+
return isReport(text) ? reportBox(text, outputPad, theme) : undefined;
|
|
14
41
|
});
|
|
15
42
|
}
|
|
16
43
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
44
|
+
const toolCalls = (message: AgentMessage) => message.role === 'assistant' ? message.content.flatMap(part => part.type === 'toolCall' ? [part.id] : []) : [];
|
|
45
|
+
|
|
46
|
+
/** What normally sits at the bottom of Pi's chat while it works: the streaming reply, running tools and the working spinner. */
|
|
47
|
+
class LiveTurn implements Component {
|
|
48
|
+
private readonly loader: Loader;
|
|
49
|
+
private readonly body = new Container();
|
|
50
|
+
private spinning = false;
|
|
51
|
+
turn: AgentMessage[] = [];
|
|
52
|
+
live?: LiveState;
|
|
53
|
+
constructor(private readonly tui: TUI, theme: Theme) {
|
|
54
|
+
this.loader = new Loader(tui, text => theme.fg('accent', text), text => theme.fg('muted', text), 'Working');
|
|
55
|
+
// Running tools show their elapsed time, which only moves when they are redrawn.
|
|
56
|
+
this.timer = setInterval(() => { if (this.live?.tools.length) this.update(); }, 1000); this.timer.unref();
|
|
57
|
+
}
|
|
58
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
59
|
+
update() {
|
|
60
|
+
const streaming = this.live?.streaming;
|
|
61
|
+
const running = new Map((this.live?.tools ?? []).map(t => [t.id, { output: t.output, started: t.started }] as const));
|
|
62
|
+
this.body.clear();
|
|
63
|
+
for (const part of view(this.tui).build('', streaming ? [...this.turn, streaming] : this.turn, streaming, running)) this.body.addChild(part);
|
|
64
|
+
const state = this.live?.state;
|
|
65
|
+
// Waiting with no agents of its own means it is waiting for you, so nothing spins, like an idle Pi.
|
|
66
|
+
const agents = this.live?.agents ?? 0;
|
|
67
|
+
const spin = !!state && statusState(state, agents) === 'working';
|
|
68
|
+
this.loader.setMessage(state === 'waiting' ? `Waiting for ${agents === 1 ? '1 agent' : `${agents} agents`}` : state === 'stopping' ? 'Stopping' : 'Working');
|
|
69
|
+
if (spin && !this.spinning) this.loader.start(); else if (!spin && this.spinning) this.loader.stop();
|
|
70
|
+
this.spinning = spin;
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
}
|
|
73
|
+
render(width: number) { return [...this.body.render(width), ...(this.spinning ? this.loader.render(width) : [])]; }
|
|
74
|
+
invalidate() { this.body.invalidate(); this.loader.invalidate(); }
|
|
75
|
+
dispose() { clearInterval(this.timer); this.loader.stop(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function openConnectedWindow(pi: ExtensionAPI, ctx: ExtensionContext, profile: string, setTitle: (title: string) => void = title => ctx.ui.setTitle(title)) {
|
|
79
|
+
// Each window draws with its own transcript, so nothing from an earlier window carries over.
|
|
80
|
+
transcript = undefined;
|
|
81
|
+
let started = false, ended = false, liveTurn: LiveTurn | undefined, live: LiveState | undefined, turn: AgentMessage[] = [];
|
|
82
|
+
const title = (state: WindowState) => setTitle(windowTitle(profile, state));
|
|
83
|
+
const display = (text: string, details: Details = {}) => pi.sendMessage<Details>({ customType: 'optchat-connected', content: text, display: true, details }, { triggerTurn: false });
|
|
84
|
+
const refresh = () => { if (liveTurn) { liveTurn.turn = turn; liveTurn.live = live; liveTurn.update(); } };
|
|
85
|
+
/** A reply moves into the chat once all its tool calls have results, as Pi's own chat does when a tool finishes. */
|
|
86
|
+
const commit = () => {
|
|
87
|
+
if (!turn.length) return;
|
|
88
|
+
const [reply] = turn;
|
|
89
|
+
display(reply.role === 'assistant' ? reply.content.flatMap(p => p.type === 'text' ? [p.text] : []).join('\n') : '', { from: 'agent', turn });
|
|
90
|
+
turn = [];
|
|
91
|
+
};
|
|
92
|
+
const settled = () => { const done = new Set(turn.flatMap(m => m.role === 'toolResult' ? [m.toolCallId] : [])); return turn.length > 0 && toolCalls(turn[0]).every(id => done.has(id)); };
|
|
93
|
+
const showLive = () => ctx.ui.setWidget('optchat-connected', (tui, theme) => { view(tui); liveTurn = new LiveTurn(tui, theme); refresh(); return liveTurn; });
|
|
94
|
+
const hideLive = () => { live = undefined; liveTurn = undefined; ctx.ui.setWidget('optchat-connected', undefined); };
|
|
20
95
|
const connection = await connectWindow(profilePath(profile), event => {
|
|
21
96
|
if (event.name === 'started') {
|
|
22
|
-
started = true;
|
|
97
|
+
started = true; showLive(); title('working');
|
|
23
98
|
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected agent ${event.text} · /complete`);
|
|
24
99
|
} else if (event.name === 'status') {
|
|
25
|
-
|
|
100
|
+
live = event.live; refresh();
|
|
101
|
+
// The tab says working exactly when the spinner spins.
|
|
102
|
+
if (!ended && live) title(statusState(live.state, live.agents));
|
|
103
|
+
} else if (event.message?.role === 'assistant') {
|
|
104
|
+
commit(); turn = [event.message];
|
|
105
|
+
if (settled()) commit();
|
|
106
|
+
refresh();
|
|
107
|
+
} else if (event.message?.role === 'toolResult') {
|
|
108
|
+
if (turn.length) { turn.push(event.message); if (settled()) commit(); }
|
|
109
|
+
refresh();
|
|
26
110
|
} else {
|
|
27
|
-
|
|
111
|
+
commit();
|
|
112
|
+
display(event.text, { from: event.from });
|
|
28
113
|
if (event.name === 'finished') {
|
|
29
|
-
ended = true;
|
|
114
|
+
ended = true; hideLive(); title('done');
|
|
30
115
|
ctx.ui.setStatus('optchat', `OptChat: ${profile} · conversation ended · /complete to exit`);
|
|
31
|
-
}
|
|
116
|
+
} else refresh();
|
|
32
117
|
}
|
|
33
118
|
}, () => {
|
|
34
119
|
if (ended) return;
|
|
35
|
-
ended = true;
|
|
36
|
-
ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`);
|
|
120
|
+
ended = true; commit(); hideLive();
|
|
121
|
+
ctx.ui.setStatus('optchat', `OptChat: ${profile} · disconnected`); title('disconnected');
|
|
37
122
|
ctx.ui.notify('Connection closed. The owner saves the interrupted conversation and handoff; no local agent will run here.', 'info');
|
|
38
123
|
});
|
|
39
|
-
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`);
|
|
124
|
+
ctx.ui.setStatus('optchat', `OptChat: ${profile} · connected window · send a task to start`); title('waiting');
|
|
40
125
|
display(`Connected to ${profile}'s original window. Messages here go to one subagent using the profile's subagent model. /tell-main sends a message to the main agent; /complete ends this conversation and sends a handoff. Closing this window interrupts it.`);
|
|
41
126
|
return {
|
|
42
127
|
async submit(text: string) {
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { createCompressor } from './compactor.ts';
|
|
|
11
11
|
import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
12
12
|
import { MASTER, VIEW_DOC } from './prompts.ts';
|
|
13
13
|
import { cachePayload, record } from './cache.ts';
|
|
14
|
-
import { boundedMessage, buildContext, logMessage, previousExchange, RUN_BOUNDARY, textContent } from './transcript.ts';
|
|
14
|
+
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
|
|
15
|
+
import { registerReportRenderer } from './report-message.ts';
|
|
15
16
|
import { memoryTools, result } from './tools.ts';
|
|
16
17
|
import { Children } from './agents.ts';
|
|
17
18
|
import { exportBrowser } from './browser.ts';
|
|
@@ -27,6 +28,7 @@ import { inspectorShortcut, mountNavigation } from './navigation.ts';
|
|
|
27
28
|
import { serveWindows } from './window-bridge.ts';
|
|
28
29
|
import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
|
|
29
30
|
import { createHandoffSummarizer } from './handoff.ts';
|
|
31
|
+
import { mainTitle, TabTitle } from './title.ts';
|
|
30
32
|
|
|
31
33
|
const binding = 'optchat.profile';
|
|
32
34
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
@@ -53,6 +55,13 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
53
55
|
let unmountNavigation: (() => void) | undefined;
|
|
54
56
|
let inspectorController: AbortController | undefined;
|
|
55
57
|
const shortcut = inspectorShortcut();
|
|
58
|
+
const title = new TabTitle();
|
|
59
|
+
let working = false;
|
|
60
|
+
let untitle: (() => void) | undefined;
|
|
61
|
+
const showTitle = (ctx: ExtensionContext) => {
|
|
62
|
+
const a = active;
|
|
63
|
+
if (a) title.show(text => ctx.ui.setTitle(text), mainTitle(a.name, working, a.children.ids.length));
|
|
64
|
+
};
|
|
56
65
|
const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
|
|
57
66
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
58
67
|
pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
|
|
@@ -76,10 +85,16 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
};
|
|
88
|
+
// Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
|
|
89
|
+
// built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
|
|
90
|
+
const sendReport = (text: string) => {
|
|
91
|
+
if (prompt) pi.sendMessage({ customType: REPORT_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: 'steer' });
|
|
92
|
+
else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
|
|
93
|
+
};
|
|
79
94
|
const deliverReport = async (text: string, once = false) => {
|
|
80
95
|
if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
|
|
81
96
|
reports.push(text); saveReports();
|
|
82
|
-
if (!stopping)
|
|
97
|
+
if (!stopping) sendReport(text);
|
|
83
98
|
};
|
|
84
99
|
const stop = async () => {
|
|
85
100
|
remote?.close(); remote = undefined;
|
|
@@ -95,7 +110,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
95
110
|
await checkpoint(old.dir);
|
|
96
111
|
} finally {
|
|
97
112
|
await old.memory.close(); await old.unlock(); active = undefined;
|
|
98
|
-
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear();
|
|
113
|
+
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
|
|
114
|
+
untitle?.(); untitle = undefined; title.clear(); working = false;
|
|
99
115
|
}
|
|
100
116
|
};
|
|
101
117
|
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
|
|
@@ -139,13 +155,15 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
139
155
|
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
|
|
140
156
|
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
|
|
141
157
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
158
|
+
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
142
159
|
status(ctx);
|
|
143
160
|
ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
144
161
|
const queuedReports = [...reports];
|
|
145
162
|
if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
|
|
146
|
-
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports)
|
|
163
|
+
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
|
|
147
164
|
} catch (error) {
|
|
148
165
|
await closeWindows?.(); closeWindows = undefined;
|
|
166
|
+
untitle?.(); untitle = undefined; title.clear();
|
|
149
167
|
if (active && active.memory === openingMemory) {
|
|
150
168
|
unmountNavigation?.(); unmountNavigation = undefined;
|
|
151
169
|
await active.children.close(); active = undefined;
|
|
@@ -168,14 +186,17 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
168
186
|
catch (error) {
|
|
169
187
|
if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
|
|
170
188
|
if (!await ctx.ui.confirm('Profile open in another window', `${error.owner}\nStart a connected subagent conversation here?`)) throw error;
|
|
171
|
-
remote = await openConnectedWindow(pi, ctx, name); fault = undefined;
|
|
189
|
+
remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined;
|
|
172
190
|
}
|
|
173
191
|
if (!boundName) pi.appendEntry(binding, { name });
|
|
174
192
|
} catch (error) {
|
|
175
193
|
if (active) await stop().catch(() => {});
|
|
176
194
|
fault = errorText(error); ctx.ui.notify(fault, 'error');
|
|
177
195
|
}
|
|
196
|
+
// Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
|
|
197
|
+
for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
|
|
178
198
|
});
|
|
199
|
+
pi.on('session_info_changed', () => title.reapply()); // Pi retitles the tab on session renames, just before this.
|
|
179
200
|
pi.on('session_shutdown', stop);
|
|
180
201
|
pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
181
202
|
pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
@@ -195,10 +216,18 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
195
216
|
}
|
|
196
217
|
return { action: 'continue' };
|
|
197
218
|
});
|
|
198
|
-
|
|
219
|
+
const startRun = (ctx: ExtensionContext) => {
|
|
199
220
|
flush(); run = []; logged = 0; view = undefined; runStarted = true;
|
|
200
221
|
previous = previousExchange(ctx.sessionManager.getBranch());
|
|
201
222
|
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
223
|
+
};
|
|
224
|
+
// A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
|
|
225
|
+
pi.on('agent_start', (_event, ctx) => {
|
|
226
|
+
working = true; showTitle(ctx);
|
|
227
|
+
if (active && !runStarted) startRun(ctx);
|
|
228
|
+
});
|
|
229
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
230
|
+
startRun(ctx);
|
|
202
231
|
const a = required();
|
|
203
232
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
204
233
|
event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.`;
|
|
@@ -207,7 +236,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
207
236
|
});
|
|
208
237
|
pi.on('message_end', (event, ctx) => {
|
|
209
238
|
if (!active || !runStarted) return;
|
|
210
|
-
const
|
|
239
|
+
const bounded = boundedMessage(event.message);
|
|
240
|
+
const message = asUser(bounded);
|
|
211
241
|
if (message.role === 'user') {
|
|
212
242
|
try {
|
|
213
243
|
const text = textContent(message.content);
|
|
@@ -223,7 +253,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
223
253
|
if (view !== undefined) {
|
|
224
254
|
try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
225
255
|
}
|
|
226
|
-
if (
|
|
256
|
+
if (bounded !== event.message) return { message: bounded };
|
|
227
257
|
});
|
|
228
258
|
pi.on('context_with_system', async (event, ctx) => {
|
|
229
259
|
try {
|
|
@@ -261,6 +291,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
261
291
|
try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
262
292
|
if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
|
|
263
293
|
runStarted = false; status(ctx);
|
|
294
|
+
working = false; showTitle(ctx);
|
|
264
295
|
if (active) {
|
|
265
296
|
const dir = active.dir;
|
|
266
297
|
checkpoints = checkpoints.then(() => checkpoint(dir)).catch(error => ctx.ui.notify(`Local checkpoint failed: ${errorText(error)}`, 'error'));
|
|
@@ -268,6 +299,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
268
299
|
}
|
|
269
300
|
});
|
|
270
301
|
registerConnectedRenderer(pi);
|
|
302
|
+
registerReportRenderer(pi);
|
|
271
303
|
for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
|
|
272
304
|
pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
|
|
273
305
|
description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Box, Markdown, Text } from '@earendil-works/pi-tui';
|
|
3
|
+
import { REPORT_TYPE, textContent } from './transcript.ts';
|
|
4
|
+
|
|
5
|
+
const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
|
|
6
|
+
|
|
7
|
+
/** Split "[id] Message from subagent (still running): body" into a short label and the body. */
|
|
8
|
+
export function reportParts(text: string) {
|
|
9
|
+
const match = HEAD.exec(text);
|
|
10
|
+
if (!match) return { label: 'subagent', body: text };
|
|
11
|
+
const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
|
|
12
|
+
return { label: `subagent ${match[1]} · ${kind}`, body: text.slice(match[0].length) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The theme's neutral grey, darkened on dark themes so the box recedes behind the conversation. */
|
|
16
|
+
function darkBackground(theme: Theme) {
|
|
17
|
+
const rgb = /^\x1b\[48;2;(\d+);(\d+);(\d+)m$/.exec(theme.getBgAnsi('toolPendingBg'))?.slice(1).map(Number);
|
|
18
|
+
if (!rgb || rgb[0] + rgb[1] + rgb[2] > 3 * 128) return (text: string) => theme.bg('toolPendingBg', text);
|
|
19
|
+
const open = `\x1b[48;2;${rgb.map(c => Math.round(c * 0.7)).join(';')}m`;
|
|
20
|
+
return (text: string) => `${open}${text}\x1b[49m`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Background traffic: a dark neutral box with dim text, so it never looks like something the user typed. */
|
|
24
|
+
export function reportBox(text: string, outputPad: number, theme: Theme) {
|
|
25
|
+
const { label, body } = reportParts(text);
|
|
26
|
+
const box = new Box(outputPad, 1, darkBackground(theme));
|
|
27
|
+
box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
|
|
28
|
+
box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
|
|
29
|
+
return box;
|
|
30
|
+
}
|
|
31
|
+
export const isReport = (text: string) => HEAD.test(text);
|
|
32
|
+
export function registerReportRenderer(pi: ExtensionAPI) {
|
|
33
|
+
pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => reportBox(textContent(message.content), outputPad, theme));
|
|
34
|
+
}
|
package/src/title.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Terminal tab titles: `● π personal · 2 agents` in the main window, `● ↳ personal` in a connected window. */
|
|
2
|
+
export type StatusState = 'waiting' | 'working';
|
|
3
|
+
export type WindowState = StatusState | 'done' | 'disconnected';
|
|
4
|
+
|
|
5
|
+
const agents = (count: number) => count > 0 ? ` · ${count} ${count === 1 ? 'agent' : 'agents'}` : '';
|
|
6
|
+
export const mainTitle = (profile: string, working: boolean, running: number) => `${working ? '● ' : ''}π ${profile}${agents(running)}`;
|
|
7
|
+
export const windowTitle = (profile: string, state: WindowState) =>
|
|
8
|
+
`${state === 'working' ? '● ' : ''}↳ ${profile}${state === 'done' || state === 'disconnected' ? ` · ${state}` : ''}`;
|
|
9
|
+
/** A connected window's agent is working while it runs or stops, or waits on agents of its own; waiting with none means waiting for you. */
|
|
10
|
+
export const statusState = (state: string, agents = 0): StatusState =>
|
|
11
|
+
state === 'running' || state === 'stopping' || state === 'waiting' && agents > 0 ? 'working' : 'waiting';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pi writes its own "π - <cwd>" title after binding a session (startup, reload, profile switch) and on
|
|
15
|
+
* session renames, so the last title is kept and re-applied at those points.
|
|
16
|
+
*/
|
|
17
|
+
export class TabTitle {
|
|
18
|
+
private title: string | undefined;
|
|
19
|
+
private set: ((title: string) => void) | undefined;
|
|
20
|
+
show(set: (title: string) => void, title: string) {
|
|
21
|
+
this.set = set;
|
|
22
|
+
if (title === this.title) return;
|
|
23
|
+
this.title = title; this.apply();
|
|
24
|
+
}
|
|
25
|
+
reapply() { this.apply(); }
|
|
26
|
+
clear() { this.title = undefined; this.set = undefined; }
|
|
27
|
+
private apply() {
|
|
28
|
+
if (!this.title || !this.set) return;
|
|
29
|
+
try { this.set(this.title); } catch { /* A ctx captured before a session replacement is stale; the next session sets its own. */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { cap, type Memory } from './memory.ts';
|
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
6
|
|
|
7
7
|
export const RUN_BOUNDARY = 'optchat.run';
|
|
8
|
+
/** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
|
|
9
|
+
export const REPORT_TYPE = 'optchat-report';
|
|
8
10
|
|
|
9
11
|
export function textContent(content: unknown): string {
|
|
10
12
|
if (typeof content === 'string') return content;
|
|
@@ -16,6 +18,11 @@ export function textContent(content: unknown): string {
|
|
|
16
18
|
return '';
|
|
17
19
|
}).filter(Boolean).join('\n');
|
|
18
20
|
}
|
|
21
|
+
/** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
|
|
22
|
+
export function asUser(message: AgentMessage): AgentMessage {
|
|
23
|
+
if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
|
|
24
|
+
return { role: 'user', content: textContent(message.content), timestamp: message.timestamp };
|
|
25
|
+
}
|
|
19
26
|
export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) {
|
|
20
27
|
const date = new Date(message.timestamp).toISOString();
|
|
21
28
|
if (message.role === 'user') memory.append('user', textContent(message.content), date, receipt);
|
|
@@ -54,7 +61,7 @@ function completedExchange(history: readonly AgentMessage[]) {
|
|
|
54
61
|
export function previousExchange(branch: readonly SessionEntry[]) {
|
|
55
62
|
let end = -1;
|
|
56
63
|
let legacyEnd = branch.length;
|
|
57
|
-
const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [entry.message] : []);
|
|
64
|
+
const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
|
|
58
65
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
59
66
|
const entry = branch[i];
|
|
60
67
|
if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
|
package/src/window-bridge.ts
CHANGED
|
@@ -5,19 +5,36 @@ import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
6
|
import { profileSocket } from './profiles.ts';
|
|
7
7
|
import { textContent } from './transcript.ts';
|
|
8
|
+
import { isActiveRun } from './runs.ts';
|
|
8
9
|
|
|
9
10
|
type Action = 'start' | 'say' | 'tell-main' | 'complete';
|
|
10
11
|
interface Request { kind: 'request'; id: number; action: Action; text?: string; cwd?: string }
|
|
11
12
|
interface Reply { kind: 'reply'; id: number; error?: string }
|
|
12
|
-
/**
|
|
13
|
-
export interface
|
|
13
|
+
/** A tool the subagent is running right now, with its streamed output so far. */
|
|
14
|
+
export interface LiveTool { id: string; name: string; args: unknown; output?: unknown; started: number }
|
|
15
|
+
/** What the subagent is doing between finished messages: its streaming reply and running tools. */
|
|
16
|
+
export interface LiveState { state: string; model: string; streaming?: AgentMessage; tools: LiveTool[]; agents?: number }
|
|
17
|
+
/**
|
|
18
|
+
* `from` marks the conversation itself (your messages and the agent's replies); other messages have no `from`.
|
|
19
|
+
* `message` carries the agent's own messages (replies with tool calls, tool results) so the window can draw them with Pi's components.
|
|
20
|
+
*/
|
|
21
|
+
export interface WindowEvent {
|
|
22
|
+
kind: 'event'; name: 'started' | 'message' | 'status' | 'finished'; text: string; from?: 'user' | 'agent';
|
|
23
|
+
message?: AgentMessage; live?: LiveState;
|
|
24
|
+
}
|
|
14
25
|
type Frame = Request | Reply | WindowEvent;
|
|
15
26
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
27
|
+
const isMessage = (value: unknown): value is AgentMessage => record(value) && (value.role === 'assistant' || value.role === 'toolResult') && Array.isArray(value.content);
|
|
28
|
+
const isLive = (value: unknown): value is LiveState => record(value) && typeof value.state === 'string' && typeof value.model === 'string'
|
|
29
|
+
&& (value.streaming === undefined || isMessage(value.streaming))
|
|
30
|
+
&& Array.isArray(value.tools) && value.tools.every(t => record(t) && typeof t.id === 'string' && typeof t.name === 'string' && typeof t.started === 'number')
|
|
31
|
+
&& (value.agents === undefined || typeof value.agents === 'number');
|
|
16
32
|
function parse(value: unknown): Frame {
|
|
17
33
|
if (!record(value)) throw new Error('Invalid window message');
|
|
18
34
|
const name = value.name, action = value.action, from = value.from;
|
|
19
35
|
if (value.kind === 'event' && (name === 'started' || name === 'message' || name === 'status' || name === 'finished') && typeof value.text === 'string')
|
|
20
|
-
return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined
|
|
36
|
+
return { kind: 'event', name, text: value.text, from: from === 'user' || from === 'agent' ? from : undefined,
|
|
37
|
+
message: isMessage(value.message) ? value.message : undefined, live: isLive(value.live) ? value.live : undefined };
|
|
21
38
|
if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) throw new Error('Invalid request ID');
|
|
22
39
|
if (value.kind === 'reply' && (value.error === undefined || typeof value.error === 'string')) return { kind: 'reply', id: value.id, error: value.error };
|
|
23
40
|
if (value.kind === 'request' && (action === 'start' || action === 'say' || action === 'tell-main' || action === 'complete')
|
|
@@ -25,6 +42,23 @@ function parse(value: unknown): Frame {
|
|
|
25
42
|
return { kind: 'request', id: value.id, action, text: value.text, cwd: value.cwd };
|
|
26
43
|
throw new Error('Invalid window message');
|
|
27
44
|
}
|
|
45
|
+
const SHORTENED = '\n[Display shortened; full text is saved in the transcript.]';
|
|
46
|
+
const shorten = (text: string, limit: number) => text.length > limit ? `${text.slice(0, limit)}${SHORTENED}` : text;
|
|
47
|
+
/** Long text parts are cut and images dropped: the window only draws them, the owner keeps the full transcript. */
|
|
48
|
+
function clipContent(content: unknown): unknown {
|
|
49
|
+
if (!Array.isArray(content)) return content;
|
|
50
|
+
return content.map(part => !record(part) ? part : part.type === 'text' && typeof part.text === 'string' ? { ...part, text: shorten(part.text, 50_000) }
|
|
51
|
+
: part.type === 'image' ? { type: 'text', text: '[image]' } : part);
|
|
52
|
+
}
|
|
53
|
+
const clip = (message: AgentMessage) => ({ ...message, content: clipContent('content' in message ? message.content : []) }) as AgentMessage;
|
|
54
|
+
const clipOutput = (output: unknown) => record(output) ? { ...output, content: clipContent(output.content) } : output;
|
|
55
|
+
/**
|
|
56
|
+
* Huge tool arguments (a large file write) would choke the socket; the window then falls back to text.
|
|
57
|
+
* Partial tool output comes from any extension and may not serialize (a cycle, a BigInt); that falls back too
|
|
58
|
+
* instead of throwing inside the status timer, which would take the owner's Pi down with it.
|
|
59
|
+
*/
|
|
60
|
+
const fits = (value: unknown) => { try { return JSON.stringify(value).length < 1_000_000; } catch { return false; } };
|
|
61
|
+
|
|
28
62
|
/** Local JSONL protocol, bounded before parsing; the socket is accessible only by its OS user. */
|
|
29
63
|
function wire(socket: Socket, receive: (frame: Frame) => void) {
|
|
30
64
|
let buffer = '';
|
|
@@ -94,13 +128,15 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
94
128
|
track(queue);
|
|
95
129
|
});
|
|
96
130
|
const drainMessages = (messages: AgentMessage[], task: string) => {
|
|
97
|
-
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant');
|
|
131
|
+
const displayable = messages.filter(message => message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult');
|
|
98
132
|
while (cursor < displayable.length) {
|
|
99
133
|
const message = displayable[cursor++];
|
|
100
|
-
let text = textContent(message.content);
|
|
134
|
+
let text = message.role === 'toolResult' ? '' : textContent(message.content);
|
|
101
135
|
if (message.role === 'user' && firstUser) { text = task; firstUser = false; said.add(task.trim()); }
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
if (message.role === 'user') { if (text) send({ kind: 'event', name: 'message', from: said.has(text.trim()) ? 'user' : undefined, text: shorten(text, 200_000) }); continue; }
|
|
137
|
+
const clipped = clip(message);
|
|
138
|
+
if (fits(clipped)) send({ kind: 'event', name: 'message', from: message.role === 'assistant' ? 'agent' : undefined, text: shorten(text, 200_000), message: clipped });
|
|
139
|
+
else if (text) send({ kind: 'event', name: 'message', from: 'agent', text: shorten(text, 200_000) });
|
|
104
140
|
}
|
|
105
141
|
};
|
|
106
142
|
const timer = setInterval(() => {
|
|
@@ -109,8 +145,14 @@ export async function serveWindows(directory: string, children: Children, availa
|
|
|
109
145
|
if (!info) return;
|
|
110
146
|
if (live) drainMessages(live.session.messages, info.task);
|
|
111
147
|
const preview = live?.streaming && 'content' in live.streaming ? textContent(live.streaming.content).slice(-2000) : '';
|
|
112
|
-
const
|
|
113
|
-
|
|
148
|
+
const text = `${info.state === 'waiting' ? 'Awaiting user or child reports' : info.state} · ${info.model}\n${live ? [...live.tools.values()].map(t => t.name).join(', ') : ''}\n${preview}`;
|
|
149
|
+
let state: LiveState = { state: info.state, model: info.model,
|
|
150
|
+
streaming: live?.streaming?.role === 'assistant' ? clip(live.streaming) : undefined,
|
|
151
|
+
tools: live ? [...live.tools].map(([id, t]) => ({ id, name: t.name, args: t.args, output: clipOutput(t.output), started: t.started })) : [],
|
|
152
|
+
agents: [...children.history.records.values()].filter(run => run.parentId === child && isActiveRun(run)).length };
|
|
153
|
+
if (!fits(state)) state = { ...state, streaming: undefined, tools: state.tools.map(t => ({ ...t, args: {}, output: undefined })) };
|
|
154
|
+
const status = JSON.stringify(state);
|
|
155
|
+
if (status !== lastStatus) { lastStatus = status; send({ kind: 'event', name: 'status', text, live: state }); }
|
|
114
156
|
if (!live && info.handoff?.delivered) {
|
|
115
157
|
try { drainMessages(children.messages(child), info.task); }
|
|
116
158
|
catch (error) { send({ kind: 'event', name: 'message', text: `Could not read the final transcript: ${errorText(error)}` }); }
|