@oh-my-pi/pi-coding-agent 14.6.5 → 14.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/examples/hooks/handoff.ts +1 -1
  3. package/examples/hooks/qna.ts +1 -1
  4. package/examples/sdk/03-custom-prompt.ts +7 -4
  5. package/examples/sdk/README.md +1 -1
  6. package/package.json +7 -7
  7. package/src/autoresearch/index.ts +48 -44
  8. package/src/cli/read-cli.ts +58 -0
  9. package/src/cli.ts +1 -0
  10. package/src/commands/read.ts +40 -0
  11. package/src/commit/agentic/agent.ts +1 -1
  12. package/src/commit/analysis/conventional.ts +1 -1
  13. package/src/commit/analysis/summary.ts +1 -1
  14. package/src/commit/changelog/generate.ts +1 -1
  15. package/src/commit/map-reduce/map-phase.ts +1 -1
  16. package/src/commit/map-reduce/reduce-phase.ts +1 -1
  17. package/src/config/settings-schema.ts +39 -0
  18. package/src/edit/line-hash.ts +34 -4
  19. package/src/edit/modes/hashline.ts +221 -7
  20. package/src/edit/streaming.ts +4 -1
  21. package/src/export/html/index.ts +1 -1
  22. package/src/extensibility/extensions/runner.ts +3 -3
  23. package/src/extensibility/extensions/types.ts +4 -4
  24. package/src/main.ts +3 -3
  25. package/src/memories/index.ts +1 -1
  26. package/src/modes/components/agent-dashboard.ts +1 -1
  27. package/src/modes/components/custom-editor.ts +4 -5
  28. package/src/modes/components/read-tool-group.ts +4 -9
  29. package/src/modes/components/tool-execution.ts +4 -0
  30. package/src/modes/controllers/event-controller.ts +2 -0
  31. package/src/modes/controllers/input-controller.ts +3 -1
  32. package/src/modes/interactive-mode.ts +24 -0
  33. package/src/modes/rpc/rpc-types.ts +1 -1
  34. package/src/modes/utils/context-usage.ts +12 -5
  35. package/src/modes/utils/ui-helpers.ts +1 -0
  36. package/src/prompts/system/project-prompt.md +36 -0
  37. package/src/prompts/system/system-prompt.md +0 -29
  38. package/src/prompts/tools/github.md +1 -0
  39. package/src/prompts/tools/hashline.md +24 -6
  40. package/src/prompts/tools/read.md +15 -14
  41. package/src/sdk.ts +29 -28
  42. package/src/session/agent-session.ts +20 -12
  43. package/src/session/compaction/branch-summarization.ts +1 -1
  44. package/src/session/compaction/compaction.ts +3 -3
  45. package/src/session/session-dump-format.ts +10 -5
  46. package/src/session/session-manager.ts +57 -0
  47. package/src/session/streaming-output.ts +1 -1
  48. package/src/system-prompt.ts +35 -3
  49. package/src/task/executor.ts +4 -3
  50. package/src/tools/fetch.ts +4 -4
  51. package/src/tools/gh.ts +187 -0
  52. package/src/tools/image-gen.ts +3 -1
  53. package/src/tools/inspect-image.ts +1 -1
  54. package/src/tools/output-meta.ts +1 -1
  55. package/src/tools/path-utils.ts +11 -0
  56. package/src/tools/read.ts +388 -204
  57. package/src/tools/search.ts +1 -1
  58. package/src/tools/sqlite-reader.ts +1 -1
  59. package/src/utils/commit-message-generator.ts +1 -1
  60. package/src/utils/title-generator.ts +1 -1
  61. package/src/web/search/providers/anthropic.ts +1 -1
  62. package/src/workspace-tree.ts +396 -0
@@ -18,13 +18,13 @@ const CELL_FILLED_MESSAGES = "⛃";
18
18
  const CELL_FREE = "⛶";
19
19
  const CELL_BUFFER = "⛝";
20
20
 
21
- type CategoryId = "systemPrompt" | "systemTools" | "skills" | "messages";
21
+ type CategoryId = "systemPrompt" | "systemContext" | "systemTools" | "skills" | "messages";
22
22
 
23
23
  interface CategoryInfo {
24
24
  id: CategoryId;
25
25
  label: string;
26
26
  tokens: number;
27
- color: "accent" | "warning" | "success" | "userMessageText";
27
+ color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel";
28
28
  glyph: string;
29
29
  }
30
30
 
@@ -86,12 +86,19 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
86
86
  // Tools = JSON tool schema sent separately on the wire
87
87
  // Skills = the skill list embedded in the system prompt
88
88
  // Messages = conversation messages
89
- const systemPromptTextTokens = countTokens(session.systemPrompt);
90
- const systemPromptTokens = Math.max(0, systemPromptTextTokens - skillsTokens);
89
+ const systemPromptTokens = Math.max(0, countTokens(session.systemPrompt[0] ?? "") - skillsTokens);
90
+ const systemContextTokens = countTokens(session.systemPrompt.slice(1));
91
91
 
92
92
  const categories: CategoryInfo[] = [
93
93
  { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
94
94
  { id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED },
95
+ {
96
+ id: "systemContext",
97
+ label: "System context",
98
+ tokens: systemContextTokens,
99
+ color: "customMessageLabel",
100
+ glyph: CELL_FILLED,
101
+ },
95
102
  { id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED },
96
103
  {
97
104
  id: "messages",
@@ -134,7 +141,7 @@ export function computeContextBreakdown(session: AgentSession): ContextBreakdown
134
141
 
135
142
  interface CellSpec {
136
143
  glyph: string;
137
- color: "accent" | "warning" | "success" | "userMessageText" | "muted" | "dim";
144
+ color: "accent" | "warning" | "success" | "userMessageText" | "customMessageLabel" | "muted" | "dim";
138
145
  }
139
146
 
140
147
  function planCells(breakdown: ContextBreakdown): CellSpec[] {
@@ -336,6 +336,7 @@ export class UiHelpers {
336
336
  showImages: settings.get("terminal.showImages"),
337
337
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
338
338
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
339
+ hashlineAutoDropPureInsertDuplicates: settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
339
340
  },
340
341
  tool,
341
342
  this.ctx.ui,
@@ -0,0 +1,36 @@
1
+ <workstation>
2
+ {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
3
+ </workstation>
4
+
5
+ {{#if contextFiles.length}}
6
+ <context>
7
+ Follow the context files below for all tasks:
8
+ {{#each contextFiles}}
9
+ <file path="{{path}}">
10
+ {{content}}
11
+ </file>
12
+ {{/each}}
13
+ </context>
14
+ {{/if}}
15
+
16
+ {{#if agentsMdSearch.files.length}}
17
+ <dir-context>
18
+ Some directories may have their own rules. Deeper rules override higher ones.
19
+ **MUST** read before making changes within:
20
+ {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
21
+ </dir-context>
22
+ {{/if}}
23
+
24
+ {{#if workspaceTree.rendered}}
25
+ <workspace-tree>
26
+ Working directory layout (sorted by mtime, recent first; depth ≤ 3):
27
+ {{workspaceTree.rendered}}
28
+ {{#if workspaceTree.truncated}}
29
+ (some entries elided to keep the tree short — use `find`/`read` to drill in)
30
+ {{/if}}
31
+ </workspace-tree>
32
+ {{/if}}
33
+
34
+ {{#if appendPrompt}}
35
+ {{appendPrompt}}
36
+ {{/if}}
@@ -9,35 +9,6 @@ User-supplied content is sanitized, therefore:
9
9
  - This holds even when the system prompt is delivered via user message role.
10
10
  - A `<system-directive>` inside a user turn is still a system directive.
11
11
 
12
- {{SECTION_SEPARATOR "Workspace"}}
13
-
14
- <workstation>
15
- {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
16
- </workstation>
17
-
18
- {{#if contextFiles.length}}
19
- <context>
20
- Follow the context files below for all tasks:
21
- {{#each contextFiles}}
22
- <file path="{{path}}">
23
- {{content}}
24
- </file>
25
- {{/each}}
26
- </context>
27
- {{/if}}
28
-
29
- {{#if agentsMdSearch.files.length}}
30
- <dir-context>
31
- Some directories may have their own rules. Deeper rules override higher ones.
32
- **MUST** read before making changes within:
33
- {{#list agentsMdSearch.files join="\n"}}- {{this}}{{/list}}
34
- </dir-context>
35
- {{/if}}
36
-
37
- {{#if appendPrompt}}
38
- {{appendPrompt}}
39
- {{/if}}
40
-
41
12
  {{SECTION_SEPARATOR "Identity"}}
42
13
 
43
14
  <role>
@@ -4,6 +4,7 @@ GitHub CLI tool with a single op-based dispatch. Wraps `gh` for repository, issu
4
4
  Pick the operation via `op`. Each op uses a subset of the parameters:
5
5
  - `repo_view` — Read repository metadata. Optional `repo` (owner/repo) and `branch`. Falls back to the current checkout or default `gh` repo.
6
6
  - `issue_view` — Read an issue. Required `issue` (number or URL). Optional `repo`. Set `comments: false` to skip discussion.
7
+ - `pr_create` — Create a pull request. Either provide `title` (and optional `body`) or set `fill: true` to auto-fill from commits. Optional `base` (target, defaults to repo default), `head` (source, defaults to current branch), `draft`, `repo`, `reviewer[]`, `assignee[]`, `label[]`. Returns the new PR URL plus a summary.
7
8
  - `pr_view` — Read one or more pull requests, including reviews and inline review comments. Optional `pr` (number, URL, branch, or array of any — pass an array to fetch multiple PRs in one call); omitting it targets the current branch's PR. Optional `repo`. Set `comments: false` for a lighter summary.
8
9
  - `pr_diff` — Read one or more pull request diffs. Optional `pr` (single identifier or array for batch). Optional `repo`. Set `nameOnly: true` for changed file names. Use `exclude` to drop generated paths from the diff.
9
10
  - `pr_checkout` — Check one or more pull requests out into dedicated git worktrees. Optional `pr` (number, URL, branch, or array of any of those — pass an array to batch-check-out multiple PRs in one call), `repo`, `force` (reset existing local branch).
@@ -8,8 +8,8 @@ This format is purely textual. The tool has NO awareness of language, indentatio
8
8
 
9
9
  <ops>
10
10
  @PATH header: subsequent ops apply to PATH
11
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
12
11
  + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
12
+ < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
13
13
  - A..B delete the line range (inclusive); `- A` for one line
14
14
  = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows
15
15
  </ops>
@@ -20,6 +20,7 @@ This format is purely textual. The tool has NO awareness of language, indentatio
20
20
  - `< A` inserts before line A; `+ A` inserts after line A. `< BOF` / `+ BOF` both prepend; `< EOF` / `+ EOF` both append.
21
21
  - `= A..B` replaces the inclusive range with the following payload lines. `= A` (or `= A..B`) with no payload blanks the range to a single empty line.
22
22
  - `- A..B` deletes the inclusive range; omit `..B` for one line.
23
+ - Pick the smallest op for the change: pure addition → `+`/`<`; pure deletion → `-`; `= A..B` ONLY when content inside `A..B` is actually being modified or removed.
23
24
  </rules>
24
25
 
25
26
  <case file="a.ts">
@@ -39,11 +40,9 @@ This format is purely textual. The tool has NO awareness of language, indentatio
39
40
 
40
41
  # Replace a contiguous range with multiple lines
41
42
  @a.ts
42
- = {{hrefr 3}}..{{hrefr 6}}
43
- {{hsep}}export function label(name: string): string {
43
+ = {{hrefr 4}}..{{hrefr 5}}
44
44
  {{hsep}} const clean = (name || DEF).trim();
45
45
  {{hsep}} return clean.length === 0 ? DEF : clean.toUpperCase();
46
- {{hsep}}}
47
46
 
48
47
  # Insert BEFORE a line
49
48
  @a.ts
@@ -73,11 +72,30 @@ This format is purely textual. The tool has NO awareness of language, indentatio
73
72
  = {{hrefr 2}}
74
73
  </examples>
75
74
 
75
+ <anti-pattern>
76
+ # WRONG — replaces 6 lines just to add one. Use `+` at the boundary instead.
77
+ @a.ts
78
+ = {{hrefr 1}}..{{hrefr 6}}
79
+ {{hsep}}const DEF = "guest";
80
+ {{hsep}}const DEBUG = false;
81
+ {{hsep}}
82
+ {{hsep}}export function label(name) {
83
+ {{hsep}} const clean = name || DEF;
84
+ {{hsep}} return clean.trim();
85
+ {{hsep}}}
86
+
87
+ # RIGHT — same effect, one-line insert
88
+ @a.ts
89
+ + {{hrefr 1}}
90
+ {{hsep}}const DEBUG = false;
91
+
92
+ If your replacement payload would render with even one unchanged line in the diff, you have the wrong op or the wrong range. Stop and rewrite as `+`/`<`/`-` plus a narrower `=`.
93
+ </anti-pattern>
94
+
76
95
  <critical>
77
96
  - Always copy anchors exactly from tool output, but **NEVER** include line content after the `{{hsep}}` separator in the op line.
78
- - Only emit changed lines. Do not restate unchanged context as payload.
79
97
  - Every inserted/replacement content line **MUST** start with `{{hsep}}`; raw content lines are invalid.
80
98
  - Do not write unified diff syntax (`@@`, `-OLD`, `+NEW`).
81
- - To replace a block, use one `= A..B` op followed by all replacement `{{hsep}}TEXT` payload lines.
82
99
  - `= A..B` deletes the range; payload is what's written. If a payload edge line already exists immediately outside `A..B`, widen the range to cover it — otherwise it duplicates.
100
+ - Multiple ops in one patch are cheap. Prefer two narrow ops over one wide `=`.
83
101
  </critical>
@@ -6,29 +6,30 @@ The `read` tool is multi-purpose and more capable than it looks — inspects fil
6
6
  - For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
7
7
 
8
8
  ## Parameters
9
- - `path` — file path or URL (required)
10
- - `sel` — optional selector for line ranges or raw mode
9
+ - `path` — file path or URL (required). Append `:<sel>` for line ranges or raw mode (for example `src/foo.ts:50-200` or `src/foo.ts:raw`).
11
10
  - `timeout` — seconds, for URLs only
12
11
 
13
12
  ## Selectors
14
13
 
15
- |`sel` value|Behavior|
14
+ |`path` suffix|Behavior|
16
15
  |---|---|
17
- |_(omitted)_|Read full file (up to {{DEFAULT_LIMIT}} lines)|
18
- |`50`|Read from line 50 onward|
19
- |`50-200`|Read lines 50-200|
20
- |`50+150`|Read 150 lines starting at line 50|
21
- |`20+1`|Read exactly one line|
16
+ |_(omitted)_|For parseable code files, return a structural summary. Otherwise read from the start (up to {{DEFAULT_LIMIT}} lines).|
17
+ |`:50`|Read from line 50 onward|
18
+ |`:50-200`|Read lines 50-200|
19
+ |`:50+150`|Read 150 lines starting at line 50|
20
+ |`:20+1`|Read exactly one line|
21
+ |`:raw`|Read verbatim text without anchors or summarization|
22
22
 
23
23
  # Filesystem
24
24
  - Reading a directory path returns a list of dirents.
25
25
  {{#if IS_HL_MODE}}
26
- - Reading a file returns lines prefixed with anchors (line+hash): `41th|def alpha():`
26
+ - Reading a file with an explicit selector returns lines prefixed with anchors (line+hash): `41th|def alpha():`
27
27
  {{else}}
28
28
  {{#if IS_LINE_NUMBER_MODE}}
29
- - Reading a file returns lines prefixed with line numbers: `41|def alpha():`
29
+ - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`
30
30
  {{/if}}
31
31
  {{/if}}
32
+ - Reading a parseable code file without a selector returns a structural summary with signatures/declarations kept and large bodies collapsed to `…`. Use `:raw` or an explicit range such as `:1-9999` for verbatim content.
32
33
 
33
34
  # Inspection
34
35
 
@@ -36,7 +37,7 @@ Extracts text from PDF, Word, PowerPoint, Excel, RTF, EPUB, and Jupyter notebook
36
37
 
37
38
  # Directories & Archives
38
39
 
39
- Directories and archive roots return a list of entries. Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read contents.
40
+ Directories and archive roots return a list of entries. Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read contents, and append a selector to the archive entry such as `archive.zip:dir/file.ts:50-60`.
40
41
 
41
42
  # SQLite Databases
42
43
 
@@ -50,13 +51,13 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
50
51
 
51
52
  # URLs
52
53
 
53
- Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout.
54
+ Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use a `:raw` suffix for untouched HTML; `timeout` to override the default request timeout. URL line selectors require the `L` form, for example `https://example.com/page:L50-L60`.
54
55
  </instruction>
55
56
 
56
57
  <critical>
57
58
  - You **MUST** use `read` for every file, directory, archive, and URL read. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, and `wget` are **FORBIDDEN** for inspection — any such Bash call is a bug, regardless of how short or convenient it looks.
58
59
  - You **MUST** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser if `read` fails to deliver reasonable content.
59
60
  - You **MUST** always include the `path` parameter — never call `read` with an empty argument object `{}`.
60
- - For specific line ranges, use `sel` (e.g. `sel="50-200"`, `sel="50+150"`) — do **NOT** reach for `sed -n`, `awk NR`, or `head`/`tail` pipelines.
61
- - You **MAY** use `sel` with URL reads; the tool paginates cached fetched output.
61
+ - For specific line ranges, append the selector to `path` (e.g. `path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`) — do **NOT** reach for `sed -n`, `awk NR`, or `head`/`tail` pipelines.
62
+ - You **MAY** use path suffix selectors with URL reads; the tool paginates cached fetched output.
62
63
  </critical>
package/src/sdk.ts CHANGED
@@ -102,6 +102,7 @@ import { closeAllConnections } from "./ssh/connection-manager";
102
102
  import { unmountAll } from "./ssh/sshfs-mount";
103
103
  import {
104
104
  type AgentsMdSearch,
105
+ type BuildSystemPromptResult,
105
106
  buildAgentsMdSearch,
106
107
  buildSystemPrompt as buildSystemPromptInternal,
107
108
  buildSystemPromptToolMetadata,
@@ -140,6 +141,7 @@ import { wrapToolWithMetaNotice } from "./tools/output-meta";
140
141
  import { queueResolveHandler } from "./tools/resolve";
141
142
  import { EventBus } from "./utils/event-bus";
142
143
  import { buildNamedToolChoice } from "./utils/tool-choice";
144
+ import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
143
145
 
144
146
  // Types
145
147
  export interface CreateAgentSessionOptions {
@@ -165,8 +167,8 @@ export interface CreateAgentSessionOptions {
165
167
  /** Models available for cycling (Ctrl+P in interactive mode) */
166
168
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
167
169
 
168
- /** System prompt. String replaces default, function receives default and returns final. */
169
- systemPrompt?: string | ((defaultPrompt: string) => string);
170
+ /** System prompt blocks. Array replaces default, function receives default blocks and returns final blocks. */
171
+ systemPrompt?: string[] | ((defaultPrompt: string[]) => string[]);
170
172
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
171
173
  * Keeps persisted session files isolated while reusing provider-side caches. */
172
174
  providerSessionId?: string;
@@ -270,6 +272,7 @@ export type { Skill } from "./extensibility/skills";
270
272
  export type { FileSlashCommand } from "./extensibility/slash-commands";
271
273
  export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp";
272
274
  export type { Tool } from "./tools";
275
+ export { buildDirectoryTree, buildWorkspaceTree, type DirectoryTree, type WorkspaceTree } from "./workspace-tree";
273
276
 
274
277
  export {
275
278
  // Individual tool classes (for custom usage)
@@ -399,9 +402,12 @@ export interface BuildSystemPromptOptions {
399
402
  }
400
403
 
401
404
  /**
402
- * Build the default system prompt.
405
+ * Build the default provider-facing system prompt blocks.
406
+ *
407
+ * The returned `systemPrompt` preserves the stable harness prompt and dynamic project context
408
+ * as separate entries so providers can cache prompt prefixes without concatenating blocks.
403
409
  */
404
- export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<string> {
410
+ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}): Promise<BuildSystemPromptResult> {
405
411
  return await buildSystemPromptInternal({
406
412
  cwd: options.cwd,
407
413
  skills: options.skills,
@@ -652,7 +658,7 @@ function buildMCPPromptCommands(manager: MCPManager): LoadedCustomCommand[] {
652
658
  * const { session } = await createAgentSession({
653
659
  * model: myModel,
654
660
  * getApiKey: async () => Bun.env.MY_KEY,
655
- * systemPrompt: 'You are helpful.',
661
+ * systemPrompt: ['You are helpful.'],
656
662
  * tools: codingTools({ cwd: getProjectDir() }),
657
663
  * skills: [],
658
664
  * sessionManager: SessionManager.inMemory(),
@@ -680,6 +686,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
680
686
  // (~200ms on large repos) and only needs `cwd`, so it can overlap with everything that follows.
681
687
  const agentsMdSearchPromise: Promise<AgentsMdSearch> = logger.time("buildAgentsMdSearch", buildAgentsMdSearch, cwd);
682
688
  agentsMdSearchPromise.catch(() => {});
689
+ const workspaceTreePromise: Promise<WorkspaceTree> = logger.time("buildWorkspaceTree", buildWorkspaceTree, cwd);
690
+ workspaceTreePromise.catch(() => {});
683
691
 
684
692
  // Independent discoveries that depend only on cwd/agentDir — kicked off in parallel and awaited
685
693
  // at their respective consumer sites. Their work can overlap with model resolution, secret loading,
@@ -1330,7 +1338,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1330
1338
  const repeatToolDescriptions = settings.get("repeatToolDescriptions");
1331
1339
  const eagerTasks = settings.get("task.eager");
1332
1340
  const intentField = settings.get("tools.intentTracing") || $flag("PI_INTENT_TRACING") ? INTENT_FIELD : undefined;
1333
- const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
1341
+ const rebuildSystemPrompt = async (
1342
+ toolNames: string[],
1343
+ tools: Map<string, AgentTool>,
1344
+ ): Promise<BuildSystemPromptResult> => {
1334
1345
  toolContextStore.setToolNames(toolNames);
1335
1346
  const discoverableMCPTools = mcpDiscoveryEnabled ? collectDiscoverableMCPTools(tools.values()) : [];
1336
1347
  const discoverableMCPSummary = summarizeDiscoverableMCPTools(discoverableMCPTools);
@@ -1380,33 +1391,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1380
1391
  eagerTasks,
1381
1392
  secretsEnabled,
1382
1393
  agentsMdSearch: agentsMdSearchPromise,
1394
+ workspaceTree: workspaceTreePromise,
1383
1395
  });
1384
1396
 
1385
1397
  if (options.systemPrompt === undefined) {
1386
1398
  return defaultPrompt;
1387
1399
  }
1388
- if (typeof options.systemPrompt === "string") {
1389
- return await buildSystemPromptInternal({
1390
- cwd,
1391
- skills,
1392
- contextFiles,
1393
- tools: promptTools,
1394
- toolNames,
1395
- rules: rulebookRules,
1396
- alwaysApplyRules,
1397
- skillsSettings: settings.getGroup("skills"),
1398
- customPrompt: options.systemPrompt,
1399
- appendSystemPrompt: appendPrompt,
1400
- repeatToolDescriptions,
1401
- intentField,
1402
- mcpDiscoveryMode: hasDiscoverableMCPTools,
1403
- mcpDiscoveryServerSummaries: discoverableMCPSummary.servers.map(formatDiscoverableMCPToolServerSummary),
1404
- eagerTasks,
1405
- secretsEnabled,
1406
- agentsMdSearch: agentsMdSearchPromise,
1407
- });
1400
+ if (Array.isArray(options.systemPrompt)) {
1401
+ return { systemPrompt: options.systemPrompt };
1408
1402
  }
1409
- return options.systemPrompt(defaultPrompt);
1403
+ return {
1404
+ systemPrompt: options.systemPrompt(defaultPrompt.systemPrompt),
1405
+ };
1410
1406
  };
1411
1407
 
1412
1408
  const toolNamesFromRegistry = Array.from(toolRegistry.keys());
@@ -1472,7 +1468,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1472
1468
  }
1473
1469
  }
1474
1470
 
1475
- const systemPrompt = await logger.time("buildSystemPrompt", rebuildSystemPrompt, initialToolNames, toolRegistry);
1471
+ const { systemPrompt } = await logger.time(
1472
+ "buildSystemPrompt",
1473
+ rebuildSystemPrompt,
1474
+ initialToolNames,
1475
+ toolRegistry,
1476
+ );
1476
1477
 
1477
1478
  const promptTemplates = await promptTemplatesPromise;
1478
1479
  toolSession.promptTemplates = promptTemplates;
@@ -245,8 +245,8 @@ export interface AgentSessionConfig {
245
245
  onResponse?: SimpleStreamOptions["onResponse"];
246
246
  /** Current session message-to-LLM conversion pipeline */
247
247
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
248
- /** System prompt builder that can consider tool availability */
249
- rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>;
248
+ /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
249
+ rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
250
250
  /**
251
251
  * Optional accessor for live MCP server instructions. Read by the session's
252
252
  * `rebuildSystemPrompt`-skip optimization to detect server-side instruction
@@ -520,9 +520,11 @@ export class AgentSession {
520
520
  #onPayload: SimpleStreamOptions["onPayload"] | undefined;
521
521
  #onResponse: SimpleStreamOptions["onResponse"] | undefined;
522
522
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
523
- #rebuildSystemPrompt: ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<string>) | undefined;
523
+ #rebuildSystemPrompt:
524
+ | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
525
+ | undefined;
524
526
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
525
- #baseSystemPrompt: string;
527
+ #baseSystemPrompt: string[];
526
528
  /**
527
529
  * Signature of the (toolNames, tool descriptions) tuple passed to the most
528
530
  * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
@@ -2083,8 +2085,8 @@ export class AgentSession {
2083
2085
  getLastAssistantMessage(): AssistantMessage | undefined {
2084
2086
  return this.#findLastAssistantMessage();
2085
2087
  }
2086
- /** Current effective system prompt (includes any per-turn extension modifications) */
2087
- get systemPrompt(): string {
2088
+ /** Current effective system prompt blocks (includes any per-turn extension modifications) */
2089
+ get systemPrompt(): string[] {
2088
2090
  return this.agent.state.systemPrompt;
2089
2091
  }
2090
2092
 
@@ -2281,7 +2283,8 @@ export class AgentSession {
2281
2283
  if (this.#rebuildSystemPrompt) {
2282
2284
  const signature = this.#computeAppliedToolSignature(validToolNames, tools);
2283
2285
  if (signature !== this.#lastAppliedToolSignature) {
2284
- this.#baseSystemPrompt = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
2286
+ const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
2287
+ this.#baseSystemPrompt = built.systemPrompt;
2285
2288
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
2286
2289
  this.#lastAppliedToolSignature = signature;
2287
2290
  }
@@ -2324,7 +2327,8 @@ export class AgentSession {
2324
2327
  async refreshBaseSystemPrompt(): Promise<void> {
2325
2328
  if (!this.#rebuildSystemPrompt) return;
2326
2329
  const activeToolNames = this.getActiveToolNames();
2327
- this.#baseSystemPrompt = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
2330
+ const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
2331
+ this.#baseSystemPrompt = built.systemPrompt;
2328
2332
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
2329
2333
  // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
2330
2334
  // the same tool set does not re-rebuild on top of the explicit refresh we
@@ -2335,14 +2339,14 @@ export class AgentSession {
2335
2339
  this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
2336
2340
  }
2337
2341
 
2338
- async #buildSystemPromptForAgentStart(promptText: string): Promise<string> {
2342
+ async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
2339
2343
  const backend = resolveMemoryBackend(this.settings);
2340
2344
  if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
2341
2345
 
2342
2346
  try {
2343
2347
  const injected = await backend.beforeAgentStartPrompt(this, promptText);
2344
2348
  if (!injected) return this.#baseSystemPrompt;
2345
- return `${this.#baseSystemPrompt}\n\n${injected}`;
2349
+ return [...this.#baseSystemPrompt, injected];
2346
2350
  } catch (err) {
2347
2351
  logger.debug("Memory backend beforeAgentStartPrompt failed", {
2348
2352
  backend: backend.id,
@@ -4215,7 +4219,11 @@ export class AgentSession {
4215
4219
  apiKey,
4216
4220
  customInstructions,
4217
4221
  compactionAbortController.signal,
4218
- { promptOverride: hookPrompt, extraContext: hookContext, remoteInstructions: this.#baseSystemPrompt },
4222
+ {
4223
+ promptOverride: hookPrompt,
4224
+ extraContext: hookContext,
4225
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
4226
+ },
4219
4227
  );
4220
4228
  summary = result.summary;
4221
4229
  shortSummary = result.shortSummary;
@@ -5328,7 +5336,7 @@ export class AgentSession {
5328
5336
  compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
5329
5337
  promptOverride: hookPrompt,
5330
5338
  extraContext: hookContext,
5331
- remoteInstructions: this.#baseSystemPrompt,
5339
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5332
5340
  initiatorOverride: "agent",
5333
5341
  });
5334
5342
  break;
@@ -290,7 +290,7 @@ export async function generateBranchSummary(
290
290
  // Call LLM for summarization
291
291
  const response = await completeSimple(
292
292
  model,
293
- { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
293
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
294
294
  { apiKey, signal, maxTokens: 2048 },
295
295
  );
296
296
 
@@ -1019,7 +1019,7 @@ export async function generateSummary(
1019
1019
 
1020
1020
  const response = await completeSimple(
1021
1021
  model,
1022
- { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
1022
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1023
1023
  { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
1024
1024
  );
1025
1025
 
@@ -1066,7 +1066,7 @@ async function generateShortSummary(
1066
1066
  const response = await completeSimple(
1067
1067
  model,
1068
1068
  {
1069
- systemPrompt: SUMMARIZATION_SYSTEM_PROMPT,
1069
+ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1070
1070
  messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1071
1071
  },
1072
1072
  { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride: options?.initiatorOverride },
@@ -1386,7 +1386,7 @@ async function generateTurnPrefixSummary(
1386
1386
 
1387
1387
  const response = await completeSimple(
1388
1388
  model,
1389
- { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
1389
+ { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1390
1390
  { maxTokens, signal, apiKey, reasoning: Effort.High, initiatorOverride },
1391
1391
  );
1392
1392
 
@@ -25,7 +25,7 @@ export interface SessionDumpToolInfo {
25
25
 
26
26
  export interface FormatSessionDumpTextOptions {
27
27
  messages: readonly AgentMessage[];
28
- systemPrompt?: string | null;
28
+ systemPrompt?: readonly string[] | null;
29
29
  model?: Model | null;
30
30
  thinkingLevel?: ThinkingLevel | string | null;
31
31
  tools?: readonly SessionDumpToolInfo[];
@@ -64,11 +64,16 @@ function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
64
64
  export function formatSessionDumpText(options: FormatSessionDumpTextOptions): string {
65
65
  const lines: string[] = [];
66
66
 
67
- const systemPrompt = options.systemPrompt;
68
- if (systemPrompt) {
67
+ const systemPrompt = options.systemPrompt?.filter(prompt => prompt.length > 0) ?? [];
68
+ if (systemPrompt.length > 0) {
69
69
  lines.push("## System Prompt\n");
70
- lines.push(systemPrompt);
71
- lines.push("\n");
70
+ for (let index = 0; index < systemPrompt.length; index++) {
71
+ if (systemPrompt.length > 1) {
72
+ lines.push(`### System Prompt ${index + 1}\n`);
73
+ }
74
+ lines.push(systemPrompt[index]);
75
+ lines.push("\n");
76
+ }
72
77
  }
73
78
 
74
79
  const model = options.model;
@@ -2182,6 +2182,63 @@ export class SessionManager {
2182
2182
  return manager.getPath(id);
2183
2183
  }
2184
2184
 
2185
+ /**
2186
+ * Path to the unsent-input draft sidecar for the current session. Lives inside
2187
+ * the artifacts directory so it is removed together with the session on
2188
+ * `dropSession`. Returns null when the session has no on-disk identity.
2189
+ */
2190
+ #getDraftPath(): string | null {
2191
+ const dir = this.getArtifactsDir();
2192
+ return dir ? path.join(dir, "draft.txt") : null;
2193
+ }
2194
+
2195
+ /**
2196
+ * Persist (or clear) the current editor draft so the next resume of this
2197
+ * session can restore it. Empty text deletes any stale draft. No-op when the
2198
+ * session is not persisted.
2199
+ */
2200
+ async saveDraft(text: string): Promise<void> {
2201
+ const draftPath = this.#getDraftPath();
2202
+ if (!draftPath || !this.persist) return;
2203
+ if (text.length === 0) {
2204
+ try {
2205
+ await this.storage.unlink(draftPath);
2206
+ } catch (err) {
2207
+ if (!isEnoent(err)) throw err;
2208
+ }
2209
+ return;
2210
+ }
2211
+ // Force the session header onto disk so resume can find the file we are
2212
+ // attaching this draft to. Without this, a session whose first message
2213
+ // never produced an assistant reply would persist a draft next to a
2214
+ // session file that does not exist on disk.
2215
+ await this.ensureOnDisk();
2216
+ await this.storage.writeText(draftPath, text);
2217
+ }
2218
+
2219
+ /**
2220
+ * Read and remove the saved draft. Returns the previously-saved text, or
2221
+ * null when no draft is pending. Single-shot: a successful read removes the
2222
+ * sidecar so a subsequent resume does not re-restore the same text.
2223
+ */
2224
+ async consumeDraft(): Promise<string | null> {
2225
+ const draftPath = this.#getDraftPath();
2226
+ if (!draftPath) return null;
2227
+ let text: string;
2228
+ try {
2229
+ text = await this.storage.readText(draftPath);
2230
+ } catch (err) {
2231
+ if (isEnoent(err)) return null;
2232
+ throw err;
2233
+ }
2234
+ try {
2235
+ await this.storage.unlink(draftPath);
2236
+ } catch (err) {
2237
+ if (!isEnoent(err)) throw err;
2238
+ }
2239
+ return text;
2240
+ }
2241
+
2185
2242
  /** The source that set the session name: "user" (manual /rename or RPC) or "auto" (generated title). */
2186
2243
  get titleSource(): "auto" | "user" | undefined {
2187
2244
  return this.#titleSource;
@@ -759,7 +759,7 @@ export function formatHeadTruncationNotice(
759
759
  const totalFileLines = options.totalFileLines ?? truncation.totalLines;
760
760
  const endLineDisplay = startLineDisplay + (truncation.outputLines ?? truncation.totalLines) - 1;
761
761
  const nextOffset = endLineDisplay + 1;
762
- const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use sel=${nextOffset} to continue]`;
762
+ const notice = `[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use :${nextOffset} to continue]`;
763
763
  return `\n\n${notice}`;
764
764
  }
765
765