pi-optchat 0.8.5 → 0.8.7

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.
@@ -0,0 +1,52 @@
1
+ # How OptChat differs from the recipe
2
+
3
+ The README has the summary table. This page has the detail: what changed in each prompt, the numbers OptChat keeps, and how each addition behaves. `docs/victor-recipe.md` maps the recipe's sections to the source files.
4
+
5
+ ## Prompts
6
+
7
+ `src/prompts.ts` holds every prompt sent to a model (the recipe's four, the subagent instruction paragraphs, the compaction task and its retry, the handoff prompt, the import note), each with a comment on where it goes. Tool descriptions stay with their tools. The recipe's prompts are lightly adapted:
8
+
9
+ - **Main agent.** The turn rules are the revised recipe's (2026-10-08) word for word, with three changes:
10
+ - its paragraph on computers is left out;
11
+ - reports start `[id] ` instead of `[Name]`;
12
+ - two sentences are added: don't poll anything slow (CI, a deploy) yourself, hand the watching to a subagent; and a message starting `[OptChat status]` is OptChat's own note, not the user.
13
+ - **View doc.** Keeps the original's description of the view. It drops the "No message appears in full" line (untrue with Previous exchange on) and the list of when to zoom, adds one sentence on zooming out from a message to the summaries above it, and ends with the revised recipe's rule that the view is the truth and zoom the only way through it.
14
+ - **Compactor.** The revised recipe's intro, view and Compactions sections, without its turn-only sections, and with:
15
+ - OptChat's kinds: `talk` for replies, subagent reports as `work`;
16
+ - its value list reworded so the size limit is a ceiling rather than a target: bare approvals are recorded as the decision they made, lines don't narrate what wasn't seen yet, and a tool result is described by what its output shows rather than by a cause guessed from an exit code or an empty match. An A/B found less filler and no recall loss (#129).
17
+ - **Compaction task and "Too long" retry.** The recipe's, verbatim, with a 512-dash ruler for the size.
18
+ - **Compactor instructions.** Unlike the recipe, the compactor doesn't get your instructions: an A/B found they added nothing (#112).
19
+ - **Tool results.** A tool result's leaf summary also gets the raw tool call it answers, as a `<call>` block of context (#153 A/B: recall 3.88 → 4.22).
20
+ - **Subagent prompt.** The original's, plus a closing note on time: report progress on long tasks with `tell_parent`, and say so before waiting on something slow, so the main agent can go on.
21
+
22
+ ## Numbers kept from the recipe
23
+
24
+ - 512-byte summary nodes. By default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace (setting: Summary size tolerance).
25
+ - A memory view that grows a line per message and, past 128,000 bytes, merges in one batch down to 64,000. Between batches it only grows at its end, so each turn reads the last one's view from the cache. The view is saved and reloaded as it was.
26
+ - Binary merges, 8 compression workers by default (setting: Summaries at once), fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. As in UniiChat, a node the model refuses keeps the first 512 bytes of its source instead (#193), and a summarizer is sent at most 100,000 characters of one message; zoom still pages through all of it.
27
+
28
+ ## Compaction view and scheduling
29
+
30
+ Compactions get their own view, as in the 2026-10-08 revision: the memory view merged further, to between 16,000 and 32,000 bytes with the same sawtooth, ending at the line being built.
31
+
32
+ A message's summary starts once fewer than N lines before it are unsummarized, so up to N compactions run at once on one cached view (N is the **Summaries at once** setting, 8 by default). An import logs its messages the same way, waiting only while that many messages and due merges are unbuilt, so the merges keep up.
33
+
34
+ Where the recipe ends a compaction's view at the first line not summarized yet, OptChat shows that line as a placeholder and keeps the lines after it. Cutting there also hid the short lines that follow, such as an echo's own tool call, and an A/B on real turns scored those summaries lower (#101).
35
+
36
+ ## Cache marks
37
+
38
+ Anthropic requests split the view into blocks of 4 lines, with cache marks on the last whole one and on the blocks 20 and 40 before it. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines (such as 40 tool calls and their results) made the next turn rewrite the whole view. With the two extra marks, a turn that adds up to 240 lines still reads the last view from the cache. OpenAI requests get no marks (see `docs/victor-recipe.md`).
39
+
40
+ When that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it.
41
+
42
+ ## Additions
43
+
44
+ Each run's context is the memory view, the previous exchange, and your new message. The additions that change the recipe's behaviour are settings (see [Settings](../README.md#settings)).
45
+
46
+ 1. **Previous exchange kept verbatim.** Your last request (with any steering) and the final answer are included in full, so "why is that?" refers to what you actually read. Tool calls and reasoning are not carried over. It comes on top of the 128,000-byte view. If it is over 16,000 bytes (about 4,000 tokens, usually a big paste; setting: Previous exchange limit) it is left out entirely, and the model relies on the view and zoom as in Victor's recipe. A new Pi session starts with the memory view only.
47
+ 2. **Subagents** are built in with Pi's SDK rather than a separate package. With Subagent levels above 1 they can delegate further. A spawn can set each task's model and thinking level when you ask for them.
48
+ 3. **Memory search** (off by default). With the setting on, the agent also gets `search(text, before?)`: plain, case-insensitive text matching over the original messages, never the summaries (a summary can be wrong, and one fact repeats at every level of the tree), skipping logged zoom and search results. It returns 20 hits at a time, newest first, each with its id, the view line that holds it when that is a summary (`1234 (in 1024+256)`), its date and a snippet. `before: id` pages back, and `zoom(id, 1)` reads a hit. One line about it is added to the system prompt, and the rule that zoom is the only way through memory names search too. Turning it on or off changes the cached prompt once.
49
+ 4. **Large messages.** `zoom(id, 1)` returns a message over 25,000 characters in pages, `[showing characters 0-25000 of 92000; next page: offset 25000]`, and `offset`/`limit` read any part of one (used on a shorter message, the page adds a note that it fits in one zoom). The recipe returns it whole, where the 30,000-character tool output cap cut out its middle.
50
+ 5. **Turn refresh** (#146). Once the current turn's verbatim messages pass the Turn refresh threshold (30% by default), they move into memory and the turn goes on with a fresh window. 0% is off, as in the recipe. See "Long turns" in the README.
51
+ 6. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
52
+ 7. **Not done**: computer use and hosting on an always-on machine.
@@ -0,0 +1,25 @@
1
+ # Storage
2
+
3
+ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPTCHAT_HOME`). The README has the table of what each path holds.
4
+
5
+ ## Git checkpoints
6
+
7
+ Each profile folder is a local Git repository, committed after each turn and on clean shutdown (memory and config; not runs, HTML, or usage). It has no remote, so it is not a backup. To back up, copy the folder while Pi is closed.
8
+
9
+ ## Import generations
10
+
11
+ An import builds a new memory generation (`memories/<id>/`) and switches `active-memory.json` to it only when the whole tree is ready. Older generations are kept.
12
+
13
+ ## The `Store` seam
14
+
15
+ Memory reaches disk through one seam, `Store` in `src/store.ts`: the log, summary nodes, saved view and images, which is what would travel between machines. The saved view (`view.json`) holds the chat's view and the compactions' view, each with its batch state; an older one without the compactions' view refolds it from the chat's, once.
16
+
17
+ - Runs, recovery journals, imports and their pointer, usage, settings and `AGENTS.md` stay local files.
18
+ - `FileStore` (the layout in the README) is the only store today. Another, such as Postgres, would implement the same few operations.
19
+ - Its lease (take, heartbeat, release) is optional: without one, OptChat keeps its machine-local profile lock.
20
+ - Import records (`imports/*.json`, the staging generations and the `active-memory.json` pointer) are per machine, so a second machine with the same `~/.claude` or Codex history on disk may offer those sessions for import again.
21
+
22
+ ## Windows differences
23
+
24
+ - **Named pipes, not socket files.** The profile lock and the connected-window bridge listen on `\\.\pipe\optchat-<hash>-lock` and `optchat-<hash>-windows`, where the hash comes from the profile path: a Windows pipe has no filesystem entry inside the profile, so the hash is the only thing separating profiles. Pipes are machine-wide and carry no file permissions, so a Windows profile is not permission-protected the way a POSIX socket file (mode 0600) is.
25
+ - **File modes and fsync** (profile folder 0700, socket 0600, directory fsync after a rename) are POSIX-only and are skipped on Windows.
@@ -0,0 +1,100 @@
1
+ # Subagents in detail
2
+
3
+ The README covers the basics. This page has the rest.
4
+
5
+ ## What a child gets
6
+
7
+ - The profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions (except pi-optchat itself).
8
+ - The Pi built-in extensions the main session loaded: MCP, codemode and tool search.
9
+
10
+ ### MCP servers
11
+
12
+ Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi.
13
+
14
+ - If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too.
15
+ - Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
16
+
17
+ ## Model and thinking level
18
+
19
+ Each task runs the profile's subagent model (`/optchat agents model`), unless the spawn sets that task's `model` (`provider/id`, as in `/model`) or `thinking`. The agent sets them only when you ask for them.
20
+
21
+ - What a task leaves out comes from the profile: a model alone runs at the profile's thinking level, or the nearest level that model takes.
22
+ - The agent's instructions name the profile's subagent model and level, so it leaves a task's `model` unset unless you ask for another one. The `models` tool (`provider` optional) lists what a task may name, with the default marked; the agent is told to take ids only from it, never from memory, since the newest ids may postdate its training ("Opus" without a version means the newest).
23
+ - A model Pi doesn't know refuses the whole spawn before any child starts, and the error lists the models you can use.
24
+ - A resumed child keeps the model and level it ran with.
25
+
26
+ ## Reports
27
+
28
+ Each child reports to its parent as soon as it finishes, as one message starting `[id]`. The parent stays alive to receive reports; it never polls.
29
+
30
+ - A failed child reports its error text.
31
+ - A child whose request aborts with no stop (a provider abort) still reports `Task aborted: ...`.
32
+ - A stopped child's report says who stopped it (you, the agent that started it, or Pi closing while it was paused) and that `tell` resumes it, followed by its last message when it has one.
33
+
34
+ ### Grouped reports
35
+
36
+ Turn **Group subagent reports** on to have the children of one spawn report together: when the last of them finishes, their reports reach the parent as one message, `[id] report` each, in spawn order.
37
+
38
+ - Each finished report is journaled right away, so if Pi dies before the rest finish, the reports it already has are delivered at the next start.
39
+ - Messages sent with `tell_parent`, connected windows and their handoffs are never held back.
40
+ - A child resumed with `tell` reports on its own.
41
+
42
+ ### How reports look
43
+
44
+ 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.
45
+
46
+ - The model still receives them as ordinary user messages.
47
+ - Memory logs them as kind `work` (older logs hold them as `user` messages starting `[id]`).
48
+ - Exception: reports recovered at startup, before your first message in the session, still show as plain user messages.
49
+
50
+ ### Reading a child's chat
51
+
52
+ Every report ends with `Full chat: zoom("<id>")`, so the agent can check it against what the child actually did. The report box leaves this trailer out.
53
+
54
+ `zoom` with a run ID returns that child's chat so far as `kind|text` lines: its task without the memory view, replies, tool calls, tool results cut to their first and last ~500 characters, and what it was told. It comes in 25,000-character pages that say which `offset` to go on from. It works on running children too. A subagent can read only its own descendants' chats, as with `tell`.
55
+
56
+ ## The "agents at work" status line
57
+
58
+ While any subagent runs, each new input to the main agent (your message, a report, a `tell_parent` message, the restart note, a connected window's message) ends with one status line:
59
+
60
+ ```
61
+ [OptChat status] Agents at work now: 8f28e694 "Review PR #123 carefully", …
62
+ ```
63
+
64
+ It lists the id and the first words of each task, so the main agent knows what is still out without asking.
65
+
66
+ - It is rebuilt each time and never stored.
67
+ - It isn't repeated on the calls after a tool result within one turn, and nothing is sent while no agent runs.
68
+ - The main prompt says what the label means, so the model doesn't take it for the user.
69
+ - It comes after the cached view and turn, with the turn's cache mark on the block before it, so it costs no cache.
70
+
71
+ ## Talking to a running child
72
+
73
+ - The parent sends a running child guidance with `tell`.
74
+ - The child messages 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.
75
+
76
+ ## Resuming a finished child
77
+
78
+ `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report.
79
+
80
+ - This also works for children from earlier Pi sessions.
81
+ - Only the agent that started the child can resume it, and the resumed child takes one active slot.
82
+ - Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
83
+
84
+ ## Levels and limits
85
+
86
+ - By default only the main agent starts subagents. Set **Subagent levels** in `/optchat settings` to let them delegate further (3 means child, grandchild, great-grandchild).
87
+ - **Max active agents** (8 by default) caps how many agents can be active per profile, including parents waiting on descendants.
88
+ - Going over a limit returns an error; there is no queue.
89
+
90
+ ## Stopping and restarts
91
+
92
+ - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
93
+ - Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
94
+ - Subagents that were working when Pi closed, crashed or `/reload`ed are resumed from their saved transcripts when the profile next opens in interactive Pi, and the main agent is told which.
95
+ - Headless `pi -p` doesn't resume them; they wait for the next interactive start.
96
+ - Ones you stopped or paused aren't resumed.
97
+
98
+ ## Long subagent turns
99
+
100
+ Subagent transcripts aren't in the summary tree, so subagents use Pi's own compaction instead of the Turn refresh threshold, starting at about the same point.
@@ -6,7 +6,7 @@ Source: https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
6
6
 
7
7
  The implementation was compared with the recipe fetched on 2026-10-04. The reference content SHA-256 was `8f6997e8944d85e4df53b5704bf7c4e393e4da361071181e9cc2f7d9d1b6e430`.
8
8
 
9
- The source remains upstream rather than duplicating the full article here. Its prompt strings, from the original and the 2026-10-08 revision, are in `src/prompts.ts` with the small adaptations the README lists, and attribution in `THIRD_PARTY_NOTICES.md`.
9
+ The source remains upstream rather than duplicating the full article here. Its prompt strings, from the original and the 2026-10-08 revision, are in `src/prompts.ts` with the small adaptations listed in `docs/recipe-differences.md`, and attribution in `THIRD_PARTY_NOTICES.md`.
10
10
 
11
11
  ## Implementation mapping
12
12
 
@@ -15,7 +15,7 @@ The source remains upstream rather than duplicating the full article here. Its p
15
15
  - `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3, plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
16
16
  - `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
17
17
  - `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
18
- - `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and grouped subagent reports (on, where the revised recipe reports each subagent on its own).
18
+ - `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and the turn refresh threshold (30%, where the recipe has none). Grouped subagent reports are off by default, as in the revised recipe, which reports each subagent on its own.
19
19
  - `src/import/`: profile-scoped historical imports retain user messages and final assistant replies, following the lighter history described in recipe section 10. An import hands `Memory` one message at a time, each once the one before it is summarized, so the compactor sees the view a live chat would have shown it. Source adapters, final-reply detection, replay filtering, and ChatGPT branch labels are integration choices. Live-chat tool logging remains unchanged.
20
20
 
21
21
  Profiles, native Pi UI, conversation import, and local Git checkpoints are integration choices described in the README.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agent-view.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  import { Editor, Spacer, compositeTuiLine, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
7
7
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
8
  import type { Children } from './agents.ts';
9
- import type { RunInfo } from './runs.ts';
9
+ import { isHandingOff, type RunInfo } from './runs.ts';
10
10
  import { textContent } from './transcript.ts';
11
11
  import { record } from './cache.ts';
12
12
  import { elapsed, fit, oneLine } from './inspector.ts';
@@ -164,7 +164,7 @@ export interface AgentViewOptions {
164
164
 
165
165
  const statusOf = (run: RunInfo, children: Children) => {
166
166
  const live = children.live(run.id);
167
- if (!live) return run.state;
167
+ if (!live) return isHandingOff(run) ? 'handing off' : run.state;
168
168
  if (run.state === 'stopping') return 'stopping';
169
169
  if (run.state === 'waiting') return 'waiting for its agents';
170
170
  if (run.state === 'paused') return 'interrupted · waiting for you';
package/src/agents.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from 'node:os';
5
5
  import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type InlineExtension, type ModelRegistry } from '@earendil-works/pi-coding-agent';
6
6
  import * as sdk from '@earendil-works/pi-coding-agent';
7
7
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
- import type { Api, Model } from '@earendil-works/pi-ai';
8
+ import { StringEnum, type Api, type Model } from '@earendil-works/pi-ai';
9
9
  import { allowSearch, CONNECTED, cutOffChildren, delegation, NO_DELEGATION, NOT_CONNECTED, RESTARTED, SEARCH_DOC, STEERABLE, SUBAGENT, VIEW_DOC } from './prompts.ts';
10
10
  import { memoryTools, searchTool } from './tools.ts';
11
11
  import { VIEW, type Memory } from './memory.ts';
@@ -14,10 +14,11 @@ import { cachePayload } from './cache.ts';
14
14
  import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
16
  import { fullChat, refreshFraction, textContent } from './transcript.ts';
17
- import { Type } from 'typebox';
17
+ import { Type, type Static } from 'typebox';
18
18
  import { result } from './tools.ts';
19
19
  import type { HandoffEvidence } from './handoff.ts';
20
20
  import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
21
+ import { THINKING } from './profiles.ts';
21
22
 
22
23
  export interface LiveRun {
23
24
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
@@ -64,7 +65,17 @@ const packageName = (path: string): string | undefined => {
64
65
  /** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
65
66
  export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
66
67
  resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
67
- export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
68
+ const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
69
+ /** The spawn tools' tasks. What a task leaves out of model and thinking is the profile's subagent choice. */
70
+ export const TASKS = Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })),
71
+ model: Type.Optional(Type.String({ description: 'Leave unset for the profile\'s subagent model (named in your instructions). Set only when the user asks for a model, as provider/id from models().' })),
72
+ thinking: Type.Optional(StringEnum(THINKING, { description: 'Leave unset for the profile\'s subagent level. Set only when the user asks for one.' })) }), { minItems: 1 });
73
+ export type Task = Static<typeof TASKS>[number];
74
+ /** The tool beside spawn that lists what a task's `model` may be. */
75
+ export const modelsTool = (children: () => Children) => ({ name: 'models', label: 'List models',
76
+ description: 'The models a spawn task may name, as provider/id, with the profile\'s subagent default marked. provider narrows the list to one provider.',
77
+ parameters: Type.Object({ provider: Type.Optional(Type.String()) }),
78
+ execute: async (_id: string, args: { provider?: string }) => result(children().models(args.provider)) });
68
79
  const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
69
80
 
70
81
  // Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
@@ -114,6 +125,16 @@ export class Children {
114
125
  }
115
126
  private get settings(): Settings { return { ...DEFAULT_SETTINGS, ...this.options.settings?.() }; }
116
127
  private full(extra: number) { return this.running.size + this.launching + extra > this.settings.maxAgents; }
128
+ /** A model named `provider/id`, as runs record it. */
129
+ /** The models a task may name, one `provider/id` per line: every model Pi is logged in to, the profile's subagent choice marked. */
130
+ models(provider?: string) {
131
+ const profile = this.choice(), fallback = `${profile.provider}/${profile.model}`;
132
+ const available = this.registry.getAvailable();
133
+ const names = available.filter(m => !provider || m.provider === provider).map(m => `${m.provider}/${m.id}`).sort();
134
+ if (!names.length) throw new Error(provider ? `No models for provider ${provider}. Providers: ${[...new Set(available.map(m => m.provider))].sort().join(', ') || 'none'}.` : 'No models available.');
135
+ return names.map(name => name === fallback ? `${name} (subagent default, thinking ${profile.thinking})` : name).join('\n');
136
+ }
137
+ private find(name: string) { const slash = name.indexOf('/'); return slash > 0 ? this.registry.find(name.slice(0, slash), name.slice(slash + 1)) : undefined; }
117
138
  get ids() { return [...this.running.keys()]; }
118
139
  get active() { return this.completions.size > 0 || this.launching > 0 || this.settling > 0; }
119
140
  live(id: string) { return this.running.get(id); }
@@ -126,10 +147,24 @@ export class Children {
126
147
  const file = this.history.records.get(id)?.sessionFile;
127
148
  return file ? sessionMessages(file) : [];
128
149
  }
150
+ /**
151
+ * Every message a run has finalized, in order and never rewritten: Pi's session entries, for a live run too.
152
+ * `session.messages` is only the model's projection, which compaction and retries replace with a shorter list.
153
+ */
154
+ retained(id: string): AgentMessage[] {
155
+ const live = this.running.get(id);
156
+ return live ? live.session.sessionManager.getEntries().flatMap(e => e.type === 'message' ? [e.message] : []) : this.messages(id);
157
+ }
129
158
  /** A run's messages for zoom, live or finished; undefined for no such run. A subagent `reader` sees only its own descendants, as with tell. */
130
159
  transcript(id: string, reader?: string) {
131
160
  const known = reader ? this.history.descendants(reader).some(run => run.id === id) : this.running.has(id) || this.history.records.has(id);
132
- return known ? this.messages(id) : undefined;
161
+ if (!known) return undefined;
162
+ const live = this.running.get(id);
163
+ if (!live) return this.messages(id);
164
+ const retained = this.retained(id);
165
+ // Pi puts the finalized message in session.messages before message_end listeners run, then persists that same object.
166
+ const tail = live.streaming ?? live.session.messages.at(-1);
167
+ return tail && !retained.includes(tail) ? [...retained, tail] : retained;
133
168
  }
134
169
  /** Runs at work now: running, waiting for children, or stopping; not paused. */
135
170
  get working() { return [...this.running.values()].filter(live => isRunning(live.info)).map(live => live.info); }
@@ -157,16 +192,23 @@ export class Children {
157
192
  this.changed();
158
193
  } catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
159
194
  }
160
- async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
195
+ async spawn(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
161
196
  return (await this.launch(tasks, cwd, signal, parentId, connected)).ids;
162
197
  }
163
198
  /** The spawn tools' answer, saying how this spawn's reports will come back. */
164
- async start(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string) {
199
+ async start(tasks: Task[], cwd: string, signal?: AbortSignal, parentId?: string) {
165
200
  const { ids, grouped } = await this.launch(tasks, cwd, signal, parentId);
166
201
  return `Started: ${ids.join(', ')}. ${grouped && ids.length > 1 ? 'Their reports will arrive together, as one message, once all of them have finished.' : 'Reports will arrive automatically.'}`;
167
202
  }
168
- private async launch(tasks: { task: string; cwd?: string }[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
203
+ private async launch(tasks: Task[], cwd: string, signal: AbortSignal | undefined, parentId: string | undefined, connected = false) {
169
204
  for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
205
+ const profile = this.choice();
206
+ const runs = tasks.map(task => {
207
+ const name = task.model ?? `${profile.provider}/${profile.model}`, model = this.find(name);
208
+ // Like Pi's own setModel: a named model needs auth, so a guess at a provider the user isn't logged in to fails here, not in the child.
209
+ if (!model || task.model && !this.registry.hasConfiguredAuth(model)) throw new Error(`Subagent model unavailable: ${name}. Models you can use: ${this.registry.getAvailable().map(m => `${m.provider}/${m.id}`).join(', ') || 'none'}.`);
210
+ return { ...task, model, thinking: task.thinking ?? profile.thinking };
211
+ });
170
212
  if (this.closing) throw new Error('Profile is closing.');
171
213
  this.settling++;
172
214
  try { await this.memory.settle(signal); } finally { this.settling--; }
@@ -179,10 +221,7 @@ export class Children {
179
221
  if (this.closing) throw new Error('Profile is closing.');
180
222
  if (this.full(tasks.length)) throw new Error(`Profile limit: at most ${maxAgents} active agents, including parents and descendants. Reduce the batch or continue without delegating.`);
181
223
  const view = this.memory.render();
182
- const selected = this.choice();
183
- const model = this.registry.find(selected.provider, selected.model);
184
- if (!model) throw new Error(`Subagent model unavailable: ${selected.provider}/${selected.model}`);
185
- const launched = await this.track(this.launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }));
224
+ const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
186
225
  // Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
187
226
  const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
188
227
  for (const live of launched) {
@@ -202,8 +241,8 @@ export class Children {
202
241
  launch.then(done, done);
203
242
  return launch;
204
243
  }
205
- private async launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }: { tasks: { task: string; cwd?: string }[]; cwd: string; depth: number; parentId?: string; connected: boolean;
206
- selected: ModelChoice; model: Model<Api>; signal?: AbortSignal; cancelled: () => boolean }) {
244
+ private async launchBatch({ tasks, cwd, depth, parentId, connected, signal, cancelled }: { tasks: { task: string; cwd?: string; model: Model<Api>; thinking: ModelChoice['thinking'] }[]; cwd: string; depth: number;
245
+ parentId?: string; connected: boolean; signal?: AbortSignal; cancelled: () => boolean }) {
207
246
  const launched: LiveRun[] = [];
208
247
  let reserved = tasks.length;
209
248
  this.launching += reserved;
@@ -212,9 +251,9 @@ export class Children {
212
251
  signal?.throwIfAborted();
213
252
  if (cancelled()) throw new Error('Parent or profile is stopping.');
214
253
  const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
215
- const session = await this.open({ id, directory, depth, parentId, connected, provider: selected.provider, model, thinking: selected.thinking,
254
+ const session = await this.open({ id, directory, depth, parentId, connected, model: task.model, thinking: task.thinking,
216
255
  sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
217
- const info: RunInfo = { id, task: task.task, cwd: directory, model: `${selected.provider}/${selected.model}`, thinking: session.thinkingLevel,
256
+ const info: RunInfo = { id, task: task.task, cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
218
257
  parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
219
258
  const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
220
259
  launched.push(live); this.save(info); this.running.set(id, live);
@@ -237,11 +276,11 @@ export class Children {
237
276
  return launched;
238
277
  }
239
278
  /** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
240
- private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
279
+ private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; model: Model<Api>;
241
280
  thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
242
281
  const { id, directory, depth, parentId, connected } = o;
243
282
  const { subagentLevels, maxAgents, memorySearch } = this.settings, delegates = depth < subagentLevels;
244
- const instructions = [this.instructions(), delegates ? delegation(maxAgents) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
283
+ const instructions = [this.instructions(), delegates ? delegation(maxAgents, this.choice()) : NO_DELEGATION, STEERABLE, connected ? CONNECTED : NOT_CONNECTED].filter(Boolean).join('\n\n');
245
284
  // The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
246
285
  const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(),
247
286
  compaction: subagentCompaction(o.model.contextWindow, refreshFraction(this.settings.refreshAt)), cacheWarming: 'off' });
@@ -249,8 +288,8 @@ export class Children {
249
288
  noPromptTemplates: true,
250
289
  extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
251
290
  extensionFactories: [...builtinExtensions(this.options.builtins?.() ?? []), pi => {
252
- const provider = this.registry.getRegisteredProviderConfig(o.provider);
253
- if (provider) pi.registerProvider(o.provider, provider);
291
+ const provider = this.registry.getRegisteredProviderConfig(o.model.provider);
292
+ if (provider) pi.registerProvider(o.model.provider, provider);
254
293
  // Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
255
294
  pi.on('before_agent_start', event => {
256
295
  event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
@@ -262,8 +301,8 @@ export class Children {
262
301
  await loader.reload();
263
302
  const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
264
303
  model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
265
- customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
266
- excludeTools: delegates ? [] : ['spawn', 'tell'],
304
+ customTools: [...memoryTools(() => this.memory, run => this.transcript(run, id)), ...(memorySearch ? [searchTool(() => this.memory)] : []), ...(delegates ? [...this.delegationTools(id, directory, subagentLevels, maxAgents), modelsTool(() => this)] : []), this.parentTool(id, parentId, connected)],
305
+ excludeTools: delegates ? [] : ['spawn', 'tell', 'models'],
267
306
  });
268
307
  // Callers track the session only after this returns: clean up here if its extensions fail to start.
269
308
  try { await session.bindExtensions({}); }
@@ -271,9 +310,9 @@ export class Children {
271
310
  return session;
272
311
  }
273
312
  private delegationTools(parentId: string, cwd: string, levels: number, maxAgents: number) {
274
- return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
275
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
276
- execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
313
+ return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. models() lists the ids a task's model may name. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
314
+ parameters: Type.Object({ tasks: TASKS }),
315
+ execute: async (_id: string, args: { tasks: Task[] }, signal?: AbortSignal) => result(await this.start(args.tasks, cwd, signal, parentId)),
277
316
  }, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children. A finished child is resumed with its earlier conversation, and its new report arrives automatically.',
278
317
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
279
318
  execute: async (_id: string, args: { id: string; message: string }) => {
@@ -517,14 +556,13 @@ export class Children {
517
556
  try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
518
557
  if (!manager?.getEntries().some(e => e.type === 'message')) throw new Error(`The saved transcript of ${id} is missing or unreadable, so it cannot be resumed. Spawn a fresh subagent and give it the context it needs.`);
519
558
  if (!existsSync(run.cwd) || !statSync(run.cwd).isDirectory()) throw new Error(`${id}'s directory ${run.cwd} no longer exists, so it cannot be resumed. Spawn a fresh subagent instead.`);
520
- const slash = run.model.indexOf('/'), provider = run.model.slice(0, slash);
521
- const model = slash > 0 ? this.registry.find(provider, run.model.slice(slash + 1)) : undefined;
559
+ const model = this.find(run.model);
522
560
  if (!model) throw new Error(`${id}'s model ${run.model} is not available, so it cannot be resumed. Spawn a fresh subagent instead.`);
523
561
  this.launching++; this.resuming.add(id);
524
562
  let reserved = true;
525
563
  try {
526
564
  // No thinking level: the session restores the one it ran with.
527
- const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
565
+ const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, model, sessionManager: manager });
528
566
  // stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
529
567
  const parent = run.parentId ? this.running.get(run.parentId) : undefined;
530
568
  if (this.closing || run.parentId && (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state))) {
package/src/cache.ts CHANGED
@@ -30,8 +30,10 @@ function lastBlock(message: Record<string, unknown>) {
30
30
  return record(block) ? block : undefined;
31
31
  }
32
32
 
33
- /** Anthropic: marks on the view (see MARKS) plus automatic end-of-request caching, so the next call finds the last one's entry
34
- * and pays only for the lines after it. */
33
+ /** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
34
+ * and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
35
+ * a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
36
+ * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'"). */
35
37
  export function cachePayload(payload: unknown): unknown {
36
38
  if (!record(payload) || !Array.isArray(payload.messages)) return payload;
37
39
  const messages = payload.messages;
@@ -60,7 +62,17 @@ export function cachePayload(payload: unknown): unknown {
60
62
  // Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
61
63
  const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
62
64
  const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
63
- if (turn) turn.cache_control = { type: 'ephemeral' };
64
- else payload.cache_control = { type: 'ephemeral' };
65
+ const end = turn ?? endBlock(messages);
66
+ if (end) end.cache_control = { type: 'ephemeral' };
65
67
  return payload;
66
68
  }
69
+
70
+ /** The last block of the last non-system message that can carry a mark (thinking blocks can't). */
71
+ function endBlock(messages: unknown[]) {
72
+ const message = messages.filter(m => !record(m) || m.role !== 'system').at(-1);
73
+ if (!record(message)) return undefined;
74
+ if (typeof message.content === 'string') return lastBlock(message);
75
+ if (!Array.isArray(message.content)) return undefined;
76
+ const block: unknown = message.content.findLast((b: unknown) => record(b) && b.type !== 'thinking' && b.type !== 'redacted_thinking');
77
+ return record(block) ? block : undefined;
78
+ }
package/src/compactor.ts CHANGED
@@ -3,7 +3,7 @@ import { clampThinkingLevel, isRetryableAssistantError, type Api, type Assistant
3
3
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
4
4
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
5
5
  import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
6
- import { bytes, NODE, type Compressor } from './memory.ts';
6
+ import { bytes, cut, Refused, type Compressor } from './memory.ts';
7
7
  import { cachePayload, splitView } from './cache.ts';
8
8
  import { DEFAULT_SETTINGS } from './settings.ts';
9
9
 
@@ -83,20 +83,22 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
83
83
  };
84
84
  for (let attempt = 0; attempt < 5; attempt++) {
85
85
  const reply = await answer();
86
+ // A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
87
+ if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && !isRetryableAssistantError(reply))
88
+ throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
86
89
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
87
90
  // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
88
91
  if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
89
92
  // The compactions' view shows each line under its id+n| head, which a line can copy.
90
93
  const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
91
- if (!line) throw new Error('Compactor returned no text.');
94
+ if (!line) throw new Refused('Compactor returned no text.');
92
95
  tries.push(line);
93
96
  // A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
94
97
  if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
95
98
  messages.push(reply);
96
- const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
97
- messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
99
+ messages.push({ role: 'user', content: tooLong(bytes(line), cut(line)), timestamp: Date.now() });
98
100
  }
99
- if (!tries.length) throw new Error('Compactor returned no whole line in 5 tries.');
101
+ if (!tries.length) throw new Refused('Compactor returned no whole line in 5 tries.');
100
102
  return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
101
103
  };
102
104
  }
package/src/import/job.ts CHANGED
@@ -95,7 +95,7 @@ export function discardImport(dir: string) {
95
95
  rmSync(pendingFile(dir)); rmSync(generationPath(dir, job.target), { recursive: true, force: true });
96
96
  }
97
97
  export interface ImportProgress { messages: number; total: number; summaries: number; error?: string }
98
- export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.importJobs,
98
+ export async function runImport(dir: string, compress: Compressor, signal: AbortSignal, jobs = DEFAULT_SETTINGS.jobs,
99
99
  progress: (state: ImportProgress) => void = () => {}): Promise<ImportJob> {
100
100
  const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
101
101
  const path = generationPath(dir, job.target);
@@ -113,7 +113,7 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
113
113
  if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
114
114
  report();
115
115
  // Recipe §10: imported messages are compressed like any other. Each is logged once its node can start, as a burst of live
116
- // messages is, so up to `jobs` summaries run at once (the "Import: summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error).
116
+ // messages is, so up to `jobs` summaries run at once (the "Summaries at once" setting). Unlike a turn, an import waits through failures (its progress shows the error).
117
117
  for (const e of plan.slice(memory.root.length)) { await memory.settle(signal, 'ahead'); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
118
118
  await memory.settle(signal, 'tree'); signal.throwIfAborted();
119
119
  // Close all writers before the single atomic pointer swap. The previous generation stays intact.
package/src/index.ts CHANGED
@@ -10,13 +10,13 @@ import { Memory } from './memory.ts';
10
10
  import { atomicWrite, storeFor } from './store.ts';
11
11
  import { createCompressor } from './compactor.ts';
12
12
  import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
13
- import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
13
+ import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
14
14
  import { cachePayload, record } from './cache.ts';
15
15
  import { saveImages } from './images.ts';
16
16
  import { asUser, boundedMessage, atWork, buildContext, logMessage, previousExchange, refreshedRun, refreshBudget, refreshFraction, refreshPoint, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
17
17
  import { registerReportRenderer, type ReportDetails } from './report-message.ts';
18
18
  import { memoryTools, result, searchTool } from './tools.ts';
19
- import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
19
+ import { Children, loadedBuiltins, modelsTool, TASKS } from './agents.ts';
20
20
  import { isActiveRun, type RunInfo } from './runs.ts';
21
21
  import { exportBrowser } from './browser.ts';
22
22
  import { Inbox } from './inbox.ts';
@@ -204,7 +204,7 @@ export default function optchat(pi: ExtensionAPI) {
204
204
  const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
205
205
  usage.compression(message, 'compactor', sessionId);
206
206
  status(ctx);
207
- }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'));
207
+ }, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'), undefined, config.jobs);
208
208
  openingMemory = memory;
209
209
  const inbox = new Inbox(dir);
210
210
  const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
@@ -385,7 +385,7 @@ export default function optchat(pi: ExtensionAPI) {
385
385
  const a = required();
386
386
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
387
387
  syncSearch(a.config);
388
- event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
388
+ event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${modelsHint(a.config.subagent)}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
389
389
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
390
390
  prompt = event.systemPrompt;
391
391
  });
@@ -507,13 +507,14 @@ export default function optchat(pi: ExtensionAPI) {
507
507
  if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
508
508
  };
509
509
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
510
- description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
511
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
510
+ description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Leave a task\'s model unset for the profile\'s default; name one only when the user asks, with an id from models(). Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
511
+ parameters: Type.Object({ tasks: TASKS }),
512
512
  async execute(_id, args, signal, _update, ctx) {
513
513
  const text = await required().children.start(args.tasks, ctx.cwd, signal); status(ctx);
514
514
  return result(text);
515
515
  },
516
516
  });
517
+ pi.registerTool(modelsTool(() => required().children));
517
518
  pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a subagent. A running one gets it at its next tool boundary. A finished one you started is resumed with its earlier conversation, and its new report arrives automatically.',
518
519
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
519
520
  async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
@@ -586,7 +587,7 @@ export default function optchat(pi: ExtensionAPI) {
586
587
  const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
587
588
  a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
588
589
  }, () => a.config.summaryAcceptBytes);
589
- const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal);
590
+ const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.jobs, progress), signal);
590
591
  ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
591
592
  : 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
592
593
  })();