pi-optchat 0.6.0 → 0.6.2

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,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
- - The parent can send a running child guidance with `tell`.
72
+ - 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
73
  - Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
74
74
  - 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
75
  - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
@@ -89,15 +89,16 @@ An **Agents | Usage** bar sits below the input.
89
89
 
90
90
  Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another extension supplies a custom editor, OptChat leaves its Down key alone; use the shortcut or commands instead.
91
91
 
92
- **Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, open with **Enter**, and press **M** to pick the subagent model. Inside a transcript:
92
+ **Agents** lists runs as a tree with state, elapsed time, current tool, and last activity. Navigate with **Up/Down**, **Page Up/Down**, **Home/End**, and press **M** to pick the subagent model.
93
+
94
+ **Enter** swaps the screen to that agent's conversation, drawn with Pi's own chat components, so it reads like the main chat: its task, replies, and collapsed tool calls, following live output. Typing and **Enter** send it guidance. Guidance from the main agent and reports from the agent's own agents show in labelled boxes, so only your own messages look typed. **Escape** goes back to the main chat.
93
95
 
94
96
  | Key | Action |
95
97
  | --- | --- |
96
- | **T** | Expand tool arguments and results |
97
- | **F** | Follow live output (scrolling pauses it) |
98
- | **S** | Send guidance |
99
- | **X** | Stop this agent and its subtree |
100
- | **Escape** | Back |
98
+ | **Escape** or **Ctrl+C** | Clear a draft, else back to the main chat |
99
+ | **Ctrl+X** twice | Stop this agent and the agents it started |
100
+ | **Page Up/Down**, mouse wheel | Scroll; back at the bottom it follows again. The wheel needs Pi's default fullscreen mode |
101
+ | **Ctrl+O** | Expand tool output (Pi's own toggle) |
101
102
 
102
103
  Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
103
104
 
@@ -139,7 +140,7 @@ Each profile is locked to one Pi process. If you open the same profile in a seco
139
140
 
140
141
  - Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
141
142
  - 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.
142
- - 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.
143
+ - 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.
143
144
  - 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.
144
145
  - Closing or force-quitting the window also produces a handoff, marked **interrupted**.
145
146
  - 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.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "files": [
54
54
  "src",
55
- "docs",
55
+ "docs/*.md",
56
56
  "README.md",
57
57
  "LICENSE",
58
58
  "THIRD_PARTY_NOTICES.md"
@@ -0,0 +1,305 @@
1
+ import {
2
+ AssistantMessageComponent, CustomMessageComponent, ToolExecutionComponent, UserMessageComponent, getMarkdownTheme, getSelectListTheme,
3
+ createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition,
4
+ type ExtensionContext,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import { Editor, Spacer, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
7
+ import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
+ import type { Children } from './agents.ts';
9
+ import type { RunInfo } from './runs.ts';
10
+ import { textContent } from './transcript.ts';
11
+ import { record } from './cache.ts';
12
+ import { elapsed, fit, oneLine } from './inspector.ts';
13
+
14
+ export type ViewTone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'success' | 'borderMuted';
15
+ type ToolResult = Parameters<ToolExecutionComponent['updateResult']>[0];
16
+ type Renderers = ConstructorParameters<typeof ToolExecutionComponent>[4];
17
+ type Guidance = RunInfo['guidance'];
18
+ type Running = Map<string, { output?: unknown; started: number }>;
19
+
20
+ /** Pi's own renderers for its built-in tools, so saved runs draw like the main chat. */
21
+ const builtIns: Record<string, (cwd: string) => Renderers> = {
22
+ bash: createBashToolDefinition, read: createReadToolDefinition, edit: createEditToolDefinition, write: createWriteToolDefinition,
23
+ grep: createGrepToolDefinition, find: createFindToolDefinition, ls: createLsToolDefinition,
24
+ };
25
+
26
+ /** Reports from the child's own agents start with their id, e.g. "[1a2b3c4d] ...". */
27
+ const REPORT = /^\[[0-9a-f]{8}\] /;
28
+ /** Connected windows prefix the main agent's guidance with this line. */
29
+ const MAIN_PREFIX = '[Main agent guidance]\n';
30
+
31
+ /** Who a user-role message in the child's conversation came from. Runs saved before senders were recorded count as the user's. */
32
+ export function senderOf(text: string, guidance: Guidance = []): 'user' | 'manager' | 'report' {
33
+ if (REPORT.test(text)) return 'report';
34
+ if (text.startsWith(MAIN_PREFIX)) return 'manager';
35
+ return guidance.findLast(g => g.text === text)?.from === 'manager' ? 'manager' : 'user';
36
+ }
37
+
38
+ /** Background traffic gets Pi's labelled box, as extension messages do in the main chat; only the user's own words look typed. */
39
+ const boxed = (label: string, text: string, timestamp: number) =>
40
+ new CustomMessageComponent({ role: 'custom', customType: label, content: text, display: true, timestamp }, undefined, getMarkdownTheme());
41
+
42
+ function toolResult(value: unknown, isError = false): ToolResult | undefined {
43
+ if (!record(value) || !Array.isArray(value.content)) return undefined;
44
+ const content = value.content.flatMap((part: unknown) => record(part) && typeof part.type === 'string'
45
+ ? [{ type: part.type, ...(typeof part.text === 'string' ? { text: part.text } : {}), ...(typeof part.data === 'string' ? { data: part.data } : {}), ...(typeof part.mimeType === 'string' ? { mimeType: part.mimeType } : {}) }] : []);
46
+ return { content, details: value.details, isError };
47
+ }
48
+
49
+ /**
50
+ * Builds the child's conversation from Pi's chat components and keeps them between frames,
51
+ * so streaming text and running tools update in place like in the main chat.
52
+ */
53
+ export class TranscriptView {
54
+ private readonly messages = new WeakMap<AgentMessage, Component>();
55
+ private readonly tools = new Map<string, ToolExecutionComponent>();
56
+ private readonly settled = new Set<string>();
57
+ private readonly partials = new Map<string, unknown>();
58
+ private readonly starts = new Map<string, number>();
59
+ private readonly ends = new Map<string, number>();
60
+ private streaming?: AssistantMessageComponent;
61
+ private expanded = false;
62
+ constructor(private readonly tui: TUI, private readonly cwd: string, private readonly definition: (name: string) => Renderers = () => undefined) {}
63
+ toggleTools() { this.expanded = !this.expanded; for (const tool of this.tools.values()) tool.setExpanded(this.expanded); }
64
+ private tool(id: string, name: string, args: unknown) {
65
+ let tool = this.tools.get(id);
66
+ if (!tool) {
67
+ tool = new ToolExecutionComponent(name, id, args, { showImages: false }, this.renderers(id, name), this.tui, this.cwd);
68
+ tool.setExpanded(this.expanded); this.tools.set(id, tool);
69
+ }
70
+ return tool;
71
+ }
72
+ /**
73
+ * Pi's shell renderer times a command from the moment it is first drawn, so opening the view would restart the clock.
74
+ * Hand it the real start and end instead; unknown starts (runs finished before the view saw them) show no time.
75
+ */
76
+ private renderers(id: string, name: string): Renderers {
77
+ const base = builtIns[name]?.(this.cwd);
78
+ if (!base) return this.definition(name);
79
+ const { renderCall, renderResult } = base;
80
+ const stamp = (state: unknown) => {
81
+ if (!record(state)) return;
82
+ const started = this.starts.get(id), ended = this.ends.get(id);
83
+ if (started !== undefined) state.startedAt = started;
84
+ if (ended !== undefined) state.endedAt = ended;
85
+ // The view redraws every second itself; the renderer's own timer would keep redrawing Pi after the view closes.
86
+ state.interval ??= 'view';
87
+ };
88
+ return {
89
+ ...base,
90
+ ...(renderCall ? { renderCall: (args, theme, context) => { stamp(context.state); return renderCall.call(base, args, theme, context); } } : {}),
91
+ ...(renderResult ? { renderResult: (result, options, theme, context) => { stamp(context.state); return renderResult.call(base, result, options, theme, context); } } : {}),
92
+ };
93
+ }
94
+ /** `task` replaces the first prompt, which also carries the memory view the child got as context. */
95
+ build(task: string, messages: AgentMessage[], streaming?: AgentMessage, running?: Running, guidance?: Guidance): Component[] {
96
+ const parts: Component[] = [];
97
+ let first = true;
98
+ for (const message of messages) {
99
+ if (message.role === 'user') {
100
+ const opening = first; first = false;
101
+ const text = opening ? task : textContent(message.content).trim();
102
+ if (!text) continue;
103
+ let component = this.messages.get(message);
104
+ if (!component) {
105
+ const sender = opening ? 'user' : senderOf(text, guidance);
106
+ component = sender === 'manager' ? boxed('main agent', text.startsWith(MAIN_PREFIX) ? text.slice(MAIN_PREFIX.length) : text, message.timestamp)
107
+ : sender === 'report' ? boxed('agent report', text, message.timestamp)
108
+ : new UserMessageComponent(text, getMarkdownTheme());
109
+ this.messages.set(message, component);
110
+ }
111
+ if (parts.length && component instanceof UserMessageComponent) parts.push(new Spacer(1)); // Boxes bring their own.
112
+ parts.push(component);
113
+ } else if (message.role === 'assistant') {
114
+ const live = message === streaming;
115
+ let component: Component | undefined;
116
+ if (live) { this.streaming ??= new AssistantMessageComponent(undefined, true, getMarkdownTheme()); this.streaming.updateContent(message, true); component = this.streaming; }
117
+ else {
118
+ component = this.messages.get(message);
119
+ if (!component) { component = new AssistantMessageComponent(message, true, getMarkdownTheme()); this.messages.set(message, component); }
120
+ }
121
+ parts.push(component);
122
+ for (const part of message.content) {
123
+ if (part.type !== 'toolCall') continue;
124
+ const tool = this.tool(part.id, part.name, part.arguments);
125
+ if (live) { if (this.partials.get(`args:${part.id}`) !== part.arguments) { tool.updateArgs(part.arguments); this.partials.set(`args:${part.id}`, part.arguments); } }
126
+ else if (!this.settled.has(`args:${part.id}`)) {
127
+ tool.updateArgs(part.arguments); tool.setArgsComplete(); this.settled.add(`args:${part.id}`);
128
+ if ((message.stopReason === 'aborted' || message.stopReason === 'error') && !this.settled.has(part.id)) {
129
+ tool.updateResult({ content: [{ type: 'text', text: message.errorMessage || (message.stopReason === 'aborted' ? 'Operation aborted' : 'Error') }], isError: true });
130
+ this.settled.add(part.id);
131
+ }
132
+ }
133
+ parts.push(tool);
134
+ }
135
+ } else if (message.role === 'toolResult' && !this.settled.has(message.toolCallId)) {
136
+ const result = toolResult(message, message.isError);
137
+ const tool = this.tools.get(message.toolCallId);
138
+ if (result && tool) { this.ends.set(message.toolCallId, message.timestamp); tool.updateResult(result); this.settled.add(message.toolCallId); }
139
+ }
140
+ }
141
+ if (!streaming) this.streaming = undefined;
142
+ // Each call below redraws, so only pass on what changed since the last frame.
143
+ for (const [id, run] of running ?? []) {
144
+ const tool = this.tools.get(id);
145
+ if (!tool || this.settled.has(id)) continue;
146
+ if (!this.settled.has(`started:${id}`)) { this.starts.set(id, run.started); tool.markExecutionStarted(); this.settled.add(`started:${id}`); }
147
+ const partial = run.output !== this.partials.get(id) ? toolResult(run.output) : undefined;
148
+ if (partial) { tool.updateResult(partial, true); this.partials.set(id, run.output); }
149
+ else tool.invalidate(); // Ticks the running time.
150
+ }
151
+ return parts;
152
+ }
153
+ }
154
+
155
+ export interface AgentViewOptions {
156
+ id: string; children: Children; tui: TUI;
157
+ rows: () => number; redraw: () => void; done: () => void;
158
+ color: (tone: ViewTone, text: string) => string;
159
+ /** Pi's tool-output toggle (Ctrl+O by default), so tool calls expand the same way as in the main chat. */
160
+ isExpandKey?: (data: string) => boolean;
161
+ signal?: AbortSignal;
162
+ }
163
+
164
+ const statusOf = (run: RunInfo, children: Children) => {
165
+ const live = children.live(run.id);
166
+ if (!live) return run.state;
167
+ if (run.state === 'stopping') return 'stopping';
168
+ if (run.state === 'waiting') return 'waiting for its agents';
169
+ const tools = [...live.tools.values()].map(t => t.name);
170
+ return tools.length ? `running ${tools.join(', ')}` : live.streaming ? 'writing' : 'working';
171
+ };
172
+
173
+ /**
174
+ * The subagent's conversation in place of the main one: same message components, one header line,
175
+ * an input that guides the agent, and Esc back to the main chat. A UI projection only.
176
+ */
177
+ export class AgentView implements Component, Focusable {
178
+ private readonly editor: Editor;
179
+ private readonly transcript: TranscriptView;
180
+ private back = 0; // Lines scrolled up from the newest; 0 follows live output.
181
+ private bodyHeight = 1;
182
+ private overflow = 0;
183
+ private drawn = { lines: 0, width: 0 }; // Last frame, so new output doesn't move what you're reading while scrolled up.
184
+ private saved?: AgentMessage[];
185
+ private wasLive = false;
186
+ private confirmStop = false;
187
+ private notice = '';
188
+ private ended = false;
189
+ private hasFocus = true;
190
+ private readonly unsubscribe: () => void;
191
+ private readonly timer: ReturnType<typeof setInterval>;
192
+ constructor(private readonly options: AgentViewOptions) {
193
+ const { children, id, tui, color } = options;
194
+ const run = children.history.records.get(id);
195
+ this.transcript = new TranscriptView(tui, run?.cwd ?? process.cwd(), name => children.live(id)?.session.getToolDefinition(name));
196
+ this.editor = new Editor(tui, { borderColor: text => color('borderMuted', text), selectList: getSelectListTheme() });
197
+ this.editor.onSubmit = text => this.send(text);
198
+ this.editor.focused = true;
199
+ this.unsubscribe = children.subscribe(() => options.redraw());
200
+ this.timer = setInterval(() => options.redraw(), 1000); this.timer.unref();
201
+ options.signal?.addEventListener('abort', this.close, { once: true });
202
+ if (options.signal?.aborted) queueMicrotask(this.close);
203
+ }
204
+ get focused() { return this.hasFocus; }
205
+ set focused(value: boolean) { this.hasFocus = value; this.editor.focused = value; }
206
+ private readonly close = () => { if (!this.ended) { this.dispose(); this.options.done(); } };
207
+ dispose() { this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.close); }
208
+ invalidate() { this.editor.invalidate(); }
209
+ private get canMessage() { return ['running', 'waiting'].includes(this.options.children.live(this.options.id)?.info.state ?? ''); }
210
+ private scroll(lines: number) { this.back = Math.max(0, Math.min(this.overflow, this.back + lines)); }
211
+ /** Wheel scrolling, in Pi's default fullscreen mode; in "regular" mode the terminal keeps the wheel. */
212
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
213
+ if (this.ended || event.type !== 'wheel' || !event.wheelDelta) return undefined;
214
+ this.scroll(-event.wheelDelta);
215
+ return { handled: true };
216
+ }
217
+ private send(text: string) {
218
+ const message = text.trim(), { children, id } = this.options;
219
+ if (!message) return;
220
+ children.tell(id, message, 'user').then(() => { this.notice = ''; }, (error: unknown) => {
221
+ this.editor.setText(text); this.notice = `Not sent: ${error instanceof Error ? error.message : String(error)}`;
222
+ }).finally(() => { if (!this.ended) this.options.redraw(); });
223
+ }
224
+ handleInput(data: string) {
225
+ if (this.ended) return;
226
+ const { children, id } = this.options;
227
+ const stopping = this.confirmStop; this.confirmStop = false; this.notice = '';
228
+ if ((matchesKey(data, 'escape') && !this.editor.isShowingAutocomplete()) || matchesKey(data, 'ctrl+c')) {
229
+ if (!this.editor.getText()) return this.close();
230
+ this.editor.setText(''); // First press clears a draft, as in the main editor.
231
+ return this.options.redraw();
232
+ }
233
+ if (matchesKey(data, 'ctrl+x')) {
234
+ if (!children.live(id)) this.notice = 'This agent has already finished.';
235
+ else if (stopping) children.stop(id).catch((error: unknown) => { this.notice = `Could not stop: ${String(error)}`; this.options.redraw(); });
236
+ else this.confirmStop = true;
237
+ } else if (matchesKey(data, 'pageUp')) this.scroll(Math.max(1, this.bodyHeight - 2));
238
+ else if (matchesKey(data, 'pageDown')) this.scroll(-Math.max(1, this.bodyHeight - 2));
239
+ else if (this.options.isExpandKey?.(data)) this.transcript.toggleTools();
240
+ else if (this.canMessage) this.editor.handleInput(data);
241
+ this.options.redraw();
242
+ }
243
+ private messages(run: RunInfo) {
244
+ const { children } = this.options, live = children.live(run.id);
245
+ // Saved transcripts are read once; live ones come from memory each frame, plus one final read when the run ends.
246
+ if (live || this.wasLive || !this.saved) {
247
+ try { this.saved = children.messages(run.id); }
248
+ catch (error) { this.notice = `Transcript unavailable: ${String(error)}`; this.saved = []; }
249
+ }
250
+ this.wasLive = !!live;
251
+ return this.saved;
252
+ }
253
+ private header(run: RunInfo | undefined, width: number) {
254
+ const { color, children, id } = this.options;
255
+ if (!run) return color('error', ` ${id} · run not found`);
256
+ const live = children.live(run.id);
257
+ const dot = live ? color('accent', '●') : run.state === 'completed' ? color('success', '✓') : run.state === 'failed' ? color('error', '✗') : color('muted', '■');
258
+ const right = `${statusOf(run, children)} · ${elapsed((run.ended ?? Date.now()) - run.started)} · ${run.model.slice(run.model.indexOf('/') + 1)}`;
259
+ const left = ` ${dot} ${color('accent', run.id)} `;
260
+ const room = width - visibleWidth(left) - visibleWidth(right) - 3;
261
+ if (room < 8) return truncateToWidth(`${left}${color('dim', right)}`, width);
262
+ const task = fit(oneLine(run.task), room);
263
+ return `${left}${task}${' '.repeat(Math.max(2, width - visibleWidth(left) - visibleWidth(task) - visibleWidth(right) - 1))}${color('dim', right)} `;
264
+ }
265
+ render(width: number): string[] {
266
+ const { color, children, id, rows } = this.options;
267
+ const run = children.history.records.get(id), live = children.live(id);
268
+ const height = Math.max(1, rows());
269
+ const top = [this.header(run, width), color('borderMuted', '─'.repeat(width))];
270
+ let input: string[];
271
+ if (this.canMessage) input = this.editor.render(width);
272
+ else if (live) input = [color('dim', ' Stopping…')];
273
+ else input = run ? [color('borderMuted', '─'.repeat(width)), color('dim', ` ${run.state === 'completed' ? 'Finished' : `Ended (${run.state})`}. ${run.parentId ? 'Its report went to the agent that started it.' : 'Its report went to the main agent.'}`)] : [];
274
+ const pending = (run?.guidance ?? []).filter(g => g.state !== 'delivered');
275
+ // Only the conversation gives way to the controls: at most three pending lines, then a count.
276
+ const queued = pending.slice(-3).map(g => color(g.state === 'queued' ? 'muted' : 'warning',
277
+ ` ${g.state === 'queued' ? 'Queued' : 'Not delivered'}${g.from === 'manager' ? ' (main agent)' : ''}: ${oneLine(g.text)}`));
278
+ if (pending.length > 3) queued.unshift(color('muted', ` +${pending.length - 3} more not yet delivered`));
279
+ this.bodyHeight = Math.max(0, height - top.length - input.length - 1 - queued.length); // One footer line.
280
+ const parts = run ? this.transcript.build(run.task, this.messages(run), live?.streaming, live?.tools, run.guidance) : [];
281
+ const lines = parts.flatMap(part => part.render(width));
282
+ if (this.back && this.drawn.width === width) this.back += Math.max(0, lines.length - this.drawn.lines);
283
+ this.drawn = { lines: lines.length, width };
284
+ this.overflow = Math.max(0, lines.length - this.bodyHeight);
285
+ this.back = Math.min(this.back, this.overflow);
286
+ const end = lines.length - this.back;
287
+ const body = lines.slice(Math.max(0, end - this.bodyHeight), end);
288
+ while (body.length < this.bodyHeight) body.push('');
289
+ const hint = this.confirmStop ? color('warning', ' Press Ctrl+X again to stop this agent and the agents it started')
290
+ : this.notice ? color('error', ` ${this.notice}`)
291
+ : color('dim', ` ${this.editor.getText() ? 'Esc clear' : 'Esc back to main'}${live ? ' · Ctrl+X stop' : ''}${this.overflow ? this.back ? ' · PgDn newer' : ' · PgUp older' : ''}`);
292
+ const scrolled = this.back ? color('warning', `↑ ${this.back} lines up `) : '';
293
+ const footer = [visibleWidth(hint) + visibleWidth(scrolled) < width ? `${hint}${' '.repeat(width - visibleWidth(hint) - visibleWidth(scrolled))}${scrolled}` : hint];
294
+ // On a terminal too short for everything, the header goes first; the input and footer stay.
295
+ return [...top, ...body, ...queued, ...input, ...footer].slice(-height).map(line => truncateToWidth(line, width));
296
+ }
297
+ }
298
+
299
+ /** Swaps the whole screen to the subagent's conversation; Pi restores the main chat on close. */
300
+ export function showAgentView(ctx: ExtensionContext, options: { id: string; children: Children; signal?: AbortSignal }) {
301
+ return ctx.ui.custom<void>((tui, theme, keybindings, done) => new AgentView({ ...options, tui,
302
+ rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: () => done(undefined),
303
+ color: (tone, text) => theme.fg(tone, text), isExpandKey: data => keybindings.matches(data, 'app.tools.expand'),
304
+ }), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', row: 0, col: 0 } });
305
+ }
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;
@@ -255,7 +269,7 @@ export class Children {
255
269
  if (!live || !['running', 'waiting'].includes(live.info.state)) throw new Error(`No running subagent ${id}.`);
256
270
  const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
257
271
  if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
258
- const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued' };
272
+ const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
259
273
  live.info.guidance.push(guidance); this.save(live.info);
260
274
  try {
261
275
  if (live.info.state === 'waiting') { live.pendingGuidance.push(text); live.wake?.(); }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { chooseImport, showProgress } from './import/ui.ts';
22
22
  import { IMPORT_GUIDANCE } from './import/guidance.ts';
23
23
  import { UsageLedger } from './usage.ts';
24
24
  import { showInspector, type InspectorPage } from './inspector.ts';
25
+ import { showAgentView } from './agent-view.ts';
25
26
  import { inspectorShortcut, mountNavigation } from './navigation.ts';
26
27
  import { serveWindows } from './window-bridge.ts';
27
28
  import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
@@ -305,7 +306,9 @@ export default function optchat(pi: ExtensionAPI) {
305
306
  const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, page, signal,
306
307
  refreshUsage: () => { collectUsage(ctx); try { a.children.collectUsage(); } catch (error) { ctx.ui.notify(`Could not save child usage: ${errorText(error)}`, 'error'); } },
307
308
  });
308
- if (action === 'model' && !signal.aborted) await pickModel(ctx, 'subagent');
309
+ if (signal.aborted) return;
310
+ if (action === 'model') await pickModel(ctx, 'subagent');
311
+ else if (action) await showAgentView(ctx, { id: action.open, children: a.children, signal });
309
312
  } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
310
313
  finally { if (inspectorController === controller) inspectorController = undefined; }
311
314
  };
package/src/inspector.ts CHANGED
@@ -1,11 +1,8 @@
1
- import { Input, matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
1
+ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable } from '@earendil-works/pi-tui';
2
2
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
- import type { AgentMessage } from '@earendil-works/pi-agent-core';
4
3
  import type { Children } from './agents.ts';
5
- import { isActiveRun, type RunInfo } from './runs.ts';
4
+ import { isActiveRun } from './runs.ts';
6
5
  import { ranges, summarizeUsage, type UsageLedger, type UsageRange } from './usage.ts';
7
- import { textContent } from './transcript.ts';
8
- import { record } from './cache.ts';
9
6
 
10
7
  export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
11
8
  export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
@@ -24,10 +21,12 @@ const spread = (left: string, right: string, width: number) => {
24
21
  return gap >= 2 ? `${left}${' '.repeat(gap)}${right}` : left;
25
22
  };
26
23
  export type InspectorPage = 'agents' | 'usage';
24
+ /** Closing the panel either picks the subagent model or opens one agent's conversation. */
25
+ export type InspectorAction = 'model' | { open: string };
27
26
  type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'border';
28
27
  interface Options {
29
28
  profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
30
- rows: () => number; redraw: () => void; done: (action?: 'model') => void;
29
+ rows: () => number; redraw: () => void; done: (action?: InspectorAction) => void;
31
30
  color: (tone: Tone, text: string) => string;
32
31
  context: () => number | null | undefined;
33
32
  refreshUsage?: () => void;
@@ -39,20 +38,12 @@ export class Inspector implements Component, Focusable {
39
38
  focused = true;
40
39
  private page: InspectorPage;
41
40
  private selected?: string;
42
- private opened?: string;
43
41
  private top = 0;
44
42
  private scroll = 0;
45
43
  private lineCount = 0;
46
44
  private hintLines = 1;
47
- private follow = true;
48
- private details = false;
49
45
  private range: UsageRange = 'This session';
50
- private composer?: Input;
51
- private error = '';
52
- private busy = false;
53
46
  private ended = false;
54
- private savedMessages?: AgentMessage[];
55
- private liveTranscript = false;
56
47
  private readonly unsubscribe: () => void;
57
48
  private readonly timer: ReturnType<typeof setInterval>;
58
49
  constructor(private readonly options: Options) {
@@ -64,50 +55,28 @@ export class Inspector implements Component, Focusable {
64
55
  if (options.signal?.aborted) queueMicrotask(this.abort);
65
56
  }
66
57
  private readonly abort = () => this.finish();
67
- private finish(action?: 'model') { if (!this.ended) { this.dispose(); this.options.done(action); } }
58
+ private finish(action?: InspectorAction) { if (!this.ended) { this.dispose(); this.options.done(action); } }
68
59
  dispose() {
69
60
  this.ended = true; clearInterval(this.timer); this.unsubscribe(); this.options.signal?.removeEventListener('abort', this.abort);
70
61
  }
71
- invalidate() { this.composer?.invalidate(); }
72
- /** Body rows left after the rules, title, hint, spacing and composer; the rest of the screen keeps the footer visible. */
73
- private get height() { return Math.max(1, Math.floor(this.options.rows() * 0.8) - (this.composer ? 7 : 6) - (this.hintLines - 1)); }
62
+ invalidate() {}
63
+ /** Body rows left after the rules, title, hint and spacing; the rest of the screen keeps the footer visible. */
64
+ private get height() { return Math.max(1, Math.floor(this.options.rows() * 0.8) - 6 - (this.hintLines - 1)); }
74
65
  private select(delta: number) {
75
66
  const list = this.options.children.history.list();
76
67
  const index = Math.max(0, list.findIndex(r => r.id === this.selected));
77
68
  this.selected = list[Math.max(0, Math.min(list.length - 1, index + delta))]?.id;
78
69
  }
79
- private async act(action: () => Promise<unknown>) {
80
- this.busy = true;
81
- try { await action(); } catch (error) { this.error = String(error); }
82
- finally { this.busy = false; if (!this.ended) this.options.redraw(); }
83
- }
84
70
  handleInput(data: string) {
85
71
  if (this.ended) return;
86
- this.error = '';
87
- if (this.composer) {
88
- if (matchesKey(data, 'escape')) this.composer = undefined;
89
- else if (matchesKey(data, 'return') && !this.busy) {
90
- const text = this.composer.getValue().trim(), id = this.opened;
91
- if (text && id) { this.composer = undefined; void this.act(() => this.options.children.tell(id, text, 'user')); }
92
- } else this.composer.handleInput(data);
93
- } else if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
94
- if (this.opened) { this.opened = undefined; this.savedMessages = undefined; this.scroll = 0; }
95
- else return this.finish();
96
- } else if (data === 'u' || matchesKey(data, 'tab')) {
97
- this.page = this.page === 'agents' ? 'usage' : 'agents'; this.opened = undefined; this.scroll = 0; this.follow = true;
72
+ if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish();
73
+ if (data === 'u' || matchesKey(data, 'tab')) {
74
+ this.page = this.page === 'agents' ? 'usage' : 'agents'; this.scroll = 0;
98
75
  } else if (this.page === 'usage') {
99
76
  if (matchesKey(data, 'left') || matchesKey(data, 'right')) {
100
77
  const delta = matchesKey(data, 'left') ? -1 : 1;
101
78
  this.range = ranges[(ranges.indexOf(this.range) + delta + ranges.length) % ranges.length]; this.scroll = 0;
102
79
  } else this.scrollInput(data);
103
- } else if (this.opened) {
104
- if (data === 's' && !this.busy && ['running', 'waiting'].includes(this.options.children.live(this.opened)?.info.state ?? '')) {
105
- this.composer = new Input({ prompt: 'Message: ', placeholder: 'guidance for this child' }); this.composer.focused = this.focused;
106
- } else if (data === 'x' && !this.busy && this.options.children.live(this.opened)) {
107
- const id = this.opened; void this.act(() => this.options.children.stop(id));
108
- } else if (data === 't') this.details = !this.details;
109
- else if (data === 'f' || matchesKey(data, 'end')) { this.follow = true; this.scroll = Math.max(0, this.lineCount - this.height); }
110
- else this.scrollInput(data);
111
80
  } else {
112
81
  if (matchesKey(data, 'up')) this.select(-1);
113
82
  else if (matchesKey(data, 'down')) this.select(1);
@@ -116,62 +85,17 @@ export class Inspector implements Component, Focusable {
116
85
  else if (matchesKey(data, 'home')) this.select(-Infinity);
117
86
  else if (matchesKey(data, 'end')) this.select(Infinity);
118
87
  else if (data === 'm') return this.finish('model');
119
- else if (matchesKey(data, 'return') && this.selected) {
120
- try {
121
- this.savedMessages = this.options.children.messages(this.selected); this.opened = this.selected;
122
- this.liveTranscript = !!this.options.children.live(this.selected);
123
- this.follow = true; this.scroll = 0;
124
- } catch (error) { this.error = `Transcript unavailable: ${String(error)}`; }
125
- }
88
+ else if (matchesKey(data, 'return') && this.selected) return this.finish({ open: this.selected });
126
89
  }
127
90
  this.options.redraw();
128
91
  }
129
92
  private scrollInput(data: string) {
130
93
  const delta = matchesKey(data, 'up') ? -1 : matchesKey(data, 'down') ? 1 : matchesKey(data, 'pageUp') ? -this.height : matchesKey(data, 'pageDown') ? this.height : 0;
131
94
  if (delta || matchesKey(data, 'home') || matchesKey(data, 'end')) {
132
- this.follow = false;
133
95
  this.scroll = matchesKey(data, 'home') ? 0 : matchesKey(data, 'end') ? this.lineCount : this.scroll + delta;
134
96
  this.scroll = Math.max(0, Math.min(this.scroll, this.lineCount - this.height));
135
97
  }
136
98
  }
137
- private transcript(run: RunInfo) {
138
- const live = this.options.children.live(run.id);
139
- if (live || this.liveTranscript) {
140
- try { this.savedMessages = this.options.children.messages(run.id); }
141
- catch (error) { this.error = `Transcript unavailable: ${String(error)}`; }
142
- this.liveTranscript = !!live;
143
- }
144
- const messages = this.savedMessages ?? [];
145
- const usage = summarizeUsage(this.options.usage.entries.filter(e => e.runId === run.id)).total;
146
- const blocks = [`Task: ${run.task}`, `Directory: ${run.cwd}`, `Model: ${run.model} · effort ${run.thinking}`, `Depth: ${run.depth}/3 · parent: ${run.parentId ?? 'main agent'}`,
147
- `Usage: ${count(usage.totalTokens)} tokens · ${count(usage.input)} input · ${count(usage.output)} output · ${count(usage.cacheRead)}/${count(usage.cacheWrite)} cache read/write · $${usage.cost.total.toFixed(4)} API-equivalent`];
148
- let initialUser = true;
149
- for (const message of messages) {
150
- if (message.role === 'user') {
151
- if (initialUser) { initialUser = false; continue; } // The initial memory view is context, not work output.
152
- blocks.push(`You / manager: ${textContent(message.content)}`);
153
- } else if (message.role === 'assistant') {
154
- for (const part of message.content) {
155
- if (part.type === 'text') blocks.push(part.text);
156
- else if (part.type === 'toolCall') blocks.push(`Tool: ${part.name}${this.details ? `\n${JSON.stringify(part.arguments, null, 2)}` : ''}`);
157
- }
158
- if (message.errorMessage) blocks.push(`Error: ${message.errorMessage}`);
159
- } else if (message.role === 'toolResult') {
160
- const output = textContent(message.content);
161
- blocks.push(`${message.isError ? 'Error' : 'Result'}: ${message.toolName}\n${this.details ? output : `${oneLine(output).slice(0, 160)}${output.length > 160 ? '… [t expands]' : ''}`}`);
162
- }
163
- }
164
- for (const tool of live?.tools.values() ?? []) {
165
- blocks.push(`Running: ${tool.name} · ${elapsed(Date.now() - tool.started)}\n${JSON.stringify(tool.args)}`);
166
- if (tool.output) {
167
- const output = record(tool.output) ? textContent(tool.output.content) : String(tool.output);
168
- blocks.push(this.details ? output : output.slice(-500));
169
- }
170
- }
171
- if (run.report && !live) blocks.push(`Final report: ${run.report}`);
172
- for (const g of run.guidance) if (g.state !== 'delivered') blocks.push(`Message ${g.state}: ${g.text}`);
173
- return blocks.filter(Boolean).map(clean).join('\n\n');
174
- }
175
99
  private usageLines() {
176
100
  const { usage, session, context } = this.options;
177
101
  const entries = usage.select(this.range, session);
@@ -194,18 +118,14 @@ export class Inspector implements Component, Focusable {
194
118
  render(width: number): string[] {
195
119
  const { color, children, profile } = this.options;
196
120
  const inner = Math.max(1, width - 2);
197
- const inspected = this.opened ? children.history.records.get(this.opened) : undefined;
198
- let title = `OptChat · ${profile} · ${inspected ? `${inspected.state}: ${oneLine(inspected.task)}` : this.page === 'usage' ? 'Usage' : 'Agents'}`;
121
+ let title = `OptChat · ${profile} · ${this.page === 'usage' ? 'Usage' : 'Agents'}`;
199
122
  let info: string, body: string[], hint: string;
200
- if (this.page === 'usage' || this.opened) {
201
- const run = this.opened ? children.history.records.get(this.opened) : undefined;
202
- const text = this.page === 'usage' ? this.usageLines() : run ? `${run.state} · ${elapsed((run.ended ?? Date.now()) - run.started)} · ${run.id}\n\n${this.transcript(run)}` : 'Run unavailable.';
203
- const lines = wrapTextWithAnsi(clean(text), inner); this.lineCount = lines.length;
204
- if (this.opened && this.follow) this.scroll = Math.max(0, lines.length - this.height);
123
+ if (this.page === 'usage') {
124
+ const lines = wrapTextWithAnsi(clean(this.usageLines()), inner); this.lineCount = lines.length;
205
125
  this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
206
126
  body = lines.slice(this.scroll, this.scroll + this.height);
207
- info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}${this.opened ? this.follow ? ' · following' : ' · scroll paused' : ''}`;
208
- hint = this.page === 'usage' ? '←→ period · ↑↓/PgUp/PgDn scroll · Tab agents · Esc close' : '↑↓/PgUp/PgDn scroll · t tools · f follow · s message · x stop tree · Esc back';
127
+ info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}`;
128
+ hint = '←→ period · ↑↓/PgUp/PgDn scroll · Tab agents · Esc close';
209
129
  } else {
210
130
  const list = children.history.list();
211
131
  if (!this.selected) this.selected = list[0]?.id;
@@ -231,11 +151,10 @@ export class Inspector implements Component, Focusable {
231
151
  });
232
152
  if (!body.length) body.push(color('muted', 'No agents yet. Ask the main agent to delegate a task.'));
233
153
  info = `${list.filter(isActiveRun).length} active · ${list.length} saved${list.length ? ` · ${cursor + 1}/${list.length}` : ''}`;
234
- hint = '↑↓ select · Enter inspect · m model · Tab usage · Esc close';
154
+ hint = '↑↓ select · Enter open · m model · Tab usage · Esc close';
235
155
  }
236
- if (this.composer) { this.composer.focused = this.focused; body.push(...this.composer.render(inner)); hint = 'Enter sends guidance · Esc cancels message'; }
237
156
  title = fit(title, Math.max(1, inner - visibleWidth(info) - 2));
238
- const footer = wrapTextWithAnsi(this.error || (this.busy ? 'Updating agent…' : hint), inner).map(line => color(this.error ? 'error' : 'dim', line));
157
+ const footer = wrapTextWithAnsi(hint, inner).map(line => color('dim', line));
239
158
  this.hintLines = footer.length;
240
159
  const rule = color('border', '─'.repeat(Math.max(1, width)));
241
160
  // Same layout as Pi's own selectors: rules above and below, content indented by one column.
@@ -249,7 +168,7 @@ let showing = false;
249
168
  export const inspectorShowing = () => showing;
250
169
  /** Takes the editor's place, like Pi's own selectors; Pi restores the editor and its draft on close. */
251
170
  export function showInspector(ctx: ExtensionContext, options: Omit<Options, 'rows' | 'redraw' | 'done' | 'color' | 'context'>) {
252
- return ctx.ui.custom<'model' | undefined>((tui, theme, _keys, done) => {
171
+ return ctx.ui.custom<InspectorAction | undefined>((tui, theme, _keys, done) => {
253
172
  showing = true;
254
173
  return new Inspector({ ...options,
255
174
  rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: action => { showing = false; done(action); },
package/src/runs.ts CHANGED
@@ -16,7 +16,8 @@ export interface RunInfo {
16
16
  state: RunState; report?: string;
17
17
  connected?: boolean;
18
18
  handoff?: { reason: FinishReason; text?: string; delivered?: boolean };
19
- guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered' }[];
19
+ /** `from` is missing on runs saved before senders were recorded. */
20
+ guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
20
21
  }
21
22
  export const isActiveRun = (run: RunInfo) => run.state === 'running' || run.state === 'waiting' || run.state === 'stopping';
22
23
  function isRun(value: unknown): value is RunInfo {
@@ -32,7 +33,7 @@ function isRun(value: unknown): value is RunInfo {
32
33
  && (value.handoff.delivered === undefined || typeof value.handoff.delivered === 'boolean'))
33
34
  && typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1 && value.depth <= 3
34
35
  && (value.parentId === undefined || typeof value.parentId === 'string')
35
- && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)));
36
+ && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager'));
36
37
  }
37
38
  export function sessionMessages(file: string): AgentMessage[] {
38
39
  return SessionManager.open(file).getEntries().flatMap(e => e.type === 'message' ? [e.message] : []);