@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2
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 +140 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +417 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/exec/bash-executor.ts +7 -5
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/bash-execution.ts +40 -11
- package/src/modes/components/custom-editor.ts +47 -47
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/python-execution.ts +2 -3
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tool-execution.ts +4 -5
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/command-controller.ts +0 -2
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +29 -23
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +19 -19
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +123 -30
- package/src/session/session-manager.ts +32 -31
- package/src/session/streaming-output.ts +87 -37
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interactive.ts +2 -6
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/python.ts +2 -2
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
calculateRateLimitBackoffMs,
|
|
47
47
|
getSupportedEfforts,
|
|
48
48
|
isContextOverflow,
|
|
49
|
+
isUsageLimitError,
|
|
49
50
|
modelsAreEqual,
|
|
50
51
|
parseRateLimitReason,
|
|
51
52
|
} from "@oh-my-pi/pi-ai";
|
|
@@ -224,6 +225,8 @@ export interface AgentSessionConfig {
|
|
|
224
225
|
mcpDiscoveryEnabled?: boolean;
|
|
225
226
|
/** MCP tool names to activate for the current session when discovery mode is enabled. */
|
|
226
227
|
initialSelectedMCPToolNames?: string[];
|
|
228
|
+
/** Whether constructor-provided MCP defaults should be persisted immediately. */
|
|
229
|
+
persistInitialMCPToolSelection?: boolean;
|
|
227
230
|
/** MCP server names whose tools should seed discovery-mode sessions whenever those servers are connected. */
|
|
228
231
|
defaultSelectedMCPServerNames?: string[];
|
|
229
232
|
/** MCP tool names that should seed brand-new sessions created from this AgentSession. */
|
|
@@ -363,6 +366,7 @@ export class AgentSession {
|
|
|
363
366
|
#followUpMessages: string[] = [];
|
|
364
367
|
/** Messages queued to be included with the next user prompt as context ("asides"). */
|
|
365
368
|
#pendingNextTurnMessages: CustomMessage[] = [];
|
|
369
|
+
#scheduledHiddenNextTurnGeneration: number | undefined = undefined;
|
|
366
370
|
#planModeState: PlanModeState | undefined;
|
|
367
371
|
#planReferenceSent = false;
|
|
368
372
|
#planReferencePath = "local://PLAN.md";
|
|
@@ -483,8 +487,11 @@ export class AgentSession {
|
|
|
483
487
|
this.#pruneSelectedMCPToolNames();
|
|
484
488
|
const persistedSelectedMCPToolNames = this.sessionManager.buildSessionContext().selectedMCPToolNames;
|
|
485
489
|
const currentSelectedMCPToolNames = this.getSelectedMCPToolNames();
|
|
490
|
+
const persistInitialMCPToolSelection =
|
|
491
|
+
config.persistInitialMCPToolSelection ?? this.sessionManager.getBranch().length === 0;
|
|
486
492
|
if (
|
|
487
493
|
this.#mcpDiscoveryEnabled &&
|
|
494
|
+
persistInitialMCPToolSelection &&
|
|
488
495
|
!this.#selectedMCPToolNamesMatch(persistedSelectedMCPToolNames, currentSelectedMCPToolNames)
|
|
489
496
|
) {
|
|
490
497
|
this.sessionManager.appendMCPToolSelection(currentSelectedMCPToolNames);
|
|
@@ -781,7 +788,6 @@ export class AgentSession {
|
|
|
781
788
|
attempt: this.#retryAttempt,
|
|
782
789
|
});
|
|
783
790
|
this.#retryAttempt = 0;
|
|
784
|
-
this.#resolveRetry();
|
|
785
791
|
}
|
|
786
792
|
}
|
|
787
793
|
|
|
@@ -857,6 +863,7 @@ export class AgentSession {
|
|
|
857
863
|
const didRetry = await this.#handleRetryableError(msg);
|
|
858
864
|
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
|
859
865
|
}
|
|
866
|
+
this.#resolveRetry();
|
|
860
867
|
|
|
861
868
|
if (msg.stopReason === "aborted" && this.#checkpointState) {
|
|
862
869
|
this.#checkpointState = undefined;
|
|
@@ -2566,6 +2573,74 @@ export class AgentSession {
|
|
|
2566
2573
|
});
|
|
2567
2574
|
}
|
|
2568
2575
|
|
|
2576
|
+
#queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
|
|
2577
|
+
this.#pendingNextTurnMessages.push(message);
|
|
2578
|
+
if (!triggerTurn) return;
|
|
2579
|
+
const generation = this.#promptGeneration;
|
|
2580
|
+
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
this.#scheduledHiddenNextTurnGeneration = generation;
|
|
2584
|
+
this.#schedulePostPromptTask(
|
|
2585
|
+
async () => {
|
|
2586
|
+
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
|
2587
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
2588
|
+
}
|
|
2589
|
+
if (this.#pendingNextTurnMessages.length === 0) {
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
try {
|
|
2593
|
+
await this.#promptQueuedHiddenNextTurnMessages();
|
|
2594
|
+
} catch {
|
|
2595
|
+
// Leave the hidden next-turn messages queued for the next explicit prompt.
|
|
2596
|
+
}
|
|
2597
|
+
},
|
|
2598
|
+
{
|
|
2599
|
+
generation,
|
|
2600
|
+
onSkip: () => {
|
|
2601
|
+
if (this.#scheduledHiddenNextTurnGeneration === generation) {
|
|
2602
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
2603
|
+
}
|
|
2604
|
+
},
|
|
2605
|
+
},
|
|
2606
|
+
);
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
|
|
2610
|
+
if (this.#pendingNextTurnMessages.length === 0) {
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
const queuedMessages = [...this.#pendingNextTurnMessages];
|
|
2615
|
+
this.#pendingNextTurnMessages = [];
|
|
2616
|
+
const message = queuedMessages[queuedMessages.length - 1];
|
|
2617
|
+
if (!message) {
|
|
2618
|
+
return;
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
const prependMessages = queuedMessages.slice(0, -1);
|
|
2622
|
+
const textContent = this.#getCustomMessageTextContent(message);
|
|
2623
|
+
try {
|
|
2624
|
+
await this.#promptWithMessage(message, textContent, {
|
|
2625
|
+
prependMessages,
|
|
2626
|
+
skipPostPromptRecoveryWait: true,
|
|
2627
|
+
});
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
|
|
2630
|
+
throw error;
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
#getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
|
|
2635
|
+
if (typeof message.content === "string") {
|
|
2636
|
+
return message.content;
|
|
2637
|
+
}
|
|
2638
|
+
return message.content
|
|
2639
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
2640
|
+
.map(content => content.text)
|
|
2641
|
+
.join("");
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2569
2644
|
/**
|
|
2570
2645
|
* Throw an error if the text is an extension command.
|
|
2571
2646
|
*/
|
|
@@ -2606,7 +2681,7 @@ export class AgentSession {
|
|
|
2606
2681
|
};
|
|
2607
2682
|
if (this.isStreaming) {
|
|
2608
2683
|
if (options?.deliverAs === "nextTurn") {
|
|
2609
|
-
this.#
|
|
2684
|
+
this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
|
|
2610
2685
|
return;
|
|
2611
2686
|
}
|
|
2612
2687
|
|
|
@@ -2618,6 +2693,22 @@ export class AgentSession {
|
|
|
2618
2693
|
return;
|
|
2619
2694
|
}
|
|
2620
2695
|
|
|
2696
|
+
if (options?.deliverAs === "nextTurn") {
|
|
2697
|
+
if (options?.triggerTurn) {
|
|
2698
|
+
await this.agent.prompt(appMessage);
|
|
2699
|
+
return;
|
|
2700
|
+
}
|
|
2701
|
+
this.agent.appendMessage(appMessage);
|
|
2702
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
2703
|
+
message.customType,
|
|
2704
|
+
message.content,
|
|
2705
|
+
message.display,
|
|
2706
|
+
message.details,
|
|
2707
|
+
message.attribution ?? "agent",
|
|
2708
|
+
);
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2621
2712
|
if (options?.triggerTurn) {
|
|
2622
2713
|
await this.agent.prompt(appMessage);
|
|
2623
2714
|
return;
|
|
@@ -2685,9 +2776,9 @@ export class AgentSession {
|
|
|
2685
2776
|
return { steering, followUp };
|
|
2686
2777
|
}
|
|
2687
2778
|
|
|
2688
|
-
/** Number of pending messages (includes
|
|
2779
|
+
/** Number of pending messages (includes steering, follow-up, and next-turn messages) */
|
|
2689
2780
|
get queuedMessageCount(): number {
|
|
2690
|
-
return this.#steeringMessages.length + this.#followUpMessages.length;
|
|
2781
|
+
return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
|
|
2691
2782
|
}
|
|
2692
2783
|
|
|
2693
2784
|
/** Get pending messages (read-only) */
|
|
@@ -2829,6 +2920,7 @@ export class AgentSession {
|
|
|
2829
2920
|
async abort(): Promise<void> {
|
|
2830
2921
|
this.abortRetry();
|
|
2831
2922
|
this.#promptGeneration++;
|
|
2923
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
2832
2924
|
this.#resolveTtsrResume();
|
|
2833
2925
|
this.#cancelPostPromptTasks();
|
|
2834
2926
|
this.agent.abort();
|
|
@@ -2878,6 +2970,7 @@ export class AgentSession {
|
|
|
2878
2970
|
this.#steeringMessages = [];
|
|
2879
2971
|
this.#followUpMessages = [];
|
|
2880
2972
|
this.#pendingNextTurnMessages = [];
|
|
2973
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
2881
2974
|
|
|
2882
2975
|
this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
|
|
2883
2976
|
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
@@ -3611,6 +3704,7 @@ export class AgentSession {
|
|
|
3611
3704
|
this.#steeringMessages = [];
|
|
3612
3705
|
this.#followUpMessages = [];
|
|
3613
3706
|
this.#pendingNextTurnMessages = [];
|
|
3707
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
3614
3708
|
this.#todoReminderCount = 0;
|
|
3615
3709
|
|
|
3616
3710
|
// Inject the handoff document as a custom message
|
|
@@ -4278,7 +4372,9 @@ export class AgentSession {
|
|
|
4278
4372
|
const shouldRetry =
|
|
4279
4373
|
retrySettings.enabled &&
|
|
4280
4374
|
attempt < retrySettings.maxRetries &&
|
|
4281
|
-
(retryAfterMs !== undefined ||
|
|
4375
|
+
(retryAfterMs !== undefined ||
|
|
4376
|
+
this.#isTransientErrorMessage(message) ||
|
|
4377
|
+
isUsageLimitError(message));
|
|
4282
4378
|
if (!shouldRetry) {
|
|
4283
4379
|
lastError = error;
|
|
4284
4380
|
break;
|
|
@@ -4476,8 +4572,9 @@ export class AgentSession {
|
|
|
4476
4572
|
// =========================================================================
|
|
4477
4573
|
|
|
4478
4574
|
/**
|
|
4479
|
-
* Check if an error is retryable (
|
|
4575
|
+
* Check if an error is retryable (transient errors or usage limits).
|
|
4480
4576
|
* Context overflow errors are NOT retryable (handled by compaction instead).
|
|
4577
|
+
* Usage-limit errors are retryable because the retry handler performs credential switching.
|
|
4481
4578
|
*/
|
|
4482
4579
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
4483
4580
|
if (message.stopReason !== "error" || !message.errorMessage) return false;
|
|
@@ -4487,20 +4584,17 @@ export class AgentSession {
|
|
|
4487
4584
|
if (isContextOverflow(message, contextWindow)) return false;
|
|
4488
4585
|
|
|
4489
4586
|
const err = message.errorMessage;
|
|
4490
|
-
return this.#
|
|
4587
|
+
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
|
4491
4588
|
}
|
|
4492
4589
|
|
|
4493
|
-
#
|
|
4494
|
-
// Match: overloaded_error,
|
|
4495
|
-
|
|
4590
|
+
#isTransientErrorMessage(errorMessage: string): boolean {
|
|
4591
|
+
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
|
4592
|
+
// service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
|
|
4593
|
+
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
|
|
4496
4594
|
errorMessage,
|
|
4497
4595
|
);
|
|
4498
4596
|
}
|
|
4499
4597
|
|
|
4500
|
-
#isUsageLimitErrorMessage(errorMessage: string): boolean {
|
|
4501
|
-
return /usage.?limit|usage_limit_reached|limit_reached|quota.?exceeded|resource.?exhausted/i.test(errorMessage);
|
|
4502
|
-
}
|
|
4503
|
-
|
|
4504
4598
|
#parseRetryAfterMsFromError(errorMessage: string): number | undefined {
|
|
4505
4599
|
const now = Date.now();
|
|
4506
4600
|
const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
|
|
@@ -4582,7 +4676,7 @@ export class AgentSession {
|
|
|
4582
4676
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
4583
4677
|
let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
|
|
4584
4678
|
|
|
4585
|
-
if (this.model &&
|
|
4679
|
+
if (this.model && isUsageLimitError(errorMessage)) {
|
|
4586
4680
|
const retryAfterMs =
|
|
4587
4681
|
this.#parseRetryAfterMsFromError(errorMessage) ??
|
|
4588
4682
|
calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
@@ -4960,6 +5054,7 @@ export class AgentSession {
|
|
|
4960
5054
|
this.#steeringMessages = [];
|
|
4961
5055
|
this.#followUpMessages = [];
|
|
4962
5056
|
this.#pendingNextTurnMessages = [];
|
|
5057
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
4963
5058
|
|
|
4964
5059
|
// Flush pending writes before switching
|
|
4965
5060
|
await this.sessionManager.flush();
|
|
@@ -5003,21 +5098,18 @@ export class AgentSession {
|
|
|
5003
5098
|
const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
|
|
5004
5099
|
const hasServiceTierEntry = this.sessionManager.getBranch().some(entry => entry.type === "service_tier_change");
|
|
5005
5100
|
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
this.
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
} else {
|
|
5019
|
-
this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
|
|
5020
|
-
}
|
|
5101
|
+
const configuredServiceTier = this.settings.get("serviceTier");
|
|
5102
|
+
const nextThinkingLevel = resolveThinkingLevelForModel(
|
|
5103
|
+
this.model,
|
|
5104
|
+
hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
|
|
5105
|
+
);
|
|
5106
|
+
this.#thinkingLevel = nextThinkingLevel;
|
|
5107
|
+
this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
|
|
5108
|
+
this.agent.serviceTier = hasServiceTierEntry
|
|
5109
|
+
? sessionContext.serviceTier
|
|
5110
|
+
: configuredServiceTier === "none"
|
|
5111
|
+
? undefined
|
|
5112
|
+
: configuredServiceTier;
|
|
5021
5113
|
|
|
5022
5114
|
this.#reconnectToAgent();
|
|
5023
5115
|
return true;
|
|
@@ -5059,6 +5151,7 @@ export class AgentSession {
|
|
|
5059
5151
|
|
|
5060
5152
|
// Clear pending messages (bound to old session state)
|
|
5061
5153
|
this.#pendingNextTurnMessages = [];
|
|
5154
|
+
this.#scheduledHiddenNextTurnGeneration = undefined;
|
|
5062
5155
|
|
|
5063
5156
|
// Flush pending writes before branching
|
|
5064
5157
|
await this.sessionManager.flush();
|
|
@@ -1302,21 +1302,19 @@ async function collectSessionsFromFiles(files: string[], storage: SessionStorage
|
|
|
1302
1302
|
}
|
|
1303
1303
|
}
|
|
1304
1304
|
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
});
|
|
1319
|
-
}
|
|
1305
|
+
const stats = storage.statSync(file);
|
|
1306
|
+
sessions.push({
|
|
1307
|
+
path: file,
|
|
1308
|
+
id: header.id,
|
|
1309
|
+
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
1310
|
+
title: header.title ?? shortSummary,
|
|
1311
|
+
parentSessionPath: (header as SessionHeader).parentSession,
|
|
1312
|
+
created: new Date(header.timestamp),
|
|
1313
|
+
modified: stats.mtime,
|
|
1314
|
+
messageCount,
|
|
1315
|
+
firstMessage: firstMessage || "(no messages)",
|
|
1316
|
+
allMessagesText: allMessages.join(" "),
|
|
1317
|
+
});
|
|
1320
1318
|
} catch {}
|
|
1321
1319
|
}),
|
|
1322
1320
|
);
|
|
@@ -1381,6 +1379,7 @@ export class SessionManager {
|
|
|
1381
1379
|
#sessionName: string | undefined;
|
|
1382
1380
|
#sessionFile: string | undefined;
|
|
1383
1381
|
#flushed: boolean = false;
|
|
1382
|
+
#needsFullRewriteOnNextPersist: boolean = false;
|
|
1384
1383
|
#fileEntries: FileEntry[] = [];
|
|
1385
1384
|
#byId: Map<string, SessionEntry> = new Map();
|
|
1386
1385
|
#labelsById: Map<string, string> = new Map();
|
|
@@ -1443,9 +1442,7 @@ export class SessionManager {
|
|
|
1443
1442
|
this.#sessionId = header?.id ?? Snowflake.next();
|
|
1444
1443
|
this.#sessionName = header?.title;
|
|
1445
1444
|
|
|
1446
|
-
|
|
1447
|
-
await this.#rewriteFile();
|
|
1448
|
-
}
|
|
1445
|
+
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
1449
1446
|
|
|
1450
1447
|
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
|
|
1451
1448
|
|
|
@@ -1632,6 +1629,7 @@ export class SessionManager {
|
|
|
1632
1629
|
this.#labelsById.clear();
|
|
1633
1630
|
this.#leafId = null;
|
|
1634
1631
|
this.#flushed = false;
|
|
1632
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
1635
1633
|
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
1636
1634
|
|
|
1637
1635
|
if (this.persist) {
|
|
@@ -1774,6 +1772,7 @@ export class SessionManager {
|
|
|
1774
1772
|
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
1775
1773
|
);
|
|
1776
1774
|
await this.#writeEntriesAtomically(entries);
|
|
1775
|
+
this.#needsFullRewriteOnNextPersist = false;
|
|
1777
1776
|
this.#flushed = true;
|
|
1778
1777
|
});
|
|
1779
1778
|
}
|
|
@@ -1782,6 +1781,16 @@ export class SessionManager {
|
|
|
1782
1781
|
return this.persist;
|
|
1783
1782
|
}
|
|
1784
1783
|
|
|
1784
|
+
/**
|
|
1785
|
+
* Force-persist all current entries to disk, even when no assistant message exists yet.
|
|
1786
|
+
* Used by ACP mode where session/new must create a discoverable session immediately.
|
|
1787
|
+
*/
|
|
1788
|
+
async ensureOnDisk(): Promise<void> {
|
|
1789
|
+
if (!this.persist || !this.#sessionFile) return;
|
|
1790
|
+
if (this.#flushed && !this.#needsFullRewriteOnNextPersist) return;
|
|
1791
|
+
await this.#rewriteFile();
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1785
1794
|
/** Flush pending writes to disk. Call before switching sessions or on shutdown. */
|
|
1786
1795
|
async flush(): Promise<void> {
|
|
1787
1796
|
await this.#queuePersistTask(async () => {
|
|
@@ -1911,23 +1920,15 @@ export class SessionManager {
|
|
|
1911
1920
|
|
|
1912
1921
|
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
1913
1922
|
if (!hasAssistant) {
|
|
1914
|
-
// Mark as not flushed so when assistant arrives, all entries get written
|
|
1923
|
+
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
1915
1924
|
this.#flushed = false;
|
|
1916
1925
|
return;
|
|
1917
1926
|
}
|
|
1918
1927
|
|
|
1919
|
-
if (!this.#flushed) {
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
if (!writer) return;
|
|
1924
|
-
const entries = await Promise.all(
|
|
1925
|
-
this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)),
|
|
1926
|
-
);
|
|
1927
|
-
for (const persistedEntry of entries) {
|
|
1928
|
-
await writer.write(persistedEntry);
|
|
1929
|
-
}
|
|
1930
|
-
});
|
|
1928
|
+
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
1929
|
+
// Full flush: rewrite the entire file atomically to avoid
|
|
1930
|
+
// duplicating entries if the file already exists (e.g. from ensureOnDisk).
|
|
1931
|
+
void this.#rewriteFile();
|
|
1931
1932
|
} else {
|
|
1932
1933
|
void this.#queuePersistTask(async () => {
|
|
1933
1934
|
const writer = this.#ensurePersistWriter();
|
|
@@ -32,6 +32,8 @@ export interface OutputSinkOptions {
|
|
|
32
32
|
artifactId?: string;
|
|
33
33
|
spillThreshold?: number;
|
|
34
34
|
onChunk?: (chunk: string) => void;
|
|
35
|
+
/** Minimum ms between onChunk calls. 0 = every chunk (default). */
|
|
36
|
+
chunkThrottleMs?: number;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface TruncationResult {
|
|
@@ -521,6 +523,7 @@ export class OutputSink {
|
|
|
521
523
|
#totalBytes = 0;
|
|
522
524
|
#sawData = false;
|
|
523
525
|
#truncated = false;
|
|
526
|
+
#lastChunkTime = 0;
|
|
524
527
|
|
|
525
528
|
#file?: {
|
|
526
529
|
path: string;
|
|
@@ -528,22 +531,46 @@ export class OutputSink {
|
|
|
528
531
|
sink: Bun.FileSink;
|
|
529
532
|
};
|
|
530
533
|
|
|
534
|
+
// Queue of chunks waiting for the file sink to be created.
|
|
535
|
+
#pendingFileWrites?: string[];
|
|
536
|
+
#fileReady = false;
|
|
537
|
+
|
|
531
538
|
readonly #artifactPath?: string;
|
|
532
539
|
readonly #artifactId?: string;
|
|
533
540
|
readonly #spillThreshold: number;
|
|
534
541
|
readonly #onChunk?: (chunk: string) => void;
|
|
542
|
+
readonly #chunkThrottleMs: number;
|
|
535
543
|
|
|
536
544
|
constructor(options?: OutputSinkOptions) {
|
|
537
|
-
const {
|
|
545
|
+
const {
|
|
546
|
+
artifactPath,
|
|
547
|
+
artifactId,
|
|
548
|
+
spillThreshold = DEFAULT_MAX_BYTES,
|
|
549
|
+
onChunk,
|
|
550
|
+
chunkThrottleMs = 0,
|
|
551
|
+
} = options ?? {};
|
|
538
552
|
this.#artifactPath = artifactPath;
|
|
539
553
|
this.#artifactId = artifactId;
|
|
540
554
|
this.#spillThreshold = spillThreshold;
|
|
541
555
|
this.#onChunk = onChunk;
|
|
556
|
+
this.#chunkThrottleMs = chunkThrottleMs;
|
|
542
557
|
}
|
|
543
558
|
|
|
544
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Push a chunk of output. The buffer management and onChunk callback run
|
|
561
|
+
* synchronously. File sink writes are deferred and serialized internally.
|
|
562
|
+
*/
|
|
563
|
+
push(chunk: string): void {
|
|
545
564
|
chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
|
|
546
|
-
|
|
565
|
+
|
|
566
|
+
// Throttled onChunk: only call the callback when enough time has passed.
|
|
567
|
+
if (this.#onChunk) {
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
|
|
570
|
+
this.#lastChunkTime = now;
|
|
571
|
+
this.#onChunk(chunk);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
547
574
|
|
|
548
575
|
const dataBytes = Buffer.byteLength(chunk, "utf-8");
|
|
549
576
|
this.#totalBytes += dataBytes;
|
|
@@ -556,10 +583,9 @@ export class OutputSink {
|
|
|
556
583
|
const threshold = this.#spillThreshold;
|
|
557
584
|
const willOverflow = this.#bufferBytes + dataBytes > threshold;
|
|
558
585
|
|
|
559
|
-
// Write to file if
|
|
560
|
-
if (this.#file != null || willOverflow) {
|
|
561
|
-
|
|
562
|
-
await sink?.write(chunk);
|
|
586
|
+
// Write to artifact file if configured and past the threshold
|
|
587
|
+
if (this.#artifactPath && (this.#file != null || willOverflow)) {
|
|
588
|
+
this.#writeToFile(chunk);
|
|
563
589
|
}
|
|
564
590
|
|
|
565
591
|
if (!willOverflow) {
|
|
@@ -589,14 +615,64 @@ export class OutputSink {
|
|
|
589
615
|
if (this.#file) this.#truncated = true;
|
|
590
616
|
}
|
|
591
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Write a chunk to the artifact file. Handles the async file sink creation
|
|
620
|
+
* by queuing writes until the sink is ready, then draining synchronously.
|
|
621
|
+
*/
|
|
622
|
+
#writeToFile(chunk: string): void {
|
|
623
|
+
if (this.#fileReady && this.#file) {
|
|
624
|
+
// Fast path: file sink exists, write synchronously
|
|
625
|
+
this.#file.sink.write(chunk);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// File sink not yet created — queue this chunk and kick off creation
|
|
629
|
+
if (!this.#pendingFileWrites) {
|
|
630
|
+
this.#pendingFileWrites = [chunk];
|
|
631
|
+
void this.#createFileSink();
|
|
632
|
+
} else {
|
|
633
|
+
this.#pendingFileWrites.push(chunk);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async #createFileSink(): Promise<void> {
|
|
638
|
+
if (!this.#artifactPath || this.#fileReady) return;
|
|
639
|
+
try {
|
|
640
|
+
const sink = Bun.file(this.#artifactPath).writer();
|
|
641
|
+
this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
|
|
642
|
+
|
|
643
|
+
// Flush existing buffer to file BEFORE it gets trimmed further.
|
|
644
|
+
if (this.#buffer.length > 0) {
|
|
645
|
+
sink.write(this.#buffer);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Drain any chunks that arrived while the sink was being created
|
|
649
|
+
if (this.#pendingFileWrites) {
|
|
650
|
+
for (const pending of this.#pendingFileWrites) {
|
|
651
|
+
sink.write(pending);
|
|
652
|
+
}
|
|
653
|
+
this.#pendingFileWrites = undefined;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
this.#fileReady = true;
|
|
657
|
+
} catch {
|
|
658
|
+
try {
|
|
659
|
+
await this.#file?.sink?.end();
|
|
660
|
+
} catch {
|
|
661
|
+
/* ignore */
|
|
662
|
+
}
|
|
663
|
+
this.#file = undefined;
|
|
664
|
+
this.#pendingFileWrites = undefined;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
592
668
|
createInput(): WritableStream<Uint8Array | string> {
|
|
593
669
|
const dec = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
594
|
-
const finalize =
|
|
595
|
-
|
|
670
|
+
const finalize = () => {
|
|
671
|
+
this.push(dec.decode());
|
|
596
672
|
};
|
|
597
673
|
return new WritableStream({
|
|
598
|
-
write:
|
|
599
|
-
|
|
674
|
+
write: chunk => {
|
|
675
|
+
this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
|
|
600
676
|
},
|
|
601
677
|
close: finalize,
|
|
602
678
|
abort: finalize,
|
|
@@ -620,32 +696,6 @@ export class OutputSink {
|
|
|
620
696
|
artifactId: this.#file?.artifactId,
|
|
621
697
|
};
|
|
622
698
|
}
|
|
623
|
-
|
|
624
|
-
// -- private ---------------------------------------------------------------
|
|
625
|
-
|
|
626
|
-
async #ensureFileSink(): Promise<Bun.FileSink | null> {
|
|
627
|
-
if (!this.#artifactPath) return null;
|
|
628
|
-
if (this.#file) return this.#file.sink;
|
|
629
|
-
|
|
630
|
-
try {
|
|
631
|
-
const sink = Bun.file(this.#artifactPath).writer();
|
|
632
|
-
this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
|
|
633
|
-
|
|
634
|
-
// Flush existing buffer to file BEFORE it gets trimmed further.
|
|
635
|
-
if (this.#buffer.length > 0) {
|
|
636
|
-
await sink.write(this.#buffer);
|
|
637
|
-
}
|
|
638
|
-
return sink;
|
|
639
|
-
} catch {
|
|
640
|
-
try {
|
|
641
|
-
await this.#file?.sink?.end();
|
|
642
|
-
} catch {
|
|
643
|
-
/* ignore */
|
|
644
|
-
}
|
|
645
|
-
this.#file = undefined;
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
699
|
}
|
|
650
700
|
|
|
651
701
|
// =============================================================================
|