pi-optchat 0.6.1 → 0.6.3

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 CHANGED
@@ -69,7 +69,8 @@ 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
- - The parent can send a running child guidance with `tell`.
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.)
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.
75
76
  - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
@@ -140,7 +141,7 @@ Each profile is locked to one Pi process. If you open the same profile in a seco
140
141
 
141
142
  - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
142
143
  - 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
- - The main agent is told when the conversation starts. Use `/tell-main <message>` to message it yourself; the subagent has a `tell_main` tool, and the main agent replies with `tell`. Routine turns don't wake the main agent.
144
+ - 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
145
  - 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.
145
146
  - Closing or force-quitting the window also produces a handoff, marked **interrupted**.
146
147
  - If the original window is closed cleanly, handoffs are delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -115,7 +115,8 @@ export class Children {
115
115
  if (cancelled()) throw new Error('Parent or profile is stopping.');
116
116
  const id = randomUUID().slice(0, 8), directory = task.cwd ?? cwd;
117
117
  const delegation = depth < 3 ? 'You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
118
- const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_main for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.' : ''].filter(Boolean).join('\n\n');
118
+ const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
119
+ : 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
119
120
  // The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
120
121
  const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
121
122
  const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
@@ -135,12 +136,7 @@ export class Children {
135
136
  await loader.reload();
136
137
  const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
137
138
  model, thinkingLevel: selected.thinking, sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')),
138
- customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), ...(connected ? [{
139
- name: 'tell_main', label: 'Message main agent', description: 'Send the main agent a question or important finding. Its reply can arrive as guidance; continue useful work instead of polling.',
140
- parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
141
- await this.report(`[${id}] Connected agent message: ${args.message}`); return result('Message sent to the main agent.');
142
- },
143
- }] : [])],
139
+ customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
144
140
  excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
145
141
  });
146
142
  await session.bindExtensions({});
@@ -185,6 +181,24 @@ export class Children {
185
181
  },
186
182
  }];
187
183
  }
184
+ /** Lets a child message its parent mid-run, the way tell lets the parent guide it. */
185
+ private parentTool(id: string, parentId: string | undefined, connected: boolean) {
186
+ return { name: 'tell_parent', label: parentId ? 'Message parent agent' : 'Message main agent',
187
+ description: `Send ${parentId ? 'your parent agent' : 'the main agent'} a question or important finding while you keep working. Its reply can arrive as guidance; continue useful work instead of polling. Your final answer is delivered automatically.`,
188
+ parameters: Type.Object({ message: Type.String() }), execute: async (_id: string, args: { message: string }) => {
189
+ const message = args.message.trim();
190
+ if (!message) throw new Error('Message is empty.');
191
+ const text = `[${id}] ${connected ? 'Connected agent message' : 'Message from subagent (still running)'}: ${message}`;
192
+ if (!parentId) { await this.report(text); return result('Message sent to the main agent.'); }
193
+ const parent = this.running.get(parentId);
194
+ if (!parent || !['running', 'waiting'].includes(parent.info.state)) throw new Error('Your parent is no longer running.');
195
+ if (parent.info.state === 'waiting') { parent.pendingReports.push(text); parent.wake?.(); }
196
+ else await parent.session.steer(text);
197
+ this.changed();
198
+ return result(`Message sent to parent ${parentId}.`);
199
+ },
200
+ };
201
+ }
188
202
  private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
189
203
  private async execute(live: LiveRun, view: string) {
190
204
  const { session, info } = live;
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';
@@ -76,10 +77,16 @@ export default function optchat(pi: ExtensionAPI) {
76
77
  }
77
78
  }
78
79
  };
80
+ // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
81
+ // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
82
+ const sendReport = (text: string) => {
83
+ if (prompt) pi.sendMessage({ customType: REPORT_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: 'steer' });
84
+ else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
85
+ };
79
86
  const deliverReport = async (text: string, once = false) => {
80
87
  if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
81
88
  reports.push(text); saveReports();
82
- if (!stopping) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
89
+ if (!stopping) sendReport(text);
83
90
  };
84
91
  const stop = async () => {
85
92
  remote?.close(); remote = undefined;
@@ -95,7 +102,7 @@ export default function optchat(pi: ExtensionAPI) {
95
102
  await checkpoint(old.dir);
96
103
  } finally {
97
104
  await old.memory.close(); await old.unlock(); active = undefined;
98
- run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear();
105
+ run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
99
106
  }
100
107
  };
101
108
  const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
@@ -143,7 +150,7 @@ export default function optchat(pi: ExtensionAPI) {
143
150
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
144
151
  const queuedReports = [...reports];
145
152
  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) pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false }); });
153
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
147
154
  } catch (error) {
148
155
  await closeWindows?.(); closeWindows = undefined;
149
156
  if (active && active.memory === openingMemory) {
@@ -195,10 +202,15 @@ export default function optchat(pi: ExtensionAPI) {
195
202
  }
196
203
  return { action: 'continue' };
197
204
  });
198
- pi.on('before_agent_start', (event, ctx) => {
205
+ const startRun = (ctx: ExtensionContext) => {
199
206
  flush(); run = []; logged = 0; view = undefined; runStarted = true;
200
207
  previous = previousExchange(ctx.sessionManager.getBranch());
201
208
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
209
+ };
210
+ // A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
211
+ pi.on('agent_start', (_event, ctx) => { if (active && !runStarted) startRun(ctx); });
212
+ pi.on('before_agent_start', (event, ctx) => {
213
+ startRun(ctx);
202
214
  const a = required();
203
215
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
204
216
  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 +219,8 @@ export default function optchat(pi: ExtensionAPI) {
207
219
  });
208
220
  pi.on('message_end', (event, ctx) => {
209
221
  if (!active || !runStarted) return;
210
- const message = boundedMessage(event.message);
222
+ const bounded = boundedMessage(event.message);
223
+ const message = asUser(bounded);
211
224
  if (message.role === 'user') {
212
225
  try {
213
226
  const text = textContent(message.content);
@@ -223,7 +236,7 @@ export default function optchat(pi: ExtensionAPI) {
223
236
  if (view !== undefined) {
224
237
  try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
225
238
  }
226
- if (message !== event.message) return { message };
239
+ if (bounded !== event.message) return { message: bounded };
227
240
  });
228
241
  pi.on('context_with_system', async (event, ctx) => {
229
242
  try {
@@ -268,6 +281,7 @@ export default function optchat(pi: ExtensionAPI) {
268
281
  }
269
282
  });
270
283
  registerConnectedRenderer(pi);
284
+ registerReportRenderer(pi);
271
285
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
272
286
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
273
287
  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,32 @@
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 registerReportRenderer(pi: ExtensionAPI) {
25
+ pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => {
26
+ const { label, body } = reportParts(textContent(message.content));
27
+ const box = new Box(outputPad, 1, darkBackground(theme));
28
+ box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
29
+ box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
30
+ return box;
31
+ });
32
+ }
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;