@oh-my-pi/pi-coding-agent 16.2.3 → 16.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 +11 -0
- package/dist/cli.js +2671 -2660
- package/dist/types/cli/update-cli.d.ts +15 -0
- package/dist/types/collab/replication-shrink.d.ts +39 -0
- package/dist/types/config/provider-globals.d.ts +7 -0
- package/dist/types/memories/index.d.ts +20 -1
- package/dist/types/modes/components/status-line/component.d.ts +5 -0
- package/dist/types/modes/controllers/command-controller.d.ts +3 -3
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/session/session-manager.d.ts +2 -3
- package/package.json +12 -12
- package/src/autolearn/controller.ts +13 -22
- package/src/cli/update-cli.ts +254 -0
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/provider-globals.ts +25 -0
- package/src/eval/__tests__/julia-prelude.test.ts +2 -2
- package/src/memories/index.ts +115 -9
- package/src/memory-backend/local-backend.ts +5 -3
- package/src/modes/components/status-line/component.ts +6 -0
- package/src/modes/controllers/command-controller.ts +5 -35
- package/src/modes/controllers/event-controller.ts +7 -1
- package/src/modes/interactive-mode.ts +9 -20
- package/src/modes/types.ts +2 -1
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/session/agent-session.ts +32 -1
- package/src/session/session-manager.ts +2 -3
- package/src/slash-commands/builtin-registry.ts +7 -21
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard-cap helper for host→guest collab frames.
|
|
3
|
+
*
|
|
4
|
+
* The host wraps every {@link CollabFrame} in an AES-GCM envelope and ships it
|
|
5
|
+
* through the relay's WebSocket. WebSocket servers enforce a per-frame
|
|
6
|
+
* `maxPayloadLength` (Bun's default is 16 MB; many proxies cap lower). A
|
|
7
|
+
* single oversized payload — typically a `read`/`bash`/`search` tool result
|
|
8
|
+
* captured as one multi-megabyte string, or a tool result whose `content`
|
|
9
|
+
* array holds thousands of small blocks — would otherwise ship as its own
|
|
10
|
+
* oversized frame and trip that limit, killing the host's WebSocket with
|
|
11
|
+
* `1006 Received too big message`. `CollabSocket` treats 1006 as transient
|
|
12
|
+
* and reconnects, the next guest hello triggers the same oversized send, and
|
|
13
|
+
* the loop never breaks (issue #3739).
|
|
14
|
+
*
|
|
15
|
+
* This helper bounds any JSON-serializable payload below
|
|
16
|
+
* {@link MAX_REPLICATED_PAYLOAD_BYTES}. Already-small payloads pass through
|
|
17
|
+
* untouched; oversized ones are returned as a deep-cloned shadow where long
|
|
18
|
+
* strings are head-truncated AND long arrays are head-clipped, with
|
|
19
|
+
* `[…N chars elided for collab session]` / `[…N items elided for collab
|
|
20
|
+
* session]` markers. Both axes are needed: string truncation alone leaves
|
|
21
|
+
* the cap unenforced for a payload built of many short strings, where no
|
|
22
|
+
* field exceeds the per-string floor.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Per-payload ceiling for host→guest frames. Bun's default WebSocket
|
|
27
|
+
* `maxPayloadLength` is 16 MB; we leave a generous margin so the AES-GCM
|
|
28
|
+
* envelope (+ IV + tag), the 4-byte peer header, and the outer wire wrapper
|
|
29
|
+
* fit comfortably under that on every reasonable relay.
|
|
30
|
+
*/
|
|
31
|
+
export const MAX_REPLICATED_PAYLOAD_BYTES = 1 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Progressive shrink passes. Each pass tightens both the per-string cap and
|
|
35
|
+
* the per-array head limit; the loop stops at the first pass whose output
|
|
36
|
+
* fits {@link MAX_REPLICATED_PAYLOAD_BYTES}. The schedule is concrete (not
|
|
37
|
+
* recomputed) so the failure modes the helper guards against are visible:
|
|
38
|
+
*
|
|
39
|
+
* - One giant string → the first pass already truncates it under 64 KB.
|
|
40
|
+
* - Array of many small blocks (e.g. a tool result with thousands of
|
|
41
|
+
* `{type:"text", text:"..."}` content items) → later passes head-clip the
|
|
42
|
+
* array to a small sample with a `[…N items elided]` summary element.
|
|
43
|
+
*
|
|
44
|
+
* The final pass clamps every string to 64 B and every array to one element,
|
|
45
|
+
* so even pathological mixes converge.
|
|
46
|
+
*/
|
|
47
|
+
interface ShrinkPass {
|
|
48
|
+
stringCap: number;
|
|
49
|
+
arrayLimit: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SHRINK_PASSES: readonly ShrinkPass[] = [
|
|
53
|
+
{ stringCap: 64 * 1024, arrayLimit: 256 },
|
|
54
|
+
{ stringCap: 16 * 1024, arrayLimit: 128 },
|
|
55
|
+
{ stringCap: 4 * 1024, arrayLimit: 64 },
|
|
56
|
+
{ stringCap: 1 * 1024, arrayLimit: 32 },
|
|
57
|
+
{ stringCap: 256, arrayLimit: 16 },
|
|
58
|
+
{ stringCap: 256, arrayLimit: 4 },
|
|
59
|
+
{ stringCap: 64, arrayLimit: 1 },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const STRING_ELISION_RESERVE = 80;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Recursively walk `value`, head-truncating any string longer than
|
|
66
|
+
* `stringCap` and head-clipping any array longer than `arrayLimit`. Returns
|
|
67
|
+
* a freshly built deep clone — every object/array is rebuilt so the
|
|
68
|
+
* recursive output can be safely serialized in isolation. Cheap to call on
|
|
69
|
+
* small values: short strings, numbers, and booleans pass through without
|
|
70
|
+
* allocation.
|
|
71
|
+
*/
|
|
72
|
+
function shrinkWalk(value: unknown, stringCap: number, arrayLimit: number): unknown {
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
if (value.length <= stringCap) return value;
|
|
75
|
+
const headLen = Math.max(0, stringCap - STRING_ELISION_RESERVE);
|
|
76
|
+
return `${value.slice(0, headLen)}\n…[${value.length - headLen} chars elided for collab session]`;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
const keep = Math.min(value.length, arrayLimit);
|
|
80
|
+
const elided = value.length - keep;
|
|
81
|
+
const out: unknown[] = new Array(elided > 0 ? keep + 1 : keep);
|
|
82
|
+
for (let i = 0; i < keep; i++) out[i] = shrinkWalk(value[i], stringCap, arrayLimit);
|
|
83
|
+
if (elided > 0) out[keep] = `…[${elided} items elided for collab session]`;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
if (value && typeof value === "object") {
|
|
87
|
+
const src = value as Record<string, unknown>;
|
|
88
|
+
const out: Record<string, unknown> = {};
|
|
89
|
+
for (const k in src) out[k] = shrinkWalk(src[k], stringCap, arrayLimit);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Return `value` unchanged when its JSON serialization already fits
|
|
97
|
+
* {@link MAX_REPLICATED_PAYLOAD_BYTES}; otherwise return a deep-cloned
|
|
98
|
+
* shadow shrunk along both string and array axes until the payload fits.
|
|
99
|
+
* The function is generic over `T` because the wire shape is preserved:
|
|
100
|
+
* only string leaves and array tails change; discriminator fields, ids, and
|
|
101
|
+
* other small metadata pass through untouched.
|
|
102
|
+
*/
|
|
103
|
+
export function shrinkForReplication<T>(value: T): T {
|
|
104
|
+
if (JSON.stringify(value).length <= MAX_REPLICATED_PAYLOAD_BYTES) return value;
|
|
105
|
+
let shrunk: unknown = value;
|
|
106
|
+
for (const pass of SHRINK_PASSES) {
|
|
107
|
+
shrunk = shrinkWalk(value, pass.stringCap, pass.arrayLimit);
|
|
108
|
+
if (JSON.stringify(shrunk).length <= MAX_REPLICATED_PAYLOAD_BYTES) return shrunk as T;
|
|
109
|
+
}
|
|
110
|
+
return shrunk as T;
|
|
111
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as imageGen from "../tools/image-gen";
|
|
2
|
+
import * as webSearch from "../web/search";
|
|
3
|
+
|
|
4
|
+
interface ProviderGlobalSettings {
|
|
5
|
+
get(path: "providers.webSearchExclude"): unknown;
|
|
6
|
+
get(path: "providers.webSearch"): unknown;
|
|
7
|
+
get(path: "providers.image"): unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function applyProviderGlobalsFromSettings(settings: ProviderGlobalSettings): void {
|
|
11
|
+
const excludedWebSearchProviders = settings.get("providers.webSearchExclude");
|
|
12
|
+
if (Array.isArray(excludedWebSearchProviders)) {
|
|
13
|
+
webSearch.setExcludedSearchProviders(excludedWebSearchProviders.filter(webSearch.isSearchProviderId));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const webSearchProvider = settings.get("providers.webSearch");
|
|
17
|
+
if (typeof webSearchProvider === "string" && webSearch.isSearchProviderPreference(webSearchProvider)) {
|
|
18
|
+
webSearch.setPreferredSearchProvider(webSearchProvider);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const imageProvider = settings.get("providers.image");
|
|
22
|
+
if (imageGen.isImageProviderPreference(imageProvider)) {
|
|
23
|
+
imageGen.setPreferredImageProvider(imageProvider);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -9,7 +9,7 @@ const OWNER_ID = "julia-prelude-tests";
|
|
|
9
9
|
describe.skipIf(!HAS_JULIA)("eval Julia prelude helpers", () => {
|
|
10
10
|
afterEach(async () => {
|
|
11
11
|
await disposeJuliaKernelSessionsByOwner(OWNER_ID);
|
|
12
|
-
});
|
|
12
|
+
}, 30_000);
|
|
13
13
|
|
|
14
14
|
it("supports output ranges, JSON queries, metadata, and ANSI stripping", async () => {
|
|
15
15
|
using tempDir = TempDir.createSync("@omp-eval-julia-output-");
|
|
@@ -44,7 +44,7 @@ nothing
|
|
|
44
44
|
expect(result.output).toContain("STRIPPED=red");
|
|
45
45
|
expect(result.output).toContain("META=alpha:true");
|
|
46
46
|
expect(result.output).toContain("MULTI=2:alpha:json");
|
|
47
|
-
},
|
|
47
|
+
}, 60_000);
|
|
48
48
|
|
|
49
49
|
it("surfaces the exception type and message in the error output, not just stack frames", async () => {
|
|
50
50
|
using tempDir = TempDir.createSync("@omp-eval-julia-error-");
|
package/src/memories/index.ts
CHANGED
|
@@ -147,16 +147,42 @@ export function startMemoryStartupTask(options: {
|
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
interface MemoryInstructionSession {
|
|
151
|
+
sessionManager: Pick<AgentSession["sessionManager"], "getSessionFile">;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface MemoryToolDeveloperInstructionsSnapshot {
|
|
155
|
+
summary: string;
|
|
156
|
+
learned: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
interface CachedMemoryToolDeveloperInstructions {
|
|
160
|
+
sessionFile: string | undefined;
|
|
161
|
+
snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined;
|
|
162
|
+
value: string | undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const memoryToolDeveloperInstructionsBySession = new WeakMap<
|
|
166
|
+
MemoryInstructionSession,
|
|
167
|
+
CachedMemoryToolDeveloperInstructions
|
|
168
|
+
>();
|
|
169
|
+
const memoryToolDeveloperInstructionsByRoot = new Map<string, MemoryToolDeveloperInstructionsSnapshot | undefined>();
|
|
170
|
+
|
|
171
|
+
function getMemoryInstructionRoot(agentDir: string, settings: Settings): string {
|
|
172
|
+
return getMemoryRoot(agentDir, settings.getCwd());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getMemoryInstructionSessionFile(session: MemoryInstructionSession): string | undefined {
|
|
176
|
+
return session.sessionManager.getSessionFile() ?? undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function readMemoryToolDeveloperInstructionsSnapshot(
|
|
154
180
|
agentDir: string,
|
|
155
181
|
settings: Settings,
|
|
156
|
-
): Promise<
|
|
182
|
+
): Promise<MemoryToolDeveloperInstructionsSnapshot | undefined> {
|
|
157
183
|
const cfg = loadMemoryConfig(settings);
|
|
158
184
|
if (!cfg.enabled) return undefined;
|
|
159
|
-
const memoryRoot =
|
|
185
|
+
const memoryRoot = getMemoryInstructionRoot(agentDir, settings);
|
|
160
186
|
|
|
161
187
|
let summary = "";
|
|
162
188
|
try {
|
|
@@ -166,9 +192,21 @@ export async function buildMemoryToolDeveloperInstructions(
|
|
|
166
192
|
// so any captured lessons still surface on their own.
|
|
167
193
|
}
|
|
168
194
|
const learned = await readLearnedLessons(memoryRoot);
|
|
169
|
-
|
|
195
|
+
return { summary, learned };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderMemoryToolDeveloperInstructionsSnapshot(
|
|
199
|
+
snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined,
|
|
200
|
+
settings: Settings,
|
|
201
|
+
): string | undefined {
|
|
202
|
+
if (!snapshot) return undefined;
|
|
203
|
+
const cfg = loadMemoryConfig(settings);
|
|
204
|
+
if (!cfg.enabled) return undefined;
|
|
205
|
+
if (!snapshot.summary && !snapshot.learned) return undefined;
|
|
170
206
|
|
|
171
|
-
const summaryOut = summary
|
|
207
|
+
const summaryOut = snapshot.summary
|
|
208
|
+
? truncateByApproxTokens(snapshot.summary, cfg.summaryInjectionTokenLimit).trim()
|
|
209
|
+
: "";
|
|
172
210
|
// Lessons share ONE injection budget with the summary so the combined block
|
|
173
211
|
// stays within `summaryInjectionTokenLimit` (~4 chars/token, matching
|
|
174
212
|
// truncateByApproxTokens). With no summary, lessons get the whole budget.
|
|
@@ -176,7 +214,8 @@ export async function buildMemoryToolDeveloperInstructions(
|
|
|
176
214
|
// can exceed `limit * 4` chars and drive the remainder negative — when the
|
|
177
215
|
// summary already fills the budget, lessons are simply dropped.
|
|
178
216
|
const learnedBudget = Math.max(0, cfg.summaryInjectionTokenLimit - Math.ceil(summaryOut.length / 4));
|
|
179
|
-
const learnedOut =
|
|
217
|
+
const learnedOut =
|
|
218
|
+
snapshot.learned && learnedBudget > 0 ? truncateByApproxTokens(snapshot.learned, learnedBudget).trim() : "";
|
|
180
219
|
if (!summaryOut && !learnedOut) return undefined;
|
|
181
220
|
|
|
182
221
|
return prompt.render(readPathTemplate, {
|
|
@@ -185,6 +224,72 @@ export async function buildMemoryToolDeveloperInstructions(
|
|
|
185
224
|
});
|
|
186
225
|
}
|
|
187
226
|
|
|
227
|
+
function cacheMemoryToolDeveloperInstructions(
|
|
228
|
+
session: MemoryInstructionSession,
|
|
229
|
+
sessionFile: string | undefined,
|
|
230
|
+
snapshot: MemoryToolDeveloperInstructionsSnapshot | undefined,
|
|
231
|
+
settings: Settings,
|
|
232
|
+
): string | undefined {
|
|
233
|
+
const value = renderMemoryToolDeveloperInstructionsSnapshot(snapshot, settings);
|
|
234
|
+
memoryToolDeveloperInstructionsBySession.set(session, { sessionFile, snapshot, value });
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Drop the per-session memory instruction snapshot after explicit memory state
|
|
240
|
+
* changes that must affect the active conversation immediately, such as
|
|
241
|
+
* `/memory clear`.
|
|
242
|
+
*/
|
|
243
|
+
export function clearMemoryToolDeveloperInstructionsCache(session: MemoryInstructionSession | undefined): void {
|
|
244
|
+
if (session) memoryToolDeveloperInstructionsBySession.delete(session);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Refresh the active session's consolidated-memory snapshot after startup maintenance.
|
|
249
|
+
*
|
|
250
|
+
* Startup may finish after the first prompt build and write `memory_summary.md`;
|
|
251
|
+
* the active session should see that summary. It must not reread `learned.md`,
|
|
252
|
+
* because a `learn` call racing with startup belongs to the next session's
|
|
253
|
+
* memory prompt, not the active prompt-cache prefix.
|
|
254
|
+
*/
|
|
255
|
+
export async function refreshMemoryToolDeveloperInstructionsCacheAfterStartup(
|
|
256
|
+
session: MemoryInstructionSession,
|
|
257
|
+
agentDir: string,
|
|
258
|
+
settings: Settings,
|
|
259
|
+
): Promise<void> {
|
|
260
|
+
const sessionFile = getMemoryInstructionSessionFile(session);
|
|
261
|
+
const cached = memoryToolDeveloperInstructionsBySession.get(session);
|
|
262
|
+
const current = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
|
|
263
|
+
const root = getMemoryInstructionRoot(agentDir, settings);
|
|
264
|
+
const baseline = memoryToolDeveloperInstructionsByRoot.get(root);
|
|
265
|
+
const cachedLearned = cached && cached.sessionFile === sessionFile ? cached.snapshot?.learned : undefined;
|
|
266
|
+
const learned = cachedLearned ?? baseline?.learned ?? "";
|
|
267
|
+
const snapshot = current ? { summary: current.summary, learned } : undefined;
|
|
268
|
+
cacheMemoryToolDeveloperInstructions(session, sessionFile, snapshot, settings);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Build memory usage instructions for prompt injection.
|
|
273
|
+
*/
|
|
274
|
+
export async function buildMemoryToolDeveloperInstructions(
|
|
275
|
+
agentDir: string,
|
|
276
|
+
settings: Settings,
|
|
277
|
+
session?: MemoryInstructionSession,
|
|
278
|
+
): Promise<string | undefined> {
|
|
279
|
+
if (!session) {
|
|
280
|
+
const snapshot = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
|
|
281
|
+
memoryToolDeveloperInstructionsByRoot.set(getMemoryInstructionRoot(agentDir, settings), snapshot);
|
|
282
|
+
return renderMemoryToolDeveloperInstructionsSnapshot(snapshot, settings);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const sessionFile = getMemoryInstructionSessionFile(session);
|
|
286
|
+
const cached = memoryToolDeveloperInstructionsBySession.get(session);
|
|
287
|
+
if (cached && cached.sessionFile === sessionFile) return cached.value;
|
|
288
|
+
|
|
289
|
+
const snapshot = await readMemoryToolDeveloperInstructionsSnapshot(agentDir, settings);
|
|
290
|
+
return cacheMemoryToolDeveloperInstructions(session, sessionFile, snapshot, settings);
|
|
291
|
+
}
|
|
292
|
+
|
|
188
293
|
/**
|
|
189
294
|
* Clear all persisted memory state and generated artifacts.
|
|
190
295
|
*/
|
|
@@ -219,6 +324,7 @@ async function runMemoryStartup(options: {
|
|
|
219
324
|
}): Promise<void> {
|
|
220
325
|
await runPhase1(options);
|
|
221
326
|
await runPhase2(options);
|
|
327
|
+
await refreshMemoryToolDeveloperInstructionsCacheAfterStartup(options.session, options.agentDir, options.settings);
|
|
222
328
|
await options.session.refreshBaseSystemPrompt?.();
|
|
223
329
|
}
|
|
224
330
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
buildMemoryToolDeveloperInstructions,
|
|
3
3
|
clearMemoryData,
|
|
4
|
+
clearMemoryToolDeveloperInstructionsCache,
|
|
4
5
|
enqueueMemoryConsolidation,
|
|
5
6
|
saveLearnedLesson,
|
|
6
7
|
startMemoryStartupTask,
|
|
@@ -20,10 +21,11 @@ export const localBackend: MemoryBackend = {
|
|
|
20
21
|
start(options) {
|
|
21
22
|
startMemoryStartupTask(options);
|
|
22
23
|
},
|
|
23
|
-
async buildDeveloperInstructions(agentDir, settings) {
|
|
24
|
-
return buildMemoryToolDeveloperInstructions(agentDir, settings);
|
|
24
|
+
async buildDeveloperInstructions(agentDir, settings, session) {
|
|
25
|
+
return buildMemoryToolDeveloperInstructions(agentDir, settings, session);
|
|
25
26
|
},
|
|
26
|
-
async clear(agentDir, cwd) {
|
|
27
|
+
async clear(agentDir, cwd, session) {
|
|
28
|
+
clearMemoryToolDeveloperInstructionsCache(session);
|
|
27
29
|
await clearMemoryData(agentDir, cwd);
|
|
28
30
|
},
|
|
29
31
|
async enqueue(agentDir, cwd) {
|
|
@@ -367,6 +367,12 @@ export class StatusLineComponent implements Component {
|
|
|
367
367
|
this.#subagentCount = count;
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Compatibility shim for callers predating the simplified subagent badge.
|
|
372
|
+
* The status line now intentionally shows only the active count.
|
|
373
|
+
*/
|
|
374
|
+
setSubagentHubHint(_hint: string | undefined): void {}
|
|
375
|
+
|
|
370
376
|
/** Active subagent count as currently displayed (collab state mirroring). */
|
|
371
377
|
get subagentCount(): number {
|
|
372
378
|
return this.#subagentCount;
|
|
@@ -42,7 +42,6 @@ import type { AsyncJobSnapshotItem } from "../../session/agent-session";
|
|
|
42
42
|
import type { AuthStorage, OAuthAccountIdentity } from "../../session/auth-storage";
|
|
43
43
|
import type { CompactMode } from "../../session/compact-modes";
|
|
44
44
|
import type { NewSessionOptions } from "../../session/session-entries";
|
|
45
|
-
import { SessionManager } from "../../session/session-manager";
|
|
46
45
|
import { formatShakeSummary, type ShakeMode, type ShakeResult } from "../../session/shake-types";
|
|
47
46
|
import { limitMatchesActiveAccount } from "../../slash-commands/helpers/active-oauth-account";
|
|
48
47
|
import { outputMeta } from "../../tools/output-meta";
|
|
@@ -935,13 +934,13 @@ export class CommandController {
|
|
|
935
934
|
}
|
|
936
935
|
|
|
937
936
|
/**
|
|
938
|
-
* `/move` —
|
|
937
|
+
* `/move` — relocate the current session to a different directory.
|
|
939
938
|
*
|
|
940
939
|
* With no `targetPath` (TUI only), opens an autocomplete overlay so the user
|
|
941
940
|
* can pick or type a directory. With a `targetPath`, resolves it directly.
|
|
942
941
|
* If the target directory does not exist, the user is asked whether to create
|
|
943
|
-
* it.
|
|
944
|
-
*
|
|
942
|
+
* it. The active session file and artifacts are moved into the target
|
|
943
|
+
* directory's session bucket so `/resume` from that directory can find it.
|
|
945
944
|
*/
|
|
946
945
|
async handleMoveCommand(targetPath?: string): Promise<void> {
|
|
947
946
|
if (this.ctx.session.isStreaming) {
|
|
@@ -1003,47 +1002,18 @@ export class CommandController {
|
|
|
1003
1002
|
}
|
|
1004
1003
|
}
|
|
1005
1004
|
|
|
1006
|
-
let newSessionFile: string | undefined;
|
|
1007
1005
|
try {
|
|
1008
|
-
|
|
1009
|
-
// folder, then switch to it. The current session is left behind and
|
|
1010
|
-
// remains resumable via /resume.
|
|
1011
|
-
newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
|
|
1012
|
-
const switched = await this.ctx.session.switchSession(newSessionFile);
|
|
1013
|
-
if (!switched) {
|
|
1014
|
-
await this.ctx.sessionManager.dropSession(newSessionFile);
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1006
|
+
await this.ctx.sessionManager.moveTo(resolvedPath);
|
|
1017
1007
|
} catch (err) {
|
|
1018
|
-
if (newSessionFile) {
|
|
1019
|
-
try {
|
|
1020
|
-
await this.ctx.sessionManager.dropSession(newSessionFile);
|
|
1021
|
-
} catch (dropErr) {
|
|
1022
|
-
this.ctx.showError(
|
|
1023
|
-
`Move failed: ${err instanceof Error ? err.message : String(err)}; failed to remove empty session: ${dropErr instanceof Error ? dropErr.message : String(dropErr)}`,
|
|
1024
|
-
);
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
1008
|
this.ctx.showError(`Move failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1029
1009
|
return;
|
|
1030
1010
|
}
|
|
1031
1011
|
|
|
1032
|
-
this.ctx.session.markMovedFromEmptySessionFile(newSessionFile!);
|
|
1033
1012
|
await this.ctx.applyCwdChange(resolvedPath);
|
|
1034
1013
|
|
|
1035
|
-
this.ctx.chatContainer.clear();
|
|
1036
|
-
this.ctx.pendingMessagesContainer.clear();
|
|
1037
|
-
this.ctx.compactionQueuedMessages = [];
|
|
1038
|
-
this.ctx.streamingComponent = undefined;
|
|
1039
|
-
this.ctx.streamingMessage = undefined;
|
|
1040
|
-
this.ctx.pendingTools.clear();
|
|
1041
|
-
this.ctx.statusLine.invalidate();
|
|
1042
|
-
this.ctx.statusLine.resetActiveTime();
|
|
1043
|
-
this.ctx.updateEditorTopBorder();
|
|
1044
1014
|
this.ctx.updateEditorBorderColor();
|
|
1045
1015
|
await this.ctx.reloadTodos();
|
|
1046
|
-
this.ctx.ui.requestRender(
|
|
1016
|
+
this.ctx.ui.requestRender();
|
|
1047
1017
|
|
|
1048
1018
|
this.ctx.present([
|
|
1049
1019
|
new Spacer(1),
|
|
@@ -953,9 +953,13 @@ export class EventController {
|
|
|
953
953
|
}
|
|
954
954
|
// Update todo display when todo tool completes
|
|
955
955
|
if (event.toolName === "todo" && !event.isError) {
|
|
956
|
+
const hadTodoReminder = (this.ctx.todoReminderContainer?.children.length ?? 0) > 0;
|
|
957
|
+
this.ctx.todoReminderContainer?.clear();
|
|
956
958
|
const details = event.result.details as { phases?: TodoPhase[] } | undefined;
|
|
957
959
|
if (details?.phases) {
|
|
958
960
|
this.ctx.setTodos(details.phases);
|
|
961
|
+
} else if (hadTodoReminder) {
|
|
962
|
+
this.ctx.ui.requestRender();
|
|
959
963
|
}
|
|
960
964
|
} else if (event.toolName === "todo" && event.isError) {
|
|
961
965
|
const textContent = event.result.content.find(
|
|
@@ -1252,7 +1256,9 @@ export class EventController {
|
|
|
1252
1256
|
|
|
1253
1257
|
async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
|
|
1254
1258
|
const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
|
|
1255
|
-
this.ctx.
|
|
1259
|
+
this.ctx.todoReminderContainer.clear();
|
|
1260
|
+
this.ctx.todoReminderContainer.addChild(component);
|
|
1261
|
+
this.ctx.ui.requestRender();
|
|
1256
1262
|
}
|
|
1257
1263
|
|
|
1258
1264
|
async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {
|
|
@@ -53,6 +53,7 @@ import { reset as resetCapabilities } from "../capability";
|
|
|
53
53
|
import type { CollabGuestLink } from "../collab/guest";
|
|
54
54
|
import type { CollabHost } from "../collab/host";
|
|
55
55
|
import { KeybindingsManager } from "../config/keybindings";
|
|
56
|
+
import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
|
56
57
|
import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
|
|
57
58
|
import { clearClaudePluginRootsCache } from "../discovery/helpers";
|
|
58
59
|
import type {
|
|
@@ -100,7 +101,6 @@ import { STTController, type SttState } from "../stt";
|
|
|
100
101
|
import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
|
|
101
102
|
import { formatTaskId } from "../task/render";
|
|
102
103
|
import type { LspStartupServerInfo } from "../tools";
|
|
103
|
-
import { isImageProviderPreference, setPreferredImageProvider } from "../tools/image-gen";
|
|
104
104
|
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
105
105
|
import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
|
|
106
106
|
import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
|
|
@@ -114,12 +114,6 @@ import { getEditorCommand, openInEditor } from "../utils/external-editor";
|
|
|
114
114
|
import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
|
|
115
115
|
import { messageHasDisplayableThinking } from "../utils/thinking-display";
|
|
116
116
|
import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
|
|
117
|
-
import {
|
|
118
|
-
isSearchProviderId,
|
|
119
|
-
isSearchProviderPreference,
|
|
120
|
-
setExcludedSearchProviders,
|
|
121
|
-
setPreferredSearchProvider,
|
|
122
|
-
} from "../web/search";
|
|
123
117
|
import type { AssistantMessageComponent } from "./components/assistant-message";
|
|
124
118
|
import type { BashExecutionComponent } from "./components/bash-execution";
|
|
125
119
|
import { ChatBlock, type ChatBlockHost } from "./components/chat-block";
|
|
@@ -384,6 +378,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
384
378
|
chatContainer: TranscriptContainer;
|
|
385
379
|
pendingMessagesContainer: Container;
|
|
386
380
|
statusContainer: Container;
|
|
381
|
+
todoReminderContainer: Container;
|
|
387
382
|
todoContainer: Container;
|
|
388
383
|
subagentContainer: Container;
|
|
389
384
|
btwContainer: Container;
|
|
@@ -552,6 +547,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
552
547
|
this.retryLoader = undefined;
|
|
553
548
|
}
|
|
554
549
|
this.statusContainer.clear();
|
|
550
|
+
this.todoReminderContainer.clear();
|
|
555
551
|
this.pendingMessagesContainer.clear();
|
|
556
552
|
this.#cancelModelCycleClearTimer();
|
|
557
553
|
this.modelCycleContainer.clear();
|
|
@@ -631,6 +627,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
631
627
|
this.chatContainer = new TranscriptContainer();
|
|
632
628
|
this.pendingMessagesContainer = new Container();
|
|
633
629
|
this.statusContainer = new AnchoredLiveContainer();
|
|
630
|
+
this.todoReminderContainer = new AnchoredLiveContainer();
|
|
634
631
|
this.todoContainer = new AnchoredLiveContainer();
|
|
635
632
|
this.subagentContainer = new AnchoredLiveContainer();
|
|
636
633
|
this.btwContainer = new AnchoredLiveContainer();
|
|
@@ -850,6 +847,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
850
847
|
|
|
851
848
|
this.ui.addChild(this.chatContainer);
|
|
852
849
|
this.ui.addChild(this.pendingMessagesContainer);
|
|
850
|
+
this.ui.addChild(this.todoReminderContainer);
|
|
853
851
|
this.ui.addChild(this.todoContainer);
|
|
854
852
|
this.ui.addChild(this.subagentContainer);
|
|
855
853
|
this.ui.addChild(this.btwContainer);
|
|
@@ -1053,18 +1051,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1053
1051
|
// module-level search/image provider state reflects the destination
|
|
1054
1052
|
// project's configuration. Without this, the previous project's
|
|
1055
1053
|
// exclusions leak and newly-excluded providers are still used.
|
|
1056
|
-
|
|
1057
|
-
if (Array.isArray(excludedWebSearchProviders)) {
|
|
1058
|
-
setExcludedSearchProviders(excludedWebSearchProviders.filter(isSearchProviderId));
|
|
1059
|
-
}
|
|
1060
|
-
const webSearchProvider = settings.get("providers.webSearch");
|
|
1061
|
-
if (typeof webSearchProvider === "string" && isSearchProviderPreference(webSearchProvider)) {
|
|
1062
|
-
setPreferredSearchProvider(webSearchProvider);
|
|
1063
|
-
}
|
|
1064
|
-
const imageProvider = settings.get("providers.image");
|
|
1065
|
-
if (isImageProviderPreference(imageProvider)) {
|
|
1066
|
-
setPreferredImageProvider(imageProvider);
|
|
1067
|
-
}
|
|
1054
|
+
applyProviderGlobalsFromSettings(settings);
|
|
1068
1055
|
}
|
|
1069
1056
|
// Re-warm plugin roots, capabilities, slash commands, and the ssh tool so
|
|
1070
1057
|
// the next prompt sees everything scoped to the new project directory.
|
|
@@ -1624,8 +1611,8 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1624
1611
|
}),
|
|
1625
1612
|
}));
|
|
1626
1613
|
if (!mutated) return;
|
|
1627
|
-
this.todoPhases = next;
|
|
1628
1614
|
this.session.setTodoPhases(next);
|
|
1615
|
+
this.setTodos(next);
|
|
1629
1616
|
}
|
|
1630
1617
|
|
|
1631
1618
|
#cancelTodoAutoClearTimer(): void {
|
|
@@ -4089,12 +4076,14 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
4089
4076
|
},
|
|
4090
4077
|
];
|
|
4091
4078
|
}
|
|
4079
|
+
this.todoReminderContainer.clear();
|
|
4092
4080
|
this.#syncTodoAutoClearTimer();
|
|
4093
4081
|
this.#renderTodoList();
|
|
4094
4082
|
this.ui.requestRender();
|
|
4095
4083
|
}
|
|
4096
4084
|
|
|
4097
4085
|
async reloadTodos(): Promise<void> {
|
|
4086
|
+
this.todoReminderContainer.clear();
|
|
4098
4087
|
await this.#loadTodoList();
|
|
4099
4088
|
this.ui.requestRender();
|
|
4100
4089
|
}
|
package/src/modes/types.ts
CHANGED
|
@@ -96,6 +96,7 @@ export interface InteractiveModeContext {
|
|
|
96
96
|
chatContainer: TranscriptContainer;
|
|
97
97
|
pendingMessagesContainer: Container;
|
|
98
98
|
statusContainer: Container;
|
|
99
|
+
todoReminderContainer: Container;
|
|
99
100
|
todoContainer: Container;
|
|
100
101
|
subagentContainer: Container;
|
|
101
102
|
btwContainer: Container;
|
|
@@ -121,7 +122,7 @@ export interface InteractiveModeContext {
|
|
|
121
122
|
focusParentSession(): Promise<void>;
|
|
122
123
|
/** Return the view to the main session (delegates to SessionFocusController.unfocus). */
|
|
123
124
|
unfocusSession(): Promise<void>;
|
|
124
|
-
/** Clear loader,
|
|
125
|
+
/** Clear loader, transient HUD/pending containers, streaming state, and pending tools. */
|
|
125
126
|
clearTransientSessionUi(): void;
|
|
126
127
|
settings: Settings;
|
|
127
128
|
keybindings: KeybindingsManager;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<todo_context>
|
|
2
|
+
Current persisted todo state for this goal follows. Goal continuations do not get a visible user nudge, so treat this as live progress state, not old transcript decoration.
|
|
3
|
+
Before continuing substantial work, compare your next action with these todos. If an item is stale, already finished, or no longer the active pointer, call the `todo` tool first to mark it done or rewrite the list. Do not leave a stale in_progress item while working on later phases.
|
|
4
|
+
|
|
5
|
+
Overall: {{closed}}/{{total}} done, {{open}} open.
|
|
6
|
+
{{#each phases}}
|
|
7
|
+
- {{escapeXml name}}
|
|
8
|
+
{{#each tasks}}
|
|
9
|
+
- [{{status}}] {{escapeXml content}}
|
|
10
|
+
{{/each}}
|
|
11
|
+
{{/each}}
|
|
12
|
+
</todo_context>
|
|
@@ -231,6 +231,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
|
|
231
231
|
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
232
232
|
import type { PlanModeState } from "../plan-mode/state";
|
|
233
233
|
import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
|
|
234
|
+
import goalTodoContextPrompt from "../prompts/goals/goal-todo-context.md" with { type: "text" };
|
|
234
235
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
235
236
|
import eagerTaskPrompt from "../prompts/system/eager-task.md" with { type: "text" };
|
|
236
237
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
@@ -6468,16 +6469,46 @@ export class AgentSession {
|
|
|
6468
6469
|
#buildGoalModeMessage(): CustomMessage | null {
|
|
6469
6470
|
const content = this.#goalRuntime.buildActivePrompt();
|
|
6470
6471
|
if (!content) return null;
|
|
6472
|
+
const todoContext = this.#buildGoalTodoContext();
|
|
6471
6473
|
return {
|
|
6472
6474
|
role: "custom",
|
|
6473
6475
|
customType: "goal-mode-context",
|
|
6474
|
-
content,
|
|
6476
|
+
content: todoContext ? `${content}\n\n${todoContext}` : content,
|
|
6475
6477
|
display: false,
|
|
6476
6478
|
attribution: "agent",
|
|
6477
6479
|
timestamp: Date.now(),
|
|
6478
6480
|
};
|
|
6479
6481
|
}
|
|
6480
6482
|
|
|
6483
|
+
#buildGoalTodoContext(): string | undefined {
|
|
6484
|
+
if (!this.settings.get("todo.enabled")) return undefined;
|
|
6485
|
+
const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0);
|
|
6486
|
+
if (phases.length === 0) return undefined;
|
|
6487
|
+
|
|
6488
|
+
let total = 0;
|
|
6489
|
+
let closed = 0;
|
|
6490
|
+
let open = 0;
|
|
6491
|
+
const promptPhases = phases.map(phase => ({
|
|
6492
|
+
name: phase.name,
|
|
6493
|
+
tasks: phase.tasks.map(task => {
|
|
6494
|
+
total++;
|
|
6495
|
+
if (task.status === "completed" || task.status === "abandoned") {
|
|
6496
|
+
closed++;
|
|
6497
|
+
} else {
|
|
6498
|
+
open++;
|
|
6499
|
+
}
|
|
6500
|
+
return { content: task.content, status: task.status };
|
|
6501
|
+
}),
|
|
6502
|
+
}));
|
|
6503
|
+
|
|
6504
|
+
return prompt.render(goalTodoContextPrompt, {
|
|
6505
|
+
closed: String(closed),
|
|
6506
|
+
open: String(open),
|
|
6507
|
+
phases: promptPhases,
|
|
6508
|
+
total: String(total),
|
|
6509
|
+
});
|
|
6510
|
+
}
|
|
6511
|
+
|
|
6481
6512
|
#normalizeImagesForModel(images: ImageContent[] | undefined): Promise<ImageContent[] | undefined> {
|
|
6482
6513
|
return normalizeModelContextImages(images, { model: this.model });
|
|
6483
6514
|
}
|
|
@@ -1618,9 +1618,8 @@ export class SessionManager {
|
|
|
1618
1618
|
/**
|
|
1619
1619
|
* Create a fresh empty session file in the default session directory for
|
|
1620
1620
|
* `cwd`, writing only the session header. The returned path can be passed to
|
|
1621
|
-
* `setSessionFile` / `AgentSession.switchSession`
|
|
1622
|
-
*
|
|
1623
|
-
* dragging the current conversation along.
|
|
1621
|
+
* `setSessionFile` / `AgentSession.switchSession` when a caller explicitly
|
|
1622
|
+
* needs a brand-new persisted session at a cwd-derived path.
|
|
1624
1623
|
*/
|
|
1625
1624
|
static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
|
|
1626
1625
|
const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|