@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.3
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 +38 -0
- package/dist/cli.js +3221 -3208
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/settings-schema.ts +10 -0
- package/src/dap/client.ts +134 -36
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +74 -8
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/session/agent-session.ts +166 -30
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/tools/bash.ts +30 -1
- package/src/tools/grep.ts +21 -1
- package/src/tools/read.ts +14 -4
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +8 -0
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +23 -6
|
@@ -443,6 +443,10 @@ export class TranscriptContainer
|
|
|
443
443
|
// drift after commit; the engine commits them audit-exempt. Provisional
|
|
444
444
|
// (commit-unstable) blocks never extend it.
|
|
445
445
|
#nativeScrollbackSnapshotSafeEnd: number | undefined;
|
|
446
|
+
// Local line index through which lower finalized siblings are safe to OFFER to
|
|
447
|
+
// native scrollback while still audited. Unlike snapshotSafeEnd, rows below a
|
|
448
|
+
// live block are not durable: growth above them must repair stale history.
|
|
449
|
+
#nativeScrollbackOfferSafeEnd: number | undefined;
|
|
446
450
|
// Persistent assembled transcript rows. Rows before the stable floor are
|
|
447
451
|
// byte-identical to the previous render; rows at/after it were re-pushed.
|
|
448
452
|
#lines: string[] = [];
|
|
@@ -491,6 +495,10 @@ export class TranscriptContainer
|
|
|
491
495
|
return this.#nativeScrollbackSnapshotSafeEnd;
|
|
492
496
|
}
|
|
493
497
|
|
|
498
|
+
getNativeScrollbackOfferSafeEnd(): number | undefined {
|
|
499
|
+
return this.#nativeScrollbackOfferSafeEnd;
|
|
500
|
+
}
|
|
501
|
+
|
|
494
502
|
/**
|
|
495
503
|
* Whether `component` sits below a still-mutating block — i.e. inside the
|
|
496
504
|
* live region, where its rows cannot have been committed to native
|
|
@@ -583,6 +591,7 @@ export class TranscriptContainer
|
|
|
583
591
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
584
592
|
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
585
593
|
this.#nativeScrollbackSnapshotSafeEnd = undefined;
|
|
594
|
+
this.#nativeScrollbackOfferSafeEnd = undefined;
|
|
586
595
|
|
|
587
596
|
const count = this.children.length;
|
|
588
597
|
|
|
@@ -629,6 +638,14 @@ export class TranscriptContainer
|
|
|
629
638
|
// liveStartIndex; empty leading blocks (or a separator) must not claim it
|
|
630
639
|
// early.
|
|
631
640
|
let liveRecorded = false;
|
|
641
|
+
// Prefix boundary for finalized siblings rendered below the first live
|
|
642
|
+
// block. These rows may be offered to native scrollback, but they cannot
|
|
643
|
+
// extend snapshotSafeEnd because a live block above can still move them.
|
|
644
|
+
let offerSafeEnd: number | undefined;
|
|
645
|
+
// Offer rows must be a contiguous finalized run below the first live
|
|
646
|
+
// block. A later live/provisional block can still push rows below it, so
|
|
647
|
+
// finalized siblings after that barrier must stay forced-overflow.
|
|
648
|
+
let offerSafeOpen = true;
|
|
632
649
|
// Frame row cursor: rows emitted (reused or pushed) so far.
|
|
633
650
|
let row = 0;
|
|
634
651
|
let stableRows = 0;
|
|
@@ -701,6 +718,7 @@ export class TranscriptContainer
|
|
|
701
718
|
// everything below it.
|
|
702
719
|
if (contribution.length === 0) {
|
|
703
720
|
if (i >= liveStartIndex && commitSafeOpen && !finalized) commitSafeOpen = false;
|
|
721
|
+
if (i > liveStartIndex && !finalized) offerSafeOpen = false;
|
|
704
722
|
if (chainStable && !(reusable && previous.rowCount === 0 && previous.startRow === row)) {
|
|
705
723
|
chainStable = false;
|
|
706
724
|
lines.length = row;
|
|
@@ -770,6 +788,13 @@ export class TranscriptContainer
|
|
|
770
788
|
// rows around as it grows, so the run closes there.
|
|
771
789
|
if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
|
|
772
790
|
}
|
|
791
|
+
if (i > liveStartIndex) {
|
|
792
|
+
if (offerSafeOpen && finalized) {
|
|
793
|
+
offerSafeEnd = blockStart + contribution.length;
|
|
794
|
+
} else if (!finalized) {
|
|
795
|
+
offerSafeOpen = false;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
773
798
|
|
|
774
799
|
segments[i] = {
|
|
775
800
|
component: child,
|
|
@@ -788,6 +813,7 @@ export class TranscriptContainer
|
|
|
788
813
|
// Trailing shrink: blocks removed from the tail leave stale rows behind
|
|
789
814
|
// when every surviving segment was reused.
|
|
790
815
|
if (lines.length !== row) lines.length = row;
|
|
816
|
+
this.#nativeScrollbackOfferSafeEnd = offerSafeEnd;
|
|
791
817
|
this.#segments = segments;
|
|
792
818
|
this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
|
|
793
819
|
return lines;
|
|
@@ -302,9 +302,13 @@ export class EventController {
|
|
|
302
302
|
await this.ctx.init();
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
305
|
+
// Each handler explicitly requests a render (or leaves it out, when it
|
|
306
|
+
// changed nothing visible). A blanket pre-render fired on every event —
|
|
307
|
+
// including the ~hundreds of `message_update` deltas per streaming turn —
|
|
308
|
+
// doubled the paint rate: the pre-render's frame fires while the handler
|
|
309
|
+
// is awaiting, then the handler's own final requestRender schedules a
|
|
310
|
+
// second identical frame. Removing it lets the render cadence follow real
|
|
311
|
+
// state changes rather than event volume (issue #4353).
|
|
308
312
|
const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
|
|
309
313
|
await run(event);
|
|
310
314
|
}
|
|
@@ -13,7 +13,7 @@ type ToolArgsRevealComponent = {
|
|
|
13
13
|
// patch/replace `edits[].diff`) still fall through to the throttled parse.
|
|
14
14
|
const STREAMING_STRING_KEYS_BY_TOOL: Record<string, readonly string[]> = {
|
|
15
15
|
write: ["content"],
|
|
16
|
-
edit: ["input"],
|
|
16
|
+
edit: ["input", "_input"],
|
|
17
17
|
eval: ["code"],
|
|
18
18
|
};
|
|
19
19
|
|
|
@@ -3584,6 +3584,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
3584
3584
|
} else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
|
|
3585
3585
|
this.statusContainer.clear();
|
|
3586
3586
|
this.statusContainer.addChild(this.loadingAnimation);
|
|
3587
|
+
this.ui.requestRender();
|
|
3587
3588
|
}
|
|
3588
3589
|
this.applyPendingWorkingMessage();
|
|
3589
3590
|
}
|
|
@@ -256,18 +256,19 @@ export class RpcClient {
|
|
|
256
256
|
args.push(...this.options.args);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
-
|
|
259
|
+
const child = ptree.spawn(["bun", cliPath, ...args], {
|
|
260
260
|
cwd: this.options.cwd,
|
|
261
261
|
env: { ...Bun.env, ...this.options.env },
|
|
262
262
|
stdin: "pipe",
|
|
263
263
|
});
|
|
264
|
+
this.#process = child;
|
|
264
265
|
|
|
265
266
|
// Wait for the "ready" signal or process exit
|
|
266
267
|
const { promise: readyPromise, resolve: readyResolve, reject: readyReject } = Promise.withResolvers<void>();
|
|
267
268
|
let readySettled = false;
|
|
268
269
|
|
|
269
270
|
// Process lines in background, intercepting the ready signal
|
|
270
|
-
const lines = readJsonl(
|
|
271
|
+
const lines = readJsonl(child.stdout, this.#abortController.signal);
|
|
271
272
|
void (async () => {
|
|
272
273
|
for await (const line of lines) {
|
|
273
274
|
if (!readySettled && isRecord(line) && line.type === "ready") {
|
|
@@ -277,10 +278,16 @@ export class RpcClient {
|
|
|
277
278
|
}
|
|
278
279
|
this.#handleLine(line);
|
|
279
280
|
}
|
|
280
|
-
// Stream ended without ready signal —
|
|
281
|
+
// Stream ended without the ready signal — the child exited or is
|
|
282
|
+
// exiting. Defer to the exit handler below: ptree resolves
|
|
283
|
+
// `exited` only after stderr is fully drained (nonzero exits), so
|
|
284
|
+
// rejecting here would snapshot a partial stderr tail and lose
|
|
285
|
+
// the actual startup error.
|
|
286
|
+
if (readySettled) return;
|
|
287
|
+
await child.exited.catch(() => {});
|
|
281
288
|
if (!readySettled) {
|
|
282
289
|
readySettled = true;
|
|
283
|
-
readyReject(new Error(`Agent process exited before ready. Stderr: ${
|
|
290
|
+
readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`));
|
|
284
291
|
}
|
|
285
292
|
})().catch((err: Error) => {
|
|
286
293
|
if (!readySettled) {
|
|
@@ -290,22 +297,26 @@ export class RpcClient {
|
|
|
290
297
|
});
|
|
291
298
|
|
|
292
299
|
// Also race against process exit (in case stdout closes before we read it)
|
|
293
|
-
void
|
|
294
|
-
|
|
300
|
+
void child.exited.then(
|
|
301
|
+
(exitCode: number) => {
|
|
302
|
+
if (readySettled) return;
|
|
295
303
|
readySettled = true;
|
|
296
|
-
readyReject(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
304
|
+
readyReject(new Error(`Agent process exited with code ${exitCode}. Stderr: ${child.peekStderr()}`));
|
|
305
|
+
},
|
|
306
|
+
(err: Error) => {
|
|
307
|
+
// Killed or reaped without an exit code (e.g. stop() during
|
|
308
|
+
// startup); surface it instead of leaking an unhandled rejection.
|
|
309
|
+
if (readySettled) return;
|
|
310
|
+
readySettled = true;
|
|
311
|
+
readyReject(new Error(`Agent process exited before ready. Stderr: ${child.peekStderr()}`, { cause: err }));
|
|
312
|
+
},
|
|
313
|
+
);
|
|
301
314
|
|
|
302
315
|
// Timeout to prevent hanging forever
|
|
303
316
|
const readyTimeout = this.#startTimeout(30000, () => {
|
|
304
317
|
if (readySettled) return;
|
|
305
318
|
readySettled = true;
|
|
306
|
-
readyReject(
|
|
307
|
-
new Error(`Timeout waiting for agent to become ready. Stderr: ${this.#process?.peekStderr() ?? ""}`),
|
|
308
|
-
);
|
|
319
|
+
readyReject(new Error(`Timeout waiting for agent to become ready. Stderr: ${child.peekStderr()}`));
|
|
309
320
|
});
|
|
310
321
|
|
|
311
322
|
try {
|
|
@@ -318,12 +329,14 @@ export class RpcClient {
|
|
|
318
329
|
// state so the caller (or a retry via start() again) does not
|
|
319
330
|
// leak the abandoned process (issue #4079).
|
|
320
331
|
try {
|
|
321
|
-
|
|
332
|
+
child.kill();
|
|
322
333
|
} catch {
|
|
323
334
|
// best-effort cleanup
|
|
324
335
|
}
|
|
325
336
|
this.#abortController.abort();
|
|
326
|
-
this.#process
|
|
337
|
+
if (this.#process === child) {
|
|
338
|
+
this.#process = null;
|
|
339
|
+
}
|
|
327
340
|
throw err;
|
|
328
341
|
} finally {
|
|
329
342
|
clearTimeout(readyTimeout);
|
|
@@ -180,12 +180,16 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
|
|
|
180
180
|
const mode = resolveMode();
|
|
181
181
|
|
|
182
182
|
// Pre-scan: total code-point count (positions the band) and resolved palette.
|
|
183
|
+
// The per-segment string is kept verbatim — iterating UTF-16 units with a
|
|
184
|
+
// surrogate-pair guard produces the same code points as `Array.from(text)`
|
|
185
|
+
// at zero per-frame allocation (previously the #1 hotspot at ~10% of profiled
|
|
186
|
+
// CPU during streaming — the working message is shimmered every animation
|
|
187
|
+
// frame at 30fps and `Array.from` reallocated the code-point array each tick).
|
|
183
188
|
let total = 0;
|
|
184
|
-
const perSeg: {
|
|
189
|
+
const perSeg: { text: string; palette: ShimmerPalette }[] = [];
|
|
185
190
|
for (const seg of segments) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
perSeg.push({ chars, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
|
|
191
|
+
total += countCodePoints(seg.text);
|
|
192
|
+
perSeg.push({ text: seg.text, palette: seg.palette ?? DEFAULT_SHIMMER_PALETTE });
|
|
189
193
|
}
|
|
190
194
|
if (total === 0) return "";
|
|
191
195
|
|
|
@@ -193,9 +197,9 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
|
|
|
193
197
|
// tier so the working line stays legible without movement.
|
|
194
198
|
if (mode === "disabled") {
|
|
195
199
|
let out = "";
|
|
196
|
-
for (const {
|
|
200
|
+
for (const { text, palette } of perSeg) {
|
|
197
201
|
const seq = compile(theme, palette).mid;
|
|
198
|
-
out += `${seq.open}${
|
|
202
|
+
out += `${seq.open}${text}${seq.close}`;
|
|
199
203
|
}
|
|
200
204
|
return out;
|
|
201
205
|
}
|
|
@@ -205,31 +209,61 @@ export function shimmerSegments(segments: readonly ShimmerSegment[], theme: Shim
|
|
|
205
209
|
|
|
206
210
|
let out = "";
|
|
207
211
|
let index = 0;
|
|
208
|
-
for (const {
|
|
212
|
+
for (const { text, palette } of perSeg) {
|
|
209
213
|
const compiled = compile(theme, palette);
|
|
210
214
|
let runTier: Tier | null = null;
|
|
211
|
-
let
|
|
212
|
-
|
|
215
|
+
let runStart = 0;
|
|
216
|
+
let runEnd = 0;
|
|
217
|
+
let i = 0;
|
|
218
|
+
while (i < text.length) {
|
|
219
|
+
// Detect a surrogate pair so a single code point (e.g. an emoji) stays
|
|
220
|
+
// atomic; the band position is measured in code points, not UTF-16 units.
|
|
221
|
+
const c = text.charCodeAt(i);
|
|
222
|
+
let step = 1;
|
|
223
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
|
|
224
|
+
const c2 = text.charCodeAt(i + 1);
|
|
225
|
+
if (c2 >= 0xdc00 && c2 <= 0xdfff) step = 2;
|
|
226
|
+
}
|
|
213
227
|
const tier = tierFor(intensityFn(time, index, total));
|
|
214
228
|
if (tier !== runTier) {
|
|
215
|
-
if (runTier !== null) {
|
|
229
|
+
if (runTier !== null && runEnd > runStart) {
|
|
216
230
|
const seq = compiled[runTier];
|
|
217
|
-
out += `${seq.open}${
|
|
218
|
-
runBuf = "";
|
|
231
|
+
out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
|
|
219
232
|
}
|
|
220
233
|
runTier = tier;
|
|
234
|
+
runStart = i;
|
|
221
235
|
}
|
|
222
|
-
|
|
236
|
+
runEnd = i + step;
|
|
223
237
|
index++;
|
|
238
|
+
i += step;
|
|
224
239
|
}
|
|
225
|
-
if (runTier !== null &&
|
|
240
|
+
if (runTier !== null && runEnd > runStart) {
|
|
226
241
|
const seq = compiled[runTier];
|
|
227
|
-
out += `${seq.open}${
|
|
242
|
+
out += `${seq.open}${text.slice(runStart, runEnd)}${seq.close}`;
|
|
228
243
|
}
|
|
229
244
|
}
|
|
230
245
|
return out;
|
|
231
246
|
}
|
|
232
247
|
|
|
248
|
+
function countCodePoints(text: string): number {
|
|
249
|
+
let n = 0;
|
|
250
|
+
let i = 0;
|
|
251
|
+
while (i < text.length) {
|
|
252
|
+
const c = text.charCodeAt(i);
|
|
253
|
+
if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
|
|
254
|
+
const c2 = text.charCodeAt(i + 1);
|
|
255
|
+
if (c2 >= 0xdc00 && c2 <= 0xdfff) {
|
|
256
|
+
i += 2;
|
|
257
|
+
n++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
i++;
|
|
262
|
+
n++;
|
|
263
|
+
}
|
|
264
|
+
return n;
|
|
265
|
+
}
|
|
266
|
+
|
|
233
267
|
export function shimmerText(text: string, theme: ShimmerTheme, palette?: ShimmerPalette): string {
|
|
234
268
|
return shimmerSegments([{ text, palette }], theme);
|
|
235
269
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -159,6 +159,8 @@ export type SymbolKey =
|
|
|
159
159
|
| "md.hrChar"
|
|
160
160
|
| "md.bullet"
|
|
161
161
|
| "md.colorSwatch"
|
|
162
|
+
// Advisor note rail
|
|
163
|
+
| "advisor.rail"
|
|
162
164
|
// Language/file type icons
|
|
163
165
|
| "lang.default"
|
|
164
166
|
| "lang.typescript"
|
|
@@ -364,6 +366,8 @@ const UNICODE_SYMBOLS: SymbolMap = {
|
|
|
364
366
|
"md.hrChar": "─",
|
|
365
367
|
"md.bullet": "•",
|
|
366
368
|
"md.colorSwatch": "■",
|
|
369
|
+
// Advisor note rail (heavier than md.quoteBorder so notes read as a distinct voice)
|
|
370
|
+
"advisor.rail": "▎",
|
|
367
371
|
// Language/file icons (emoji-centric, no Nerd Font required)
|
|
368
372
|
"lang.default": "⌘",
|
|
369
373
|
"lang.typescript": "🟦",
|
|
@@ -675,6 +679,8 @@ const NERD_SYMBOLS: SymbolMap = {
|
|
|
675
679
|
"md.bullet": "\uf111",
|
|
676
680
|
// pick: ■ | alt: (U+F096)
|
|
677
681
|
"md.colorSwatch": "■",
|
|
682
|
+
// pick: ▎ | alt: ┃ │
|
|
683
|
+
"advisor.rail": "▎",
|
|
678
684
|
// Language icons (nerd font devicons)
|
|
679
685
|
"lang.default": "",
|
|
680
686
|
"lang.typescript": "\u{E628}",
|
|
@@ -877,6 +883,7 @@ const ASCII_SYMBOLS: SymbolMap = {
|
|
|
877
883
|
"md.hrChar": "-",
|
|
878
884
|
"md.bullet": "*",
|
|
879
885
|
"md.colorSwatch": "[]",
|
|
886
|
+
"advisor.rail": "|",
|
|
880
887
|
// Language icons (ASCII uses abbreviations)
|
|
881
888
|
"lang.default": "code",
|
|
882
889
|
"lang.typescript": "ts",
|
|
@@ -969,6 +969,18 @@ interface ActiveAdvisor {
|
|
|
969
969
|
agentUnsubscribe?: () => void;
|
|
970
970
|
model: Model;
|
|
971
971
|
thinkingLevel: ThinkingLevel;
|
|
972
|
+
/** Stable key for the resolved runtime inputs that require a rebuild to change. */
|
|
973
|
+
signature: string;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Resolved advisor config ready to instantiate as an {@link ActiveAdvisor}. */
|
|
977
|
+
interface AdvisorRuntimeDescriptor {
|
|
978
|
+
config: AdvisorConfig;
|
|
979
|
+
name: string;
|
|
980
|
+
slug: string;
|
|
981
|
+
model: Model;
|
|
982
|
+
thinkingLevel: ThinkingLevel;
|
|
983
|
+
signature: string;
|
|
972
984
|
}
|
|
973
985
|
|
|
974
986
|
export interface FreshSessionResult {
|
|
@@ -2268,32 +2280,10 @@ export class AgentSession {
|
|
|
2268
2280
|
}
|
|
2269
2281
|
}
|
|
2270
2282
|
|
|
2271
|
-
#
|
|
2272
|
-
if (this.#isDisposed) return false;
|
|
2273
|
-
if (this.#advisors.length > 0) return true;
|
|
2274
|
-
if (!this.#advisorEnabled) return false;
|
|
2275
|
-
if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
|
|
2276
|
-
|
|
2277
|
-
// The lone implicit "default" advisor (slug "") reproduces the legacy
|
|
2278
|
-
// single-advisor path: the `advisor` role model + `__advisor.jsonl`.
|
|
2283
|
+
#resolveAdvisorRuntimeDescriptors(emitWarnings: boolean): AdvisorRuntimeDescriptor[] {
|
|
2279
2284
|
const legacy = !this.#advisorConfigs?.length;
|
|
2280
2285
|
const roster: AdvisorConfig[] = legacy ? [{ name: "default" }] : this.#advisorConfigs!;
|
|
2281
|
-
|
|
2282
|
-
// Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
|
|
2283
|
-
// on standard processing; "inherit" tracks the session's live per-family
|
|
2284
|
-
// tiers per request (like the main agent, including /fast toggles); a
|
|
2285
|
-
// concrete value is broadcast across families and applied to the advisor
|
|
2286
|
-
// model's family. One value for all advisors.
|
|
2287
|
-
const advisorTierSetting = this.settings.get("tier.advisor");
|
|
2288
|
-
const advisorTierMap =
|
|
2289
|
-
advisorTierSetting === "inherit"
|
|
2290
|
-
? undefined
|
|
2291
|
-
: serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
|
|
2292
|
-
const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
|
|
2293
|
-
advisorTierSetting === "inherit"
|
|
2294
|
-
? this.#effectiveServiceTier(model)
|
|
2295
|
-
: resolveModelServiceTier(advisorTierMap, model);
|
|
2296
|
-
|
|
2286
|
+
const descriptors: AdvisorRuntimeDescriptor[] = [];
|
|
2297
2287
|
const usedSlugs = new Set<string>();
|
|
2298
2288
|
for (const config of roster) {
|
|
2299
2289
|
let slug = legacy ? "" : slugifyAdvisorName(config.name);
|
|
@@ -2314,23 +2304,84 @@ export class AgentSession {
|
|
|
2314
2304
|
model = resolved.model;
|
|
2315
2305
|
thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
|
|
2316
2306
|
if (!model) {
|
|
2317
|
-
|
|
2307
|
+
if (emitWarnings) {
|
|
2308
|
+
this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
|
|
2309
|
+
}
|
|
2318
2310
|
continue;
|
|
2319
2311
|
}
|
|
2320
2312
|
} else {
|
|
2321
2313
|
const sel = resolveAdvisorRoleSelection(this.settings, this.#modelRegistry.getAvailable());
|
|
2322
2314
|
if (!sel) {
|
|
2323
|
-
|
|
2324
|
-
advisor
|
|
2325
|
-
|
|
2315
|
+
if (emitWarnings) {
|
|
2316
|
+
logger.debug("advisor enabled but no model assigned to the 'advisor' role; advisor inactive", {
|
|
2317
|
+
advisor: config.name,
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2326
2320
|
continue;
|
|
2327
2321
|
}
|
|
2328
2322
|
model = sel.model;
|
|
2329
2323
|
thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
|
|
2330
2324
|
}
|
|
2331
|
-
const advisorModel = model;
|
|
2332
|
-
const advisorName = config.name;
|
|
2333
2325
|
const advisorThinkingLevel = thinkingLevel ?? ThinkingLevel.Medium;
|
|
2326
|
+
descriptors.push({
|
|
2327
|
+
config,
|
|
2328
|
+
name: config.name,
|
|
2329
|
+
slug,
|
|
2330
|
+
model,
|
|
2331
|
+
thinkingLevel: advisorThinkingLevel,
|
|
2332
|
+
signature: this.#advisorRuntimeSignature(config, slug, model, advisorThinkingLevel),
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
return descriptors;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
#advisorRuntimeSignature(config: AdvisorConfig, slug: string, model: Model, thinkingLevel: ThinkingLevel): string {
|
|
2339
|
+
const tools = config.tools?.length ? config.tools.join("\u001e") : "";
|
|
2340
|
+
const instructions = config.instructions?.trim() ?? "";
|
|
2341
|
+
return [config.name, slug, model.provider, model.id, thinkingLevel, tools, instructions].join("\u001f");
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
#advisorRuntimeMatchesCurrentConfig(): boolean {
|
|
2345
|
+
const descriptors = this.#resolveAdvisorRuntimeDescriptors(false);
|
|
2346
|
+
if (descriptors.length !== this.#advisors.length) return false;
|
|
2347
|
+
for (let i = 0; i < descriptors.length; i++) {
|
|
2348
|
+
if (descriptors[i].signature !== this.#advisors[i].signature) return false;
|
|
2349
|
+
}
|
|
2350
|
+
return true;
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
#buildAdvisorRuntime(seedToCurrent = false): boolean {
|
|
2354
|
+
if (this.#isDisposed) return false;
|
|
2355
|
+
if (this.#advisors.length > 0) return true;
|
|
2356
|
+
if (!this.#advisorEnabled) return false;
|
|
2357
|
+
if (this.#agentKind !== "main" && !this.settings.get("advisor.subagents")) return false;
|
|
2358
|
+
|
|
2359
|
+
const descriptors = this.#resolveAdvisorRuntimeDescriptors(true);
|
|
2360
|
+
|
|
2361
|
+
// Advisor service tier (`tier.advisor`): "none" (default) runs the advisor
|
|
2362
|
+
// on standard processing; "inherit" tracks the session's live per-family
|
|
2363
|
+
// tiers per request (like the main agent, including /fast toggles); a
|
|
2364
|
+
// concrete value is broadcast across families and applied to the advisor
|
|
2365
|
+
// model's family. One value for all advisors.
|
|
2366
|
+
const advisorTierSetting = this.settings.get("tier.advisor");
|
|
2367
|
+
const advisorTierMap =
|
|
2368
|
+
advisorTierSetting === "inherit"
|
|
2369
|
+
? undefined
|
|
2370
|
+
: serviceTierForAllFamilies(serviceTierSettingToTier(advisorTierSetting));
|
|
2371
|
+
const advisorServiceTierResolver = (model: Model): ServiceTier | undefined =>
|
|
2372
|
+
advisorTierSetting === "inherit"
|
|
2373
|
+
? this.#effectiveServiceTier(model)
|
|
2374
|
+
: resolveModelServiceTier(advisorTierMap, model);
|
|
2375
|
+
|
|
2376
|
+
for (const descriptor of descriptors) {
|
|
2377
|
+
const {
|
|
2378
|
+
config,
|
|
2379
|
+
slug,
|
|
2380
|
+
model: advisorModel,
|
|
2381
|
+
name: advisorName,
|
|
2382
|
+
thinkingLevel: advisorThinkingLevel,
|
|
2383
|
+
signature,
|
|
2384
|
+
} = descriptor;
|
|
2334
2385
|
|
|
2335
2386
|
const emissionGuard = new AdvisorEmissionGuard();
|
|
2336
2387
|
const adviseTool = new AdviseTool((note, severity) => this.#routeAdvice(advisorRef, note, severity));
|
|
@@ -2456,6 +2507,7 @@ export class AgentSession {
|
|
|
2456
2507
|
recorderClosed: Promise.resolve(),
|
|
2457
2508
|
model: advisorModel,
|
|
2458
2509
|
thinkingLevel: advisorThinkingLevel,
|
|
2510
|
+
signature,
|
|
2459
2511
|
};
|
|
2460
2512
|
this.#attachAdvisorRecorderFeed(advisorRef);
|
|
2461
2513
|
if (seedToCurrent) runtime.seedTo(this.agent.state.messages.length);
|
|
@@ -2980,6 +3032,26 @@ export class AgentSession {
|
|
|
2980
3032
|
manager?.cancelAll({ ownerId: this.#agentId });
|
|
2981
3033
|
}
|
|
2982
3034
|
|
|
3035
|
+
/**
|
|
3036
|
+
* True when a background async job owned by this agent is still running with
|
|
3037
|
+
* an unsuppressed delivery, or a finished job's delivery is still queued or
|
|
3038
|
+
* in flight. Either way the async-result follow-up will re-wake the loop, so
|
|
3039
|
+
* a settle observed now is a scheduling pause rather than a terminal stop:
|
|
3040
|
+
* stop-time passes (todo reminder, session_stop hooks) defer to the settle
|
|
3041
|
+
* reached once the session is fully idle. Suppressed deliveries
|
|
3042
|
+
* (acknowledged, or watched by an in-flight `job` poll) never wake the loop,
|
|
3043
|
+
* so they don't count.
|
|
3044
|
+
*/
|
|
3045
|
+
#hasPendingAsyncWake(): boolean {
|
|
3046
|
+
const manager = this.#asyncJobManager;
|
|
3047
|
+
if (!manager) return false;
|
|
3048
|
+
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
|
|
3049
|
+
return (
|
|
3050
|
+
manager.getRunningJobs(ownerFilter).some(job => !manager.isDeliverySuppressed(job.id)) ||
|
|
3051
|
+
manager.hasPendingDeliveries(ownerFilter)
|
|
3052
|
+
);
|
|
3053
|
+
}
|
|
3054
|
+
|
|
2983
3055
|
// =========================================================================
|
|
2984
3056
|
// Event Subscription
|
|
2985
3057
|
// =========================================================================
|
|
@@ -3894,6 +3966,15 @@ export class AgentSession {
|
|
|
3894
3966
|
return;
|
|
3895
3967
|
}
|
|
3896
3968
|
}
|
|
3969
|
+
// A pending async wake means this settle is a scheduling pause, not
|
|
3970
|
+
// the terminal stop: the async-result delivery continues the loop and
|
|
3971
|
+
// the real stop settles later. Defer the session_stop hook pass until
|
|
3972
|
+
// the session is fully idle (the todo reminder above defers the same
|
|
3973
|
+
// way inside #checkTodoCompletion).
|
|
3974
|
+
if (this.#hasPendingAsyncWake()) {
|
|
3975
|
+
await emitAgentEndNotification();
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3897
3978
|
await this.#emitSessionStopEvent(settledMessages, msg);
|
|
3898
3979
|
await emitAgentEndNotification();
|
|
3899
3980
|
}
|
|
@@ -10117,6 +10198,48 @@ export class AgentSession {
|
|
|
10117
10198
|
}
|
|
10118
10199
|
return COMPACTION_CHECK_NONE;
|
|
10119
10200
|
}
|
|
10201
|
+
// A context promotion can land while the failing call is already in
|
|
10202
|
+
// flight (or on a run whose loop predates the switch): the overflow
|
|
10203
|
+
// error then arrives stamped with the pre-promotion model while
|
|
10204
|
+
// `this.model` is already the promoted target. The sameModel guard
|
|
10205
|
+
// above deliberately ignores stale foreign-model errors, but this
|
|
10206
|
+
// state is not stale — recover exactly like the promotion path:
|
|
10207
|
+
// drop the dead turn and retry on the already-promoted model. Gated
|
|
10208
|
+
// narrowly on "current model IS the failed model's promotion target
|
|
10209
|
+
// with a strictly larger window" so genuinely stale errors from
|
|
10210
|
+
// old user-switched models keep surfacing untouched.
|
|
10211
|
+
if (
|
|
10212
|
+
!sameModel &&
|
|
10213
|
+
autoContinue &&
|
|
10214
|
+
!errorIsFromBeforeCompaction &&
|
|
10215
|
+
assistantMessage.stopReason === "error" &&
|
|
10216
|
+
this.model &&
|
|
10217
|
+
contextWindow > 0 &&
|
|
10218
|
+
this.settings.getGroup("contextPromotion").enabled
|
|
10219
|
+
) {
|
|
10220
|
+
const failedModel = this.#modelRegistry.find(assistantMessage.provider, assistantMessage.model);
|
|
10221
|
+
const failedWindow = failedModel?.contextWindow ?? 0;
|
|
10222
|
+
const promotionTarget = failedModel
|
|
10223
|
+
? this.#resolveContextPromotionConfiguredTarget(failedModel, this.#modelRegistry.getAvailable())
|
|
10224
|
+
: undefined;
|
|
10225
|
+
if (
|
|
10226
|
+
failedModel &&
|
|
10227
|
+
failedWindow > 0 &&
|
|
10228
|
+
contextWindow > failedWindow &&
|
|
10229
|
+
promotionTarget &&
|
|
10230
|
+
modelsAreEqual(promotionTarget, this.model) &&
|
|
10231
|
+
AIError.isContextOverflow(assistantMessage, failedWindow)
|
|
10232
|
+
) {
|
|
10233
|
+
this.#removeAssistantMessageFromActiveContext(assistantMessage);
|
|
10234
|
+
await this.#dropPersistedAssistantTurn(assistantMessage);
|
|
10235
|
+
logger.debug("Overflow on pre-promotion model; retrying on promoted model", {
|
|
10236
|
+
failed: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
10237
|
+
current: `${this.model.provider}/${this.model.id}`,
|
|
10238
|
+
});
|
|
10239
|
+
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
10240
|
+
return COMPACTION_CHECK_CONTINUATION;
|
|
10241
|
+
}
|
|
10242
|
+
}
|
|
10120
10243
|
|
|
10121
10244
|
// Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
|
|
10122
10245
|
// (and Codex) maps to stopReason === "length". The model burned its
|
|
@@ -10874,6 +10997,18 @@ export class AgentSession {
|
|
|
10874
10997
|
return false;
|
|
10875
10998
|
}
|
|
10876
10999
|
|
|
11000
|
+
// Background async jobs (bash/task) owned by this agent re-wake the loop
|
|
11001
|
+
// when they complete: the result delivery enqueues an async-result
|
|
11002
|
+
// follow-up that continues the run, and todos are re-evaluated at that
|
|
11003
|
+
// settle. A stop with such a job in flight is a scheduling pause, not
|
|
11004
|
+
// abandonment — stay silent instead of nagging.
|
|
11005
|
+
if (this.#hasPendingAsyncWake()) {
|
|
11006
|
+
logger.debug("Todo completion: async jobs in flight will re-wake the loop; skipping reminder", {
|
|
11007
|
+
incomplete: incomplete.length,
|
|
11008
|
+
});
|
|
11009
|
+
return false;
|
|
11010
|
+
}
|
|
11011
|
+
|
|
10877
11012
|
// Build reminder message
|
|
10878
11013
|
this.#todoReminderCount++;
|
|
10879
11014
|
const todoList = incompleteByPhase
|
|
@@ -15301,6 +15436,7 @@ export class AgentSession {
|
|
|
15301
15436
|
setAdvisorEnabled(enabled: boolean): boolean {
|
|
15302
15437
|
this.#advisorEnabled = enabled;
|
|
15303
15438
|
if (enabled) {
|
|
15439
|
+
if (this.#advisors.length > 0 && !this.#advisorRuntimeMatchesCurrentConfig()) this.#stopAdvisorRuntime();
|
|
15304
15440
|
return this.#buildAdvisorRuntime(true);
|
|
15305
15441
|
}
|
|
15306
15442
|
this.#stopAdvisorRuntime();
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { toError } from "@oh-my-pi/pi-utils";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
SessionStorage,
|
|
4
|
+
SessionStorageStat,
|
|
5
|
+
SessionStorageWriter,
|
|
6
|
+
WriteTextAtomicOptions,
|
|
7
|
+
} from "./session-storage";
|
|
3
8
|
import {
|
|
4
9
|
overlayTitleSlotContent,
|
|
5
10
|
overlayTitleSlotPrefix,
|
|
@@ -234,8 +239,40 @@ export class IndexedSessionStorage implements SessionStorage {
|
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
241
|
|
|
237
|
-
writeTextAtomic(path: string, content: string): Promise<void> {
|
|
238
|
-
|
|
242
|
+
async writeTextAtomic(path: string, content: string, options?: WriteTextAtomicOptions): Promise<void> {
|
|
243
|
+
const commitGuard = options?.commitGuard;
|
|
244
|
+
if (commitGuard && !commitGuard()) return;
|
|
245
|
+
await this.#awaitPath(path);
|
|
246
|
+
// A concurrent flushSync (writeTextSync) may have taken over during the
|
|
247
|
+
// awaitPath yield and bumped the epoch. Re-check before touching the
|
|
248
|
+
// index or enqueueing the backend publish.
|
|
249
|
+
if (commitGuard && !commitGuard()) return;
|
|
250
|
+
const previous = this.#index.get(path);
|
|
251
|
+
const mtimeMs = this.#allocMtimeMs();
|
|
252
|
+
const title = titleUpdateFromSlot(parseTitleSlotFromContent(content));
|
|
253
|
+
this.#setIndex(path, byteLength(content), mtimeMs, title ?? null);
|
|
254
|
+
try {
|
|
255
|
+
await this.#enqueuePath(
|
|
256
|
+
path,
|
|
257
|
+
async () => {
|
|
258
|
+
// Final guard immediately before the backend actually publishes.
|
|
259
|
+
// If a concurrent writer has advanced the index past our
|
|
260
|
+
// optimistic entry, leave that newer state alone; otherwise
|
|
261
|
+
// restore the pre-write snapshot so readers do not observe a
|
|
262
|
+
// body we never wrote.
|
|
263
|
+
if (commitGuard && !commitGuard()) {
|
|
264
|
+
const current = this.#index.get(path);
|
|
265
|
+
if (current?.mtimeMs === mtimeMs) this.#restoreIndex(path, previous);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
await this.#backend.writeFull(path, content, mtimeMs, title);
|
|
269
|
+
},
|
|
270
|
+
{ trackDrain: false },
|
|
271
|
+
);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
this.#restoreIndex(path, previous);
|
|
274
|
+
throw toError(err);
|
|
275
|
+
}
|
|
239
276
|
}
|
|
240
277
|
|
|
241
278
|
async rename(src: string, dst: string): Promise<void> {
|