@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.
- package/CHANGELOG.md +49 -1
- package/dist/types/cli/worktree-cli.d.ts +26 -0
- package/dist/types/commands/worktree.d.ts +34 -0
- package/dist/types/config/settings-schema.d.ts +23 -0
- package/dist/types/hashline/constants.d.ts +0 -2
- package/dist/types/hashline/hash.d.ts +13 -39
- package/dist/types/hashline/parser.d.ts +2 -6
- package/dist/types/modes/shared.d.ts +9 -0
- package/dist/types/modes/theme/shimmer.d.ts +21 -10
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/yield-queue.d.ts +24 -0
- package/dist/types/slash-commands/helpers/format.d.ts +1 -1
- package/dist/types/task/worktree.d.ts +0 -1
- package/dist/types/utils/git.d.ts +1 -0
- package/package.json +7 -7
- package/src/autoresearch/storage.ts +14 -2
- package/src/cli/worktree-cli.ts +291 -0
- package/src/cli.ts +1 -0
- package/src/commands/worktree.ts +56 -0
- package/src/config/prompt-templates.ts +1 -8
- package/src/config/settings-schema.ts +16 -0
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +5 -7
- package/src/edit/streaming.ts +24 -12
- package/src/hashline/constants.ts +0 -3
- package/src/hashline/diff.ts +1 -1
- package/src/hashline/execute.ts +2 -2
- package/src/hashline/grammar.lark +7 -8
- package/src/hashline/hash.ts +21 -43
- package/src/hashline/input.ts +15 -13
- package/src/hashline/parser.ts +62 -161
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/components/mcp-add-wizard.ts +4 -3
- package/src/modes/components/settings-selector.ts +23 -10
- package/src/modes/components/welcome.ts +77 -35
- package/src/modes/controllers/event-controller.ts +2 -1
- package/src/modes/controllers/mcp-command-controller.ts +4 -3
- package/src/modes/interactive-mode.ts +51 -10
- package/src/modes/shared.ts +16 -0
- package/src/modes/theme/shimmer.ts +173 -33
- package/src/modes/utils/ui-helpers.ts +31 -13
- package/src/prompts/tools/async-result.md +5 -2
- package/src/prompts/tools/hashline.md +62 -81
- package/src/sdk.ts +95 -21
- package/src/session/agent-session.ts +22 -0
- package/src/session/yield-queue.ts +155 -0
- package/src/slash-commands/helpers/format.ts +4 -1
- package/src/task/worktree.ts +2 -7
- package/src/tools/gh.ts +35 -32
- package/src/utils/commit-message-generator.ts +6 -1
- package/src/utils/git.ts +4 -0
- 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
|
|
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
|
-
|
|
10
|
+
§PATH header: subsequent ops apply to PATH
|
|
11
11
|
Each op line is ONE of:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
-
|
|
46
|
-
-
|
|
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 →
|
|
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
|
|
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.**
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
73
|
+
§mod.ts
|
|
74
|
+
»{{hrefr 4}}
|
|
75
|
+
"Dr",
|
|
76
|
+
«{{hrefr 5}}
|
|
77
|
+
"Dr",
|
|
100
78
|
|
|
101
79
|
# Append to file
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
80
|
+
§mod.ts
|
|
81
|
+
»EOF
|
|
82
|
+
export const done = true;
|
|
105
83
|
|
|
106
84
|
# Delete a line
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
109
|
+
§mod.ts
|
|
110
|
+
≔{{hrefr 4}}..{{hrefr 5}}
|
|
111
|
+
"Dr",
|
|
112
|
+
name?.trim() || "guest",
|
|
132
113
|
# RIGHT — widen to the full statement
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
-
|
|
144
|
-
-
|
|
145
|
-
-
|
|
146
|
-
- Multiple ops are cheap. SHOULD prefer two narrow ops over one wide
|
|
147
|
-
- Before
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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 {
|
package/src/task/worktree.ts
CHANGED
|
@@ -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
|
|
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);
|