@oh-my-pi/pi-coding-agent 15.2.2 → 15.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/dist/types/cli/worktree-cli.d.ts +26 -0
  3. package/dist/types/commands/worktree.d.ts +34 -0
  4. package/dist/types/config/settings-schema.d.ts +23 -0
  5. package/dist/types/hashline/constants.d.ts +0 -2
  6. package/dist/types/hashline/hash.d.ts +13 -39
  7. package/dist/types/hashline/parser.d.ts +2 -6
  8. package/dist/types/modes/shared.d.ts +9 -0
  9. package/dist/types/modes/theme/shimmer.d.ts +21 -10
  10. package/dist/types/session/agent-session.d.ts +2 -0
  11. package/dist/types/session/yield-queue.d.ts +24 -0
  12. package/dist/types/slash-commands/helpers/format.d.ts +1 -1
  13. package/dist/types/task/worktree.d.ts +0 -1
  14. package/dist/types/utils/git.d.ts +1 -0
  15. package/package.json +7 -7
  16. package/src/autoresearch/storage.ts +14 -2
  17. package/src/cli/worktree-cli.ts +291 -0
  18. package/src/cli.ts +1 -0
  19. package/src/commands/worktree.ts +56 -0
  20. package/src/config/prompt-templates.ts +1 -8
  21. package/src/config/settings-schema.ts +16 -0
  22. package/src/edit/index.ts +1 -1
  23. package/src/edit/renderer.ts +5 -7
  24. package/src/edit/streaming.ts +24 -12
  25. package/src/hashline/constants.ts +0 -3
  26. package/src/hashline/diff.ts +1 -1
  27. package/src/hashline/execute.ts +2 -2
  28. package/src/hashline/grammar.lark +7 -8
  29. package/src/hashline/hash.ts +21 -43
  30. package/src/hashline/input.ts +15 -13
  31. package/src/hashline/parser.ts +62 -161
  32. package/src/internal-urls/docs-index.generated.ts +2 -2
  33. package/src/modes/components/mcp-add-wizard.ts +4 -3
  34. package/src/modes/components/settings-selector.ts +23 -10
  35. package/src/modes/components/welcome.ts +77 -35
  36. package/src/modes/controllers/event-controller.ts +2 -1
  37. package/src/modes/controllers/mcp-command-controller.ts +4 -3
  38. package/src/modes/interactive-mode.ts +51 -10
  39. package/src/modes/shared.ts +16 -0
  40. package/src/modes/theme/shimmer.ts +173 -33
  41. package/src/modes/utils/ui-helpers.ts +31 -13
  42. package/src/prompts/tools/async-result.md +5 -2
  43. package/src/prompts/tools/hashline.md +62 -81
  44. package/src/sdk.ts +95 -21
  45. package/src/session/agent-session.ts +22 -0
  46. package/src/session/yield-queue.ts +155 -0
  47. package/src/slash-commands/helpers/format.ts +4 -1
  48. package/src/task/worktree.ts +2 -7
  49. package/src/tools/gh.ts +35 -32
  50. package/src/utils/commit-message-generator.ts +6 -1
  51. package/src/utils/git.ts +4 -0
  52. package/src/utils/title-generator.ts +45 -13
@@ -1,58 +1,36 @@
1
1
  Your patch language is a compact, line-anchored edit format.
2
2
 
3
- A patch contains one or more file sections. The first non-blank line of every edit section MUST be `@@ PATH`.
3
+ A patch contains one or more file sections. The first non-blank line of every edit section MUST be PATH`.
4
4
  Operations reference lines in the file by their line number and hash, called "Anchors", e.g. `5th`, `123ab`.
5
5
  You MUST copy them verbatim from the latest output for the file you're editing.
6
6
 
7
7
  Purely textual format. The tool has NO awareness of language, indentation, brackets, fences, or table widths. You MUST emit valid syntax in replacements/insertions.
8
8
 
9
9
  <ops>
10
- @@ PATH header: subsequent ops apply to PATH
10
+ §PATH header: subsequent ops apply to PATH
11
11
  Each op line is ONE of:
12
- + ANCHOR insert lines AFTER the anchored line (or EOF); payload follows as `{{hsep}}TEXT` lines
13
- < ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows as `{{hsep}}TEXT` lines
14
- - A..B delete the line range (inclusive).
15
- = A..B replace the range with payload `{{hsep}}TEXT` lines, or with one blank line if no payload follows.
12
+ »ANCHOR insert lines AFTER the anchored line (or EOF); payload follows on subsequent lines
13
+ «ANCHOR insert lines BEFORE the anchored line (or BOF); payload follows on subsequent lines
14
+ A..B replace the inclusive range A..B with payload; delete the range if no payload follows
15
+ ≔A shorthand for ≔A..A
16
16
  </ops>
17
17
 
18
- <format-reminder>
19
- Op lines carry no content — payload goes on the next line.
20
-
21
- WRONG: + 5pg| some code
22
- WRONG: {{hsep}} some code
23
- RIGHT: + 5pg
24
- {{hsep}}some code
25
-
26
- A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each.
27
-
28
- WRONG (one op per inserted line, with fabricated anchors):
29
- + 5pg
30
- {{hsep}}first new line
31
- + 6xx ← FABRICATED
32
- {{hsep}}second new line
33
-
34
- RIGHT (one op, many payload lines):
35
- + 5pg
36
- {{hsep}}first new line
37
- {{hsep}}second new line
38
- </format-reminder>
39
-
40
18
  <rules>
41
- - Every payload line MUST start with `{{hsep}}` immediately followed by payload text. Do NOT add a readability space after `{{hsep}}`.
42
- - Every character after `{{hsep}}` is file content. If the target line intentionally starts with one space, write exactly one space after `{{hsep}}`; otherwise write none.
43
19
  - Payload text is verbatim — NEVER escape unicode.
20
+ - Payload ends at the next `»`, `«`, `≔`, `§`, envelope marker, or EOF.
21
+ - `≔A..B` with no payload deletes the range. To keep a blank line, include one explicit empty payload line.
44
22
  - **Payload is only what's NEW relative to your range:**
45
- - `=` replaces inside; NEVER include lines outside.
46
- - `+`/`<` adds at the anchor; NEVER repeat line A or neighbors.
23
+ - `≔` replaces inside; NEVER include lines outside.
24
+ - `»`/`«` adds at the anchor; NEVER repeat line A or neighbors.
47
25
  - Payload matching nearby content duplicates — drop it or widen.
48
26
  - **Pick a self-contained unit first.** Touching a multiline construct? Widen to the whole thing.
49
- - Then smallest op: add → `+`/`<`; delete → `-`; `=` ONLY when modifying inside.
27
+ - Then smallest op: add → `»`/`«`; delete/replace`≔`.
50
28
  </rules>
51
29
 
52
30
  <brace-shapes>
53
31
  When braces bound your edit, you SHOULD prefer these shapes:
54
32
  - **Whole block**: range spans `{` through matching `}`.
55
- - **Signature only**: one-line `=` on the opener; body untouched.
33
+ - **Signature only**: one-line `≔` on the opener; body untouched.
56
34
  - **Insert inside**: anchor on `{` or last interior line; NEVER repeat the braces.
57
35
  - **End on `}`**: only when that `}` is part of the change. Otherwise extend or stop earlier.
58
36
  </brace-shapes>
@@ -61,9 +39,9 @@ When braces bound your edit, you SHOULD prefer these shapes:
61
39
  - **NEVER replay past your range.** Stop before B+1; extend B if it must go.
62
40
  - **NEVER duplicate chunks inside one payload.** Caught re-emitting? Rewrite.
63
41
  - **Anchor only inside the visible region.** B+1 truncated? Re-`read` first.
64
- - **You SHOULD prefer the narrowest self-contained edit.** Small `+`/`-` beats wide `=`.
42
+ - **You SHOULD prefer the narrowest self-contained edit.** Narrow range beats wide range.
65
43
  - **Anchors reference the file as last read.** NEVER shift for prior ops.
66
- - **One `+`/`<` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
44
+ - **One `»`/`«` op per block, NOT per line.** N lines = ONE op, N payloads. Collapse adjacent ops.
67
45
  - **NEVER fabricate anchor hashes.** Missing? Re-`read`.
68
46
  </common-failures>
69
47
 
@@ -79,71 +57,74 @@ When braces bound your edit, you SHOULD prefer these shapes:
79
57
 
80
58
  <examples>
81
59
  # Replace one line (the payload must re-emit the original indentation)
82
- @@ mod.ts
83
- = {{hrefr 1}}..{{hrefr 1}}
84
- {{hsep}}const TITLE = "Mrs";
60
+ §mod.ts
61
+ {{hrefr 1}}
62
+ const TITLE = "Mrs";
85
63
 
86
64
  # Replace a full multiline statement (widen to a self-contained boundary)
87
- @@ mod.ts
88
- = {{hrefr 3}}..{{hrefr 6}}
89
- {{hsep}} return [
90
- {{hsep}} "Mrs",
91
- {{hsep}} name?.trim() || "guest",
92
- {{hsep}} ].join(" ");
65
+ §mod.ts
66
+ {{hrefr 3}}..{{hrefr 6}}
67
+ return [
68
+ "Mrs",
69
+ name?.trim() || "guest",
70
+ ].join(" ");
93
71
 
94
72
  # Insert AFTER/BEFORE a line
95
- @@ mod.ts
96
- + {{hrefr 4}}
97
- {{hsep}} "Dr",
98
- < {{hrefr 5}}
99
- {{hsep}} "Dr",
73
+ §mod.ts
74
+ »{{hrefr 4}}
75
+ "Dr",
76
+ «{{hrefr 5}}
77
+ "Dr",
100
78
 
101
79
  # Append to file
102
- @@ mod.ts
103
- + EOF
104
- {{hsep}}export const done = true;
80
+ §mod.ts
81
+ »EOF
82
+ export const done = true;
105
83
 
106
84
  # Delete a line
107
- @@ mod.ts
108
- - {{hrefr 5}}..{{hrefr 5}}
85
+ §mod.ts
86
+ {{hrefr 5}}
87
+
88
+ # Blank a line (replace with LF: the empty payload is the blank line before `»EOF`)
89
+ §mod.ts
90
+ ≔{{hrefr 5}}
109
91
 
110
- # Blank a line (replace with LF)
111
- @@ mod.ts
112
- = {{hrefr 5}}..{{hrefr 5}}
92
+ »EOF
93
+ export const done = true;
113
94
  </examples>
114
95
 
115
96
  <anti-pattern>
116
97
  # WRONG — replaces 2 lines just to add one.
117
- @@ mod.ts
118
- = {{hrefr 1}}..{{hrefr 2}}
119
- {{hsep}}const TITLE = "Mr";
120
- {{hsep}}const DEBUG = false;
121
- {{hsep}}export function greet(name) {
98
+ §mod.ts
99
+ {{hrefr 1}}..{{hrefr 2}}
100
+ const TITLE = "Mr";
101
+ const DEBUG = false;
102
+ export function greet(name) {
122
103
  # RIGHT — same effect, one-line insert
123
- @@ mod.ts
124
- + {{hrefr 1}}
125
- {{hsep}}const DEBUG = false;
104
+ §mod.ts
105
+ »{{hrefr 1}}
106
+ const DEBUG = false;
126
107
 
127
108
  # WRONG — replace from the middle of a larger statement (error-prone)
128
- @@ mod.ts
129
- = {{hrefr 4}}..{{hrefr 5}}
130
- {{hsep}} "Dr",
131
- {{hsep}} name?.trim() || "guest",
109
+ §mod.ts
110
+ {{hrefr 4}}..{{hrefr 5}}
111
+ "Dr",
112
+ name?.trim() || "guest",
132
113
  # RIGHT — widen to the full statement
133
- @@ mod.ts
134
- = {{hrefr 3}}..{{hrefr 6}}
135
- {{hsep}} return [
136
- {{hsep}} "Dr",
137
- {{hsep}} name?.trim() || "guest",
138
- {{hsep}} ].join(" ");
114
+ §mod.ts
115
+ {{hrefr 3}}..{{hrefr 6}}
116
+ return [
117
+ "Dr",
118
+ name?.trim() || "guest",
119
+ ].join(" ");
139
120
  </anti-pattern>
140
121
 
141
122
  <critical>
142
123
  - Copy anchors verbatim (line number + 2-char hash); NEVER include the `|TEXT` body.
143
- - Every payload line MUST start with `{{hsep}}`; raw content is invalid.
144
- - NEVER write unified diff syntax. Header is `@@ PATH`; ops are `<`/`+`/`-`/`=`.
145
- - `= A..B` deletes the range; payload is what's written. Edge line matches just outside? Widen, or it duplicates.
146
- - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `=`.
147
- - Before `= A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
124
+ - NEVER write unified diff syntax. Headers are `§PATH`; ops are `»`/`«`/`≔`.
125
+ - `≔A..B` deletes the range when no payload follows. To keep a blank line, include one explicit empty payload line.
126
+ - `≔A..B` with payload writes exactly that payload. Edge line matches just outside? Widen, or it duplicates.
127
+ - Multiple ops are cheap. SHOULD prefer two narrow ops over one wide `≔`.
128
+ - Before `≔A..B`, mentally delete A..B. Splits an unclosed bracket/brace/string from above, or orphans a closer inside? You're bisecting a construct.
148
129
  - NEVER use this tool to reformat code (indentation, whitespace, line wrapping, style). Run the project's formatter instead.
149
130
  </critical>
package/src/sdk.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  Snowflake,
32
32
  } from "@oh-my-pi/pi-utils";
33
33
  import chalk from "chalk";
34
- import { AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
34
+ import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
35
35
  import { createAutoresearchExtension } from "./autoresearch";
36
36
  import { loadCapability } from "./capability";
37
37
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
@@ -101,7 +101,7 @@ import {
101
101
  import { AgentSession } from "./session/agent-session";
102
102
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
103
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
- import { convertToLlm } from "./session/messages";
104
+ import { type CustomMessage, convertToLlm } from "./session/messages";
105
105
  import { SessionManager } from "./session/session-manager";
106
106
  import { closeAllConnections } from "./ssh/connection-manager";
107
107
  import { unmountAll } from "./ssh/sshfs-mount";
@@ -152,6 +152,83 @@ import { EventBus } from "./utils/event-bus";
152
152
  import { buildNamedToolChoice } from "./utils/tool-choice";
153
153
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
154
154
 
155
+ type AsyncResultEntry = {
156
+ jobId: string;
157
+ result: string;
158
+ job: AsyncJob | undefined;
159
+ durationMs: number | undefined;
160
+ };
161
+
162
+ type AsyncResultJobDetails = {
163
+ jobId: string;
164
+ type?: "bash" | "task";
165
+ label?: string;
166
+ durationMs?: number;
167
+ };
168
+
169
+ type AsyncResultDetails = {
170
+ jobs: AsyncResultJobDetails[];
171
+ };
172
+
173
+ type McpNotificationEntry = {
174
+ serverName: string;
175
+ uri: string;
176
+ };
177
+
178
+ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessage<AsyncResultDetails> | null {
179
+ if (entries.length === 0) return null;
180
+ const jobs = entries.map(entry => ({
181
+ jobId: entry.jobId,
182
+ result: entry.result,
183
+ type: entry.job?.type,
184
+ label: entry.job?.label,
185
+ durationMs: entry.durationMs,
186
+ }));
187
+ const details: AsyncResultDetails = {
188
+ jobs: jobs.map(job => ({
189
+ jobId: job.jobId,
190
+ type: job.type,
191
+ label: job.label,
192
+ durationMs: job.durationMs,
193
+ })),
194
+ };
195
+ return {
196
+ role: "custom",
197
+ customType: "async-result",
198
+ content: prompt.render(asyncResultTemplate, {
199
+ multiple: jobs.length > 1,
200
+ jobs,
201
+ }),
202
+ display: true,
203
+ attribution: "agent",
204
+ details,
205
+ timestamp: Date.now(),
206
+ };
207
+ }
208
+
209
+ function buildMcpNotificationBatchMessage(entries: McpNotificationEntry[]): AgentMessage | null {
210
+ const resources: McpNotificationEntry[] = [];
211
+ const seen = new Set<string>();
212
+ for (const entry of entries) {
213
+ const key = `${entry.serverName}\0${entry.uri}`;
214
+ if (seen.has(key)) continue;
215
+ seen.add(key);
216
+ resources.push(entry);
217
+ }
218
+ if (resources.length === 0) return null;
219
+ const lines = [`[MCP notification] ${resources.length} resource(s) updated:`];
220
+ for (const resource of resources) {
221
+ lines.push(`- server="${resource.serverName}" uri=${resource.uri}`);
222
+ }
223
+ lines.push('Use read(path="mcp://<uri>") to inspect if relevant.');
224
+ return {
225
+ role: "user",
226
+ content: [{ type: "text", text: lines.join("\n") }],
227
+ attribution: "agent",
228
+ timestamp: Date.now(),
229
+ };
230
+ }
231
+
155
232
  // Types
156
233
  export interface CreateAgentSessionOptions {
157
234
  /** Working directory for project-local discovery. Default: getProjectDir() */
@@ -1035,23 +1112,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1035
1112
  const formattedResult = await formatAsyncResultForFollowUp(result);
1036
1113
  if (asyncJobManager!.isDeliverySuppressed(jobId)) return;
1037
1114
 
1038
- const message = prompt.render(asyncResultTemplate, { jobId, result: formattedResult });
1039
1115
  const durationMs = job ? Math.max(0, Date.now() - job.startTime) : undefined;
1040
- await session.sendCustomMessage(
1041
- {
1042
- customType: "async-result",
1043
- content: message,
1044
- display: true,
1045
- attribution: "agent",
1046
- details: {
1047
- jobId,
1048
- type: job?.type,
1049
- label: job?.label,
1050
- durationMs,
1051
- },
1052
- },
1053
- { deliverAs: "followUp", triggerTurn: true },
1054
- );
1116
+ session.yieldQueue.enqueue<AsyncResultEntry>("async-result", {
1117
+ jobId,
1118
+ result: formattedResult,
1119
+ job,
1120
+ durationMs,
1121
+ });
1055
1122
  },
1056
1123
  })
1057
1124
  : undefined;
@@ -1902,6 +1969,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1902
1969
  providerSessionId: options.providerSessionId,
1903
1970
  });
1904
1971
  hasSession = true;
1972
+ if (asyncJobManager) {
1973
+ session.yieldQueue.register<AsyncResultEntry>("async-result", {
1974
+ isStale: entry => asyncJobManager.isDeliverySuppressed(entry.jobId),
1975
+ build: buildAsyncResultBatchMessage,
1976
+ });
1977
+ }
1978
+ session.yieldQueue.register<McpNotificationEntry>("mcp-notification", {
1979
+ build: buildMcpNotificationBatchMessage,
1980
+ });
1905
1981
 
1906
1982
  // Attach the live session to the pre-registered ref so peers can route IRC
1907
1983
  // messages here. Refresh sessionFile in case it was unavailable at pre-register
@@ -2036,9 +2112,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2036
2112
  notificationDebounceTimers.delete(key);
2037
2113
  // Re-check: user may have disabled notifications during the debounce window
2038
2114
  if (!settings.get("mcp.notifications")) return;
2039
- void session.followUp(
2040
- `[MCP notification] Server "${serverName}" reports resource \`${uri}\` was updated. Use read(path="mcp://${uri}") to inspect if relevant.`,
2041
- );
2115
+ session.yieldQueue.enqueue<McpNotificationEntry>("mcp-notification", { serverName, uri });
2042
2116
  }, debounceMs),
2043
2117
  );
2044
2118
  });
@@ -205,6 +205,7 @@ import type {
205
205
  } from "./session-manager";
206
206
  import { getLatestCompactionEntry } from "./session-manager";
207
207
  import { ToolChoiceQueue } from "./tool-choice-queue";
208
+ import { YieldQueue } from "./yield-queue";
208
209
 
209
210
  /** Session-specific events that extend the core AgentEvent */
210
211
  export type AgentSessionEvent =
@@ -735,6 +736,7 @@ export class AgentSession {
735
736
  readonly agent: Agent;
736
737
  readonly sessionManager: SessionManager;
737
738
  readonly settings: Settings;
739
+ readonly yieldQueue: YieldQueue;
738
740
 
739
741
  #powerAssertion: MacOSPowerAssertion | undefined;
740
742
 
@@ -1031,6 +1033,24 @@ export class AgentSession {
1031
1033
  };
1032
1034
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1033
1035
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1036
+ this.yieldQueue = new YieldQueue({
1037
+ isStreaming: () => this.isStreaming,
1038
+ injectStreaming: message => this.agent.followUp(message),
1039
+ injectIdle: async messages => {
1040
+ const first = messages[0];
1041
+ if (!first) return;
1042
+ await this.agent.prompt(messages.length === 1 ? first : messages);
1043
+ },
1044
+ scheduleIdleFlush: run => {
1045
+ this.#schedulePostPromptTask(
1046
+ async () => {
1047
+ await run();
1048
+ },
1049
+ { delayMs: 1 },
1050
+ );
1051
+ },
1052
+ });
1053
+ this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming"));
1034
1054
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1035
1055
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1036
1056
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -2720,6 +2740,8 @@ export class AgentSession {
2720
2740
  async dispose(): Promise<void> {
2721
2741
  this.#isDisposed = true;
2722
2742
  this.#pendingBackgroundExchanges = [];
2743
+ this.yieldQueue.clear();
2744
+ this.agent.setOnBeforeYield(undefined);
2723
2745
  this.#evalExecutionDisposing = true;
2724
2746
  try {
2725
2747
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
@@ -0,0 +1,155 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import { logger } from "@oh-my-pi/pi-utils";
3
+
4
+ export interface YieldDispatcher<P> {
5
+ /** Drop entries already delivered through another path. Called per-entry at flush time. */
6
+ isStale?(entry: P): boolean;
7
+ /** Produce one batched AgentMessage from non-stale entries. Return null to skip. */
8
+ build(survivors: P[]): AgentMessage | null;
9
+ }
10
+
11
+ export interface YieldQueueOptions {
12
+ isStreaming: () => boolean;
13
+ injectStreaming(msg: AgentMessage): void;
14
+ injectIdle(messages: AgentMessage[]): Promise<void>;
15
+ scheduleIdleFlush(run: () => Promise<void>): void;
16
+ }
17
+
18
+ type YieldFlushMode = "streaming" | "idle";
19
+
20
+ interface StoredDispatcher {
21
+ isStale?: (entry: unknown) => boolean;
22
+ build: (survivors: unknown[]) => AgentMessage | null;
23
+ }
24
+
25
+ function formatError(error: unknown): string {
26
+ return error instanceof Error ? error.message : String(error);
27
+ }
28
+
29
+ export class YieldQueue {
30
+ readonly #options: YieldQueueOptions;
31
+ readonly #dispatchers = new Map<string, StoredDispatcher>();
32
+ readonly #entries = new Map<string, unknown[]>();
33
+ #idleFlushPending = false;
34
+
35
+ constructor(options: YieldQueueOptions) {
36
+ this.#options = options;
37
+ }
38
+
39
+ register<P>(kind: string, dispatcher: YieldDispatcher<P>): () => void {
40
+ const stored: StoredDispatcher = {
41
+ ...(dispatcher.isStale ? { isStale: entry => dispatcher.isStale?.(entry as P) ?? false } : {}),
42
+ build: survivors => dispatcher.build(survivors as P[]),
43
+ };
44
+ this.#dispatchers.set(kind, stored);
45
+ return () => {
46
+ if (this.#dispatchers.get(kind) !== stored) return;
47
+ this.#dispatchers.delete(kind);
48
+ this.#entries.delete(kind);
49
+ };
50
+ }
51
+
52
+ enqueue<P>(kind: string, entry: P): void {
53
+ if (!this.#dispatchers.has(kind)) {
54
+ logger.warn("Yield queue entry ignored for unregistered kind", { kind });
55
+ return;
56
+ }
57
+ let entries = this.#entries.get(kind);
58
+ if (!entries) {
59
+ entries = [];
60
+ this.#entries.set(kind, entries);
61
+ }
62
+ entries.push(entry);
63
+ if (!this.#options.isStreaming()) {
64
+ this.#scheduleIdleFlush();
65
+ }
66
+ }
67
+
68
+ has(kind?: string): boolean {
69
+ if (kind !== undefined) return (this.#entries.get(kind)?.length ?? 0) > 0;
70
+ for (const entries of this.#entries.values()) {
71
+ if (entries.length > 0) return true;
72
+ }
73
+ return false;
74
+ }
75
+
76
+ async flush(mode: YieldFlushMode): Promise<void> {
77
+ if (mode === "idle") {
78
+ this.#idleFlushPending = false;
79
+ }
80
+ const idleMessages: AgentMessage[] = [];
81
+ for (const [kind, dispatcher] of this.#dispatchers) {
82
+ const entries = this.#drain(kind);
83
+ if (entries.length === 0) continue;
84
+ const message = this.#build(kind, dispatcher, entries);
85
+ if (!message) continue;
86
+ if (mode === "streaming") {
87
+ try {
88
+ this.#options.injectStreaming(message);
89
+ } catch (error) {
90
+ logger.warn("Yield queue streaming dispatch failed", { kind, error: formatError(error) });
91
+ }
92
+ } else {
93
+ idleMessages.push(message);
94
+ }
95
+ }
96
+ if (mode === "idle" && idleMessages.length > 0) {
97
+ try {
98
+ await this.#options.injectIdle(idleMessages);
99
+ } catch (error) {
100
+ logger.warn("Yield queue idle dispatch failed", { error: formatError(error) });
101
+ }
102
+ }
103
+ }
104
+
105
+ clear(): void {
106
+ this.#entries.clear();
107
+ this.#idleFlushPending = false;
108
+ }
109
+
110
+ #scheduleIdleFlush(): void {
111
+ if (this.#idleFlushPending) return;
112
+ this.#idleFlushPending = true;
113
+ try {
114
+ this.#options.scheduleIdleFlush(async () => {
115
+ this.#idleFlushPending = false;
116
+ if (this.#options.isStreaming()) return;
117
+ await this.flush("idle");
118
+ });
119
+ } catch (error) {
120
+ this.#idleFlushPending = false;
121
+ logger.warn("Yield queue idle flush scheduling failed", { error: formatError(error) });
122
+ }
123
+ }
124
+
125
+ #drain(kind: string): unknown[] {
126
+ const entries = this.#entries.get(kind);
127
+ if (!entries || entries.length === 0) return [];
128
+ this.#entries.delete(kind);
129
+ return entries;
130
+ }
131
+
132
+ #build(kind: string, dispatcher: StoredDispatcher, entries: unknown[]): AgentMessage | null {
133
+ const survivors: unknown[] = [];
134
+ for (const entry of entries) {
135
+ if (dispatcher.isStale) {
136
+ let stale: boolean;
137
+ try {
138
+ stale = dispatcher.isStale(entry);
139
+ } catch (error) {
140
+ logger.warn("Yield queue stale check failed", { kind, error: formatError(error) });
141
+ continue;
142
+ }
143
+ if (stale) continue;
144
+ }
145
+ survivors.push(entry);
146
+ }
147
+ if (survivors.length === 0) return null;
148
+ try {
149
+ return dispatcher.build(survivors);
150
+ } catch (error) {
151
+ logger.warn("Yield queue build failed", { kind, error: formatError(error) });
152
+ return null;
153
+ }
154
+ }
155
+ }
@@ -13,7 +13,7 @@ export function formatDuration(ms: number): string {
13
13
  return `${days}d`;
14
14
  }
15
15
 
16
- type ProgressBarTheme = Pick<Theme, "bold" | "fg">;
16
+ type ProgressBarTheme = Pick<Theme, "bold" | "fg" | "getFgAnsi">;
17
17
 
18
18
  const unstyledProgressBarTheme: ProgressBarTheme = {
19
19
  fg(_color, text) {
@@ -22,6 +22,9 @@ const unstyledProgressBarTheme: ProgressBarTheme = {
22
22
  bold(text) {
23
23
  return text;
24
24
  },
25
+ getFgAnsi() {
26
+ return "";
27
+ },
25
28
  };
26
29
 
27
30
  function resolveProgressBarTheme(uiTheme: ProgressBarTheme | undefined): ProgressBarTheme {
@@ -3,7 +3,7 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import * as natives from "@oh-my-pi/pi-natives";
6
- import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
6
+ import { getWorktreeDir, hashPath, logger, Snowflake } from "@oh-my-pi/pi-utils";
7
7
  import * as git from "../utils/git";
8
8
 
9
9
  const { IsoBackendKind } = natives;
@@ -26,10 +26,6 @@ export interface WorktreeBaseline {
26
26
  nested: Array<{ relativePath: string; baseline: RepoBaseline }>;
27
27
  }
28
28
 
29
- export function getEncodedProjectName(cwd: string): string {
30
- return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
31
- }
32
-
33
29
  export async function getRepoRoot(cwd: string): Promise<string> {
34
30
  const repoRoot = await git.repo.root(cwd);
35
31
  if (!repoRoot) {
@@ -316,8 +312,7 @@ export async function ensureIsolation(
316
312
  preferred?: IsoBackendKind,
317
313
  ): Promise<IsolationHandle> {
318
314
  const repoRoot = await getRepoRoot(baseCwd);
319
- const encodedProject = getEncodedProjectName(repoRoot);
320
- const baseDir = getWorktreeDir(encodedProject, id);
315
+ const baseDir = getWorktreeDir(`${id}-${hashPath(repoRoot)}`);
321
316
  const mergedDir = path.join(baseDir, "merged");
322
317
 
323
318
  const resolution = natives.isoResolve(preferred ?? null);