@oh-my-pi/pi-coding-agent 16.2.3 → 16.2.5
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 +31 -0
- package/dist/cli.js +3449 -3438
- 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/components/status-line/types.d.ts +10 -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/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +7 -0
- package/dist/types/session/session-manager.d.ts +2 -3
- package/dist/types/task/provider-concurrency.d.ts +40 -0
- package/dist/types/utils/git.d.ts +11 -0
- package/package.json +12 -12
- package/src/autolearn/controller.ts +13 -22
- package/src/cli/update-cli.ts +254 -0
- package/src/cli/worktree-cli.ts +8 -5
- package/src/collab/host.ts +13 -8
- package/src/collab/replication-shrink.ts +111 -0
- package/src/config/model-discovery.ts +33 -13
- package/src/config/provider-globals.ts +25 -0
- package/src/config/settings-schema.ts +5 -2
- 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 +35 -2
- package/src/modes/components/status-line/segments.ts +22 -8
- package/src/modes/components/status-line/types.ts +7 -0
- package/src/modes/controllers/command-controller.ts +5 -35
- package/src/modes/controllers/event-controller.ts +7 -1
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +9 -20
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +2 -1
- package/src/prompts/goals/goal-todo-context.md +12 -0
- package/src/sdk.ts +13 -5
- package/src/session/agent-session.ts +129 -14
- package/src/session/session-manager.ts +2 -3
- package/src/slash-commands/builtin-registry.ts +7 -21
- package/src/task/executor.ts +4 -62
- package/src/task/provider-concurrency.ts +100 -0
- package/src/task/worktree.ts +13 -4
- package/src/task/yield-assembly.ts +27 -39
- package/src/tools/read.ts +11 -1
- package/src/utils/git.ts +13 -0
- package/src/prompts/system/autolearn-nudge.md +0 -5
|
@@ -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" };
|
|
@@ -533,6 +534,13 @@ export interface AgentSessionConfig {
|
|
|
533
534
|
* inherits this so its requests undergo the same shaping as the main turn.
|
|
534
535
|
*/
|
|
535
536
|
transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
|
|
537
|
+
/**
|
|
538
|
+
* Stream wrapper passed to side-channel requests (`/btw`, `/omfg`, IRC
|
|
539
|
+
* auto-replies, and handoff generation) so they apply the same provider
|
|
540
|
+
* shaping and host-level request wrappers as normal agent turns. Defaults
|
|
541
|
+
* to plain `streamSimple` when omitted.
|
|
542
|
+
*/
|
|
543
|
+
sideStreamFn?: StreamFn;
|
|
536
544
|
/**
|
|
537
545
|
* Stream wrapper passed to the advisor agent so its requests apply the
|
|
538
546
|
* session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
|
|
@@ -1458,6 +1466,7 @@ export class AgentSession {
|
|
|
1458
1466
|
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
|
|
1459
1467
|
#onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
|
|
1460
1468
|
#transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
|
|
1469
|
+
#sideStreamFn: StreamFn;
|
|
1461
1470
|
#advisorStreamFn: StreamFn | undefined;
|
|
1462
1471
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
1463
1472
|
#rebuildSystemPrompt:
|
|
@@ -1787,6 +1796,7 @@ export class AgentSession {
|
|
|
1787
1796
|
this.#requestedToolNames = config.requestedToolNames;
|
|
1788
1797
|
this.#transformContext = config.transformContext ?? (messages => messages);
|
|
1789
1798
|
this.#transformProviderContext = config.transformProviderContext;
|
|
1799
|
+
this.#sideStreamFn = config.sideStreamFn ?? streamSimple;
|
|
1790
1800
|
this.#advisorStreamFn = config.advisorStreamFn;
|
|
1791
1801
|
this.#onPayload = config.onPayload;
|
|
1792
1802
|
this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
|
|
@@ -6468,16 +6478,46 @@ export class AgentSession {
|
|
|
6468
6478
|
#buildGoalModeMessage(): CustomMessage | null {
|
|
6469
6479
|
const content = this.#goalRuntime.buildActivePrompt();
|
|
6470
6480
|
if (!content) return null;
|
|
6481
|
+
const todoContext = this.#buildGoalTodoContext();
|
|
6471
6482
|
return {
|
|
6472
6483
|
role: "custom",
|
|
6473
6484
|
customType: "goal-mode-context",
|
|
6474
|
-
content,
|
|
6485
|
+
content: todoContext ? `${content}\n\n${todoContext}` : content,
|
|
6475
6486
|
display: false,
|
|
6476
6487
|
attribution: "agent",
|
|
6477
6488
|
timestamp: Date.now(),
|
|
6478
6489
|
};
|
|
6479
6490
|
}
|
|
6480
6491
|
|
|
6492
|
+
#buildGoalTodoContext(): string | undefined {
|
|
6493
|
+
if (!this.settings.get("todo.enabled")) return undefined;
|
|
6494
|
+
const phases = this.getTodoPhases().filter(phase => phase.tasks.length > 0);
|
|
6495
|
+
if (phases.length === 0) return undefined;
|
|
6496
|
+
|
|
6497
|
+
let total = 0;
|
|
6498
|
+
let closed = 0;
|
|
6499
|
+
let open = 0;
|
|
6500
|
+
const promptPhases = phases.map(phase => ({
|
|
6501
|
+
name: phase.name,
|
|
6502
|
+
tasks: phase.tasks.map(task => {
|
|
6503
|
+
total++;
|
|
6504
|
+
if (task.status === "completed" || task.status === "abandoned") {
|
|
6505
|
+
closed++;
|
|
6506
|
+
} else {
|
|
6507
|
+
open++;
|
|
6508
|
+
}
|
|
6509
|
+
return { content: task.content, status: task.status };
|
|
6510
|
+
}),
|
|
6511
|
+
}));
|
|
6512
|
+
|
|
6513
|
+
return prompt.render(goalTodoContextPrompt, {
|
|
6514
|
+
closed: String(closed),
|
|
6515
|
+
open: String(open),
|
|
6516
|
+
phases: promptPhases,
|
|
6517
|
+
total: String(total),
|
|
6518
|
+
});
|
|
6519
|
+
}
|
|
6520
|
+
|
|
6481
6521
|
#normalizeImagesForModel(images: ImageContent[] | undefined): Promise<ImageContent[] | undefined> {
|
|
6482
6522
|
return normalizeModelContextImages(images, { model: this.model });
|
|
6483
6523
|
}
|
|
@@ -9164,6 +9204,10 @@ export class AgentSession {
|
|
|
9164
9204
|
model,
|
|
9165
9205
|
{
|
|
9166
9206
|
streamOptions: handoffStreamOptions,
|
|
9207
|
+
completeImpl: async (requestModel, requestContext, requestOptions) => {
|
|
9208
|
+
const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
|
|
9209
|
+
return stream.result();
|
|
9210
|
+
},
|
|
9167
9211
|
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
9168
9212
|
// Honor the user's /model thinking selection on the handoff path.
|
|
9169
9213
|
// Clamped per-model inside generateHandoffFromContext via
|
|
@@ -9451,16 +9495,18 @@ export class AgentSession {
|
|
|
9451
9495
|
const errorIsFromBeforeCompaction =
|
|
9452
9496
|
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
|
9453
9497
|
if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
|
|
9454
|
-
//
|
|
9455
|
-
//
|
|
9456
|
-
|
|
9457
|
-
|
|
9458
|
-
|
|
9459
|
-
|
|
9498
|
+
// Clear the failed turn from active context so the retry (or the next
|
|
9499
|
+
// user prompt) does not replay it. The persisted branch entry stays
|
|
9500
|
+
// for now: when no recovery path runs, the user-facing transcript
|
|
9501
|
+
// MUST keep the only assistant message explaining why the turn
|
|
9502
|
+
// stopped. The branch entry is dropped further down, but only on the
|
|
9503
|
+
// paths that actually schedule a retry/compaction.
|
|
9504
|
+
this.#removeAssistantMessageFromActiveContext(assistantMessage);
|
|
9460
9505
|
|
|
9461
9506
|
// Try context promotion first - switch to a larger model and retry without compacting
|
|
9462
9507
|
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
9463
9508
|
if (promoted) {
|
|
9509
|
+
await this.#dropPersistedAssistantTurn(assistantMessage);
|
|
9464
9510
|
// Retry on the promoted (larger) model without compacting
|
|
9465
9511
|
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
9466
9512
|
return COMPACTION_CHECK_CONTINUATION;
|
|
@@ -9469,7 +9515,9 @@ export class AgentSession {
|
|
|
9469
9515
|
// No promotion target available fall through to compaction
|
|
9470
9516
|
const compactionSettings = this.settings.getGroup("compaction");
|
|
9471
9517
|
if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
|
|
9472
|
-
return await this.#
|
|
9518
|
+
return await this.#runRecoveryCompactionWithRollback("overflow", assistantMessage, allowDefer, {
|
|
9519
|
+
autoContinue,
|
|
9520
|
+
});
|
|
9473
9521
|
}
|
|
9474
9522
|
return COMPACTION_CHECK_NONE;
|
|
9475
9523
|
}
|
|
@@ -9481,13 +9529,14 @@ export class AgentSession {
|
|
|
9481
9529
|
// otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
|
|
9482
9530
|
// allow the handoff strategy to actually run.
|
|
9483
9531
|
if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9532
|
+
// Same active-context vs persisted-history split as the overflow path
|
|
9533
|
+
// above: clear the dead turn from agent state so it cannot be replayed,
|
|
9534
|
+
// but keep it on the branch unless promotion or compaction actually runs.
|
|
9535
|
+
this.#removeAssistantMessageFromActiveContext(assistantMessage);
|
|
9488
9536
|
|
|
9489
9537
|
const promoted = await this.#tryContextPromotion(assistantMessage);
|
|
9490
9538
|
if (promoted) {
|
|
9539
|
+
await this.#dropPersistedAssistantTurn(assistantMessage);
|
|
9491
9540
|
logger.debug("Context promotion triggered by response.incomplete (length stop)", {
|
|
9492
9541
|
from: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
9493
9542
|
});
|
|
@@ -9501,7 +9550,7 @@ export class AgentSession {
|
|
|
9501
9550
|
model: `${assistantMessage.provider}/${assistantMessage.model}`,
|
|
9502
9551
|
strategy: incompleteCompactionSettings.strategy,
|
|
9503
9552
|
});
|
|
9504
|
-
return await this.#
|
|
9553
|
+
return await this.#runRecoveryCompactionWithRollback("incomplete", assistantMessage, allowDefer, {
|
|
9505
9554
|
autoContinue,
|
|
9506
9555
|
triggerContextTokens: calculateContextTokens(assistantMessage.usage),
|
|
9507
9556
|
});
|
|
@@ -9766,6 +9815,54 @@ export class AgentSession {
|
|
|
9766
9815
|
}
|
|
9767
9816
|
}
|
|
9768
9817
|
|
|
9818
|
+
/**
|
|
9819
|
+
* Drop a recoverable assistant turn from the persisted session branch once a
|
|
9820
|
+
* recovery path (context promotion or compaction) is committed. Waits for the
|
|
9821
|
+
* in-flight `message_end` persistence slot first so the branch entry exists
|
|
9822
|
+
* before we reparent past it. Active context removal is the caller's
|
|
9823
|
+
* responsibility — recovery paths clear it eagerly so the retry never
|
|
9824
|
+
* replays the failed turn, while no-recovery paths leave the persisted entry
|
|
9825
|
+
* (and the user-visible transcript line) in place.
|
|
9826
|
+
*/
|
|
9827
|
+
async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise<void> {
|
|
9828
|
+
await this.#waitForSessionMessagePersistence(assistantMessage);
|
|
9829
|
+
this.#discardAssistantTurn(assistantMessage);
|
|
9830
|
+
}
|
|
9831
|
+
|
|
9832
|
+
/**
|
|
9833
|
+
* Drop the failed assistant turn from persisted history, run
|
|
9834
|
+
* {@link #runAutoCompaction} for an `overflow` / `incomplete` recovery, and
|
|
9835
|
+
* restore the assistant entry if compaction did not actually commit
|
|
9836
|
+
* anything (no usable model/preparation, hook cancel, or compaction error).
|
|
9837
|
+
*
|
|
9838
|
+
* Compaction has to see a clean branch — otherwise its `prepareCompaction`
|
|
9839
|
+
* pass would keep the failed turn in the kept region and the retry would
|
|
9840
|
+
* replay it. But a NONE return that was not paired with a fresh compaction
|
|
9841
|
+
* summary means no recovery is in progress, and leaving the branch
|
|
9842
|
+
* reparented would erase the only user-visible explanation for why the turn
|
|
9843
|
+
* stopped. Reverting the drop in that case preserves the transcript while
|
|
9844
|
+
* still letting a real recovery path own the rewrite.
|
|
9845
|
+
*/
|
|
9846
|
+
async #runRecoveryCompactionWithRollback(
|
|
9847
|
+
reason: "overflow" | "incomplete",
|
|
9848
|
+
assistantMessage: AssistantMessage,
|
|
9849
|
+
allowDefer: boolean,
|
|
9850
|
+
options: { autoContinue: boolean; triggerContextTokens?: number },
|
|
9851
|
+
): Promise<CompactionCheckResult> {
|
|
9852
|
+
const compactionEntryBefore = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
9853
|
+
await this.#dropPersistedAssistantTurn(assistantMessage);
|
|
9854
|
+
const result = await this.#runAutoCompaction(reason, true, false, allowDefer, options);
|
|
9855
|
+
const recoveryCommitted =
|
|
9856
|
+
result.continuationScheduled || result.deferredHandoff || result.automaticContinuationBlocked === true;
|
|
9857
|
+
if (!recoveryCommitted) {
|
|
9858
|
+
const compactionEntryAfter = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
9859
|
+
if (compactionEntryAfter === compactionEntryBefore) {
|
|
9860
|
+
this.sessionManager.appendMessage(assistantMessage);
|
|
9861
|
+
}
|
|
9862
|
+
}
|
|
9863
|
+
return result;
|
|
9864
|
+
}
|
|
9865
|
+
|
|
9769
9866
|
/**
|
|
9770
9867
|
* Drop an assistant turn from BOTH the live agent context and the persisted
|
|
9771
9868
|
* session branch (reparenting the leaf to the turn's parent), so a discarded
|
|
@@ -10663,6 +10760,18 @@ export class AgentSession {
|
|
|
10663
10760
|
tools: this.agent.state.tools,
|
|
10664
10761
|
sessionId: this.sessionId,
|
|
10665
10762
|
promptCacheKey: this.sessionId,
|
|
10763
|
+
// Route every summarization HTTP request through the
|
|
10764
|
+
// session's side-stream transport so the provider
|
|
10765
|
+
// concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
|
|
10766
|
+
// brackets compaction the same way it brackets the live
|
|
10767
|
+
// agent turn — without this, multiple ollama-cloud
|
|
10768
|
+
// subagents auto/manually compacting issued uncapped
|
|
10769
|
+
// summary requests in parallel (chatgpt-codex review on
|
|
10770
|
+
// #3751).
|
|
10771
|
+
completeImpl: async (requestModel, requestContext, requestOptions) => {
|
|
10772
|
+
const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
|
|
10773
|
+
return stream.result();
|
|
10774
|
+
},
|
|
10666
10775
|
},
|
|
10667
10776
|
);
|
|
10668
10777
|
} catch (error) {
|
|
@@ -12873,7 +12982,7 @@ export class AgentSession {
|
|
|
12873
12982
|
let providerReplyText = "";
|
|
12874
12983
|
let emittedReplyText = "";
|
|
12875
12984
|
let assistantMessage: AssistantMessage | undefined;
|
|
12876
|
-
const stream =
|
|
12985
|
+
const stream = await this.#sideStreamFn(model, obfuscateProviderContext(this.#obfuscator, context), options);
|
|
12877
12986
|
for await (const event of stream) {
|
|
12878
12987
|
if (event.type === "text_delta") {
|
|
12879
12988
|
providerReplyText += event.delta;
|
|
@@ -13491,6 +13600,12 @@ export class AgentSession {
|
|
|
13491
13600
|
metadata: this.agent.metadataForProvider(model.provider),
|
|
13492
13601
|
convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
|
|
13493
13602
|
telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
|
|
13603
|
+
// Same per-provider concurrency cap rationale as the compaction
|
|
13604
|
+
// path above (chatgpt-codex review on #3751).
|
|
13605
|
+
completeImpl: async (requestModel, requestContext, requestOptions) => {
|
|
13606
|
+
const stream = await this.#sideStreamFn(requestModel, requestContext, requestOptions);
|
|
13607
|
+
return stream.result();
|
|
13608
|
+
},
|
|
13494
13609
|
});
|
|
13495
13610
|
this.#branchSummaryAbortController = undefined;
|
|
13496
13611
|
if (result.aborted) {
|
|
@@ -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);
|
|
@@ -6,6 +6,7 @@ import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
|
|
|
6
6
|
import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
|
|
8
8
|
import { CollabHost } from "../collab/host";
|
|
9
|
+
import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
|
9
10
|
import type { SettingPath, SettingValue } from "../config/settings";
|
|
10
11
|
import { settings } from "../config/settings";
|
|
11
12
|
import {
|
|
@@ -29,7 +30,6 @@ import type { InteractiveModeContext } from "../modes/types";
|
|
|
29
30
|
import type { AgentSession, FreshSessionResult } from "../session/agent-session";
|
|
30
31
|
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
|
|
31
32
|
import { resolveResumableSession } from "../session/session-listing";
|
|
32
|
-
import { SessionManager } from "../session/session-manager";
|
|
33
33
|
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
|
34
34
|
import { expandTilde, resolveToCwd } from "../tools/path-utils";
|
|
35
35
|
import { urlHyperlinkAlways } from "../tui";
|
|
@@ -1606,8 +1606,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1606
1606
|
},
|
|
1607
1607
|
{
|
|
1608
1608
|
name: "move",
|
|
1609
|
-
description: "
|
|
1610
|
-
acpDescription: "
|
|
1609
|
+
description: "Move the current session to a different directory",
|
|
1610
|
+
acpDescription: "Move the current session to a different directory",
|
|
1611
1611
|
inlineHint: "[<path>]",
|
|
1612
1612
|
allowArgs: true,
|
|
1613
1613
|
handle: async (command, runtime) => {
|
|
@@ -1622,32 +1622,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
1622
1622
|
} catch {
|
|
1623
1623
|
return usage(`Directory does not exist: ${resolvedPath}`, runtime);
|
|
1624
1624
|
}
|
|
1625
|
-
let newSessionFile: string | undefined;
|
|
1626
1625
|
try {
|
|
1627
|
-
|
|
1628
|
-
const switched = await runtime.session.switchSession(newSessionFile);
|
|
1629
|
-
if (!switched) {
|
|
1630
|
-
await runtime.sessionManager.dropSession(newSessionFile);
|
|
1631
|
-
return usage("Move cancelled.", runtime);
|
|
1632
|
-
}
|
|
1626
|
+
await runtime.sessionManager.moveTo(resolvedPath);
|
|
1633
1627
|
} catch (err) {
|
|
1634
|
-
if (newSessionFile) {
|
|
1635
|
-
try {
|
|
1636
|
-
await runtime.sessionManager.dropSession(newSessionFile);
|
|
1637
|
-
} catch (dropErr) {
|
|
1638
|
-
return usage(
|
|
1639
|
-
`Move failed: ${errorMessage(err)}; failed to remove empty session: ${errorMessage(dropErr)}`,
|
|
1640
|
-
runtime,
|
|
1641
|
-
);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
1628
|
return usage(`Move failed: ${errorMessage(err)}`, runtime);
|
|
1645
1629
|
}
|
|
1646
|
-
runtime.session.markMovedFromEmptySessionFile(newSessionFile!);
|
|
1647
1630
|
setProjectDir(resolvedPath);
|
|
1631
|
+
await runtime.settings.reloadForCwd(resolvedPath);
|
|
1632
|
+
applyProviderGlobalsFromSettings(runtime.settings);
|
|
1648
1633
|
// Reload plugin/capability caches so the next prompt sees commands and
|
|
1649
1634
|
// capabilities scoped to the new cwd.
|
|
1650
1635
|
await runtime.reloadPlugins();
|
|
1636
|
+
await runtime.notifyConfigChanged?.();
|
|
1651
1637
|
await runtime.notifyTitleChanged?.();
|
|
1652
1638
|
await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
|
|
1653
1639
|
return commandConsumed();
|
package/src/task/executor.ts
CHANGED
|
@@ -56,7 +56,6 @@ import { ToolAbortError } from "../tools/tool-errors";
|
|
|
56
56
|
import type { EventBus } from "../utils/event-bus";
|
|
57
57
|
import { buildNamedToolChoice } from "../utils/tool-choice";
|
|
58
58
|
import type { WorkspaceTree } from "../workspace-tree";
|
|
59
|
-
import { Semaphore } from "./parallel";
|
|
60
59
|
import { subprocessToolRegistry } from "./subprocess-tool-registry";
|
|
61
60
|
import {
|
|
62
61
|
type AgentDefinition,
|
|
@@ -199,51 +198,6 @@ function installSubagentRetryFallbackChain(args: {
|
|
|
199
198
|
return role;
|
|
200
199
|
}
|
|
201
200
|
|
|
202
|
-
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
203
|
-
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
interface ProviderSemaphoreEntry {
|
|
207
|
-
limit: number;
|
|
208
|
-
semaphore: Semaphore;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
215
|
-
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
216
|
-
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
217
|
-
* holds a slot and a later finite resize counts work started while unlimited.
|
|
218
|
-
*/
|
|
219
|
-
function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
220
|
-
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
221
|
-
if (!settingPath) return undefined;
|
|
222
|
-
const raw = settings.get(settingPath);
|
|
223
|
-
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
224
|
-
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
228
|
-
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
229
|
-
if (limit === undefined) return undefined;
|
|
230
|
-
// Always hand out (and acquire on) the single shared limiter, even when
|
|
231
|
-
// unlimited (Infinity). Resizing it in place — rather than replacing it —
|
|
232
|
-
// keeps every in-flight slot counted, so a runtime or mixed limit change can
|
|
233
|
-
// never push concurrency past the cap (issue #3464 review feedback).
|
|
234
|
-
const existing = providerSemaphores.get(provider);
|
|
235
|
-
if (existing) {
|
|
236
|
-
if (existing.limit !== limit) {
|
|
237
|
-
existing.limit = limit;
|
|
238
|
-
existing.semaphore.resize(limit);
|
|
239
|
-
}
|
|
240
|
-
return existing.semaphore;
|
|
241
|
-
}
|
|
242
|
-
const semaphore = new Semaphore(limit);
|
|
243
|
-
providerSemaphores.set(provider, { limit, semaphore });
|
|
244
|
-
return semaphore;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
201
|
function renderIrcPeerRoster(selfId: string): string {
|
|
248
202
|
const peers = AgentRegistry.global()
|
|
249
203
|
.list()
|
|
@@ -2028,8 +1982,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2028
1982
|
let sessionOpenedAt: number | undefined;
|
|
2029
1983
|
let sessionCreatedAt: number | undefined;
|
|
2030
1984
|
let readyAt: number | undefined;
|
|
2031
|
-
let providerSemaphore: Semaphore | undefined;
|
|
2032
|
-
let providerSemaphoreAcquired = false;
|
|
2033
1985
|
|
|
2034
1986
|
try {
|
|
2035
1987
|
checkAbort();
|
|
@@ -2098,13 +2050,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2098
2050
|
? resolvedThinkingLevel
|
|
2099
2051
|
: (thinkingLevel ?? resolvedThinkingLevel);
|
|
2100
2052
|
resolvedAt = performance.now();
|
|
2101
|
-
if (model) {
|
|
2102
|
-
providerSemaphore = getProviderSemaphore(settings, model.provider);
|
|
2103
|
-
if (providerSemaphore) {
|
|
2104
|
-
await providerSemaphore.acquire(abortSignal);
|
|
2105
|
-
providerSemaphoreAcquired = true;
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
2053
|
|
|
2109
2054
|
const effectiveCwd = worktree ?? cwd;
|
|
2110
2055
|
const sessionManager = sessionFile
|
|
@@ -2395,10 +2340,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2395
2340
|
}
|
|
2396
2341
|
if (exitCode === 0) exitCode = 1;
|
|
2397
2342
|
}
|
|
2398
|
-
if (providerSemaphoreAcquired) {
|
|
2399
|
-
providerSemaphore?.release();
|
|
2400
|
-
providerSemaphoreAcquired = false;
|
|
2401
|
-
}
|
|
2402
2343
|
sessionAbortController.abort();
|
|
2403
2344
|
try {
|
|
2404
2345
|
await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
|
|
@@ -2429,9 +2370,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
2429
2370
|
}
|
|
2430
2371
|
|
|
2431
2372
|
// Launch-latency breakdown (subagent invocation → first chat dispatch).
|
|
2432
|
-
// Phase deltas are performance.now() spans; the
|
|
2433
|
-
// Date.now epochs captured by the spawn site
|
|
2434
|
-
// acquiredAt after) so queue wait and
|
|
2373
|
+
// Phase deltas are performance.now() spans; the task-tool concurrency
|
|
2374
|
+
// brackets use the Date.now epochs captured by the spawn site
|
|
2375
|
+
// (invokedAt before acquire, acquiredAt after) so queue wait and
|
|
2376
|
+
// pre-run setup are reported apart.
|
|
2435
2377
|
const span = (from: number | undefined, to: number | undefined): number | undefined =>
|
|
2436
2378
|
from !== undefined && to !== undefined ? Math.round(to - from) : undefined;
|
|
2437
2379
|
const queueMs =
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-provider LLM concurrency cap, applied around each provider HTTP request.
|
|
3
|
+
*
|
|
4
|
+
* The semaphore brackets only the streaming request itself, not the whole
|
|
5
|
+
* agent lifetime: a parent subagent releases its slot the moment its LLM
|
|
6
|
+
* stream finishes producing, so children spawned during tool execution can
|
|
7
|
+
* acquire slots for their own turns. Holding the slot across the parent's
|
|
8
|
+
* full conversation deadlocks any spawn tree whose width exceeds
|
|
9
|
+
* `maxConcurrency` because the parents wait for children that wait for
|
|
10
|
+
* slots the parents are holding (issue
|
|
11
|
+
* [#3749](https://github.com/can1357/oh-my-pi/issues/3749)).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { StreamFn } from "@oh-my-pi/pi-agent-core";
|
|
15
|
+
import type { Settings } from "../config/settings";
|
|
16
|
+
import type { SettingPath } from "../config/settings-schema";
|
|
17
|
+
import { Semaphore } from "./parallel";
|
|
18
|
+
|
|
19
|
+
const PROVIDER_MAX_CONCURRENCY_SETTINGS: Record<string, SettingPath> = {
|
|
20
|
+
"ollama-cloud": "providers.ollama-cloud.maxConcurrency",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface ProviderSemaphoreEntry {
|
|
24
|
+
limit: number;
|
|
25
|
+
semaphore: Semaphore;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const providerSemaphores = new Map<string, ProviderSemaphoreEntry>();
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the configured concurrency ceiling for a provider, or `undefined`
|
|
32
|
+
* when the provider has no cap concept at all. A configured value `<= 0` means
|
|
33
|
+
* "unlimited" and maps to `Infinity` — still a tracked ceiling, so every run
|
|
34
|
+
* holds a slot and a later finite resize counts work started while unlimited.
|
|
35
|
+
*/
|
|
36
|
+
export function getProviderConcurrencyLimit(settings: Settings, provider: string): number | undefined {
|
|
37
|
+
const settingPath = PROVIDER_MAX_CONCURRENCY_SETTINGS[provider];
|
|
38
|
+
if (!settingPath) return undefined;
|
|
39
|
+
const raw = settings.get(settingPath);
|
|
40
|
+
const limit = Number.isFinite(raw) ? Math.trunc(raw) : 0;
|
|
41
|
+
return limit > 0 ? limit : Number.POSITIVE_INFINITY;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hand out the single shared limiter for `provider` (creating one lazily) and
|
|
46
|
+
* resize it in place when the configured limit changes. Replacing the
|
|
47
|
+
* semaphore would orphan in-flight slots on the old instance and let a
|
|
48
|
+
* runtime or mixed limit value exceed the cap (issue #3464 review feedback).
|
|
49
|
+
*/
|
|
50
|
+
export function getProviderSemaphore(settings: Settings, provider: string): Semaphore | undefined {
|
|
51
|
+
const limit = getProviderConcurrencyLimit(settings, provider);
|
|
52
|
+
if (limit === undefined) return undefined;
|
|
53
|
+
const existing = providerSemaphores.get(provider);
|
|
54
|
+
if (existing) {
|
|
55
|
+
if (existing.limit !== limit) {
|
|
56
|
+
existing.limit = limit;
|
|
57
|
+
existing.semaphore.resize(limit);
|
|
58
|
+
}
|
|
59
|
+
return existing.semaphore;
|
|
60
|
+
}
|
|
61
|
+
const semaphore = new Semaphore(limit);
|
|
62
|
+
providerSemaphores.set(provider, { limit, semaphore });
|
|
63
|
+
return semaphore;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap a {@link StreamFn} so every LLM HTTP request acquires the provider's
|
|
68
|
+
* concurrency slot before the request goes out and releases it when the
|
|
69
|
+
* stream finishes producing (success, error, or abort). Providers without a
|
|
70
|
+
* configured cap pass straight through.
|
|
71
|
+
*
|
|
72
|
+
* The acquire bracket is intentionally narrow (one slot per LLM call), so
|
|
73
|
+
* spawn trees deeper than `maxConcurrency` no longer deadlock on themselves —
|
|
74
|
+
* see the module-level comment for the failure mode this fixes.
|
|
75
|
+
*/
|
|
76
|
+
export function wrapStreamFnWithProviderConcurrency(settings: Settings, base: StreamFn): StreamFn {
|
|
77
|
+
return async (model, context, options) => {
|
|
78
|
+
const semaphore = getProviderSemaphore(settings, model.provider);
|
|
79
|
+
if (!semaphore) return base(model, context, options);
|
|
80
|
+
await semaphore.acquire(options?.signal);
|
|
81
|
+
let released = false;
|
|
82
|
+
const release = () => {
|
|
83
|
+
if (released) return;
|
|
84
|
+
released = true;
|
|
85
|
+
semaphore.release();
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
const stream = await base(model, context, options);
|
|
89
|
+
// EventStream.result() settles when the producer pushes 'done'/'error'
|
|
90
|
+
// or calls fail() — i.e. once the provider has finished producing.
|
|
91
|
+
// Releasing here keeps the slot held for the network request and
|
|
92
|
+
// nothing else.
|
|
93
|
+
stream.result().then(release, release);
|
|
94
|
+
return stream;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
release();
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/task/worktree.ts
CHANGED
|
@@ -3,12 +3,16 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import * as natives from "@oh-my-pi/pi-natives";
|
|
6
|
-
import { getWorktreeDir,
|
|
6
|
+
import { getWorktreeDir, logger, Snowflake } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import * as git from "../utils/git";
|
|
8
8
|
import * as jj from "../utils/jj";
|
|
9
9
|
import { mapWithConcurrencyLimit } from "./parallel";
|
|
10
10
|
|
|
11
11
|
const { IsoBackendKind } = natives;
|
|
12
|
+
|
|
13
|
+
const TASK_ISOLATION_DIR_PREFIX = "t";
|
|
14
|
+
const TASK_ISOLATION_DIR_DIGEST_CHARS = 9;
|
|
15
|
+
const TASK_ISOLATION_MOUNT_DIR = "m";
|
|
12
16
|
type IsoBackendKind = natives.IsoBackendKind;
|
|
13
17
|
|
|
14
18
|
/** Baseline state for a single git repository. */
|
|
@@ -389,15 +393,20 @@ function errorMessage(err: unknown): string {
|
|
|
389
393
|
return err instanceof Error ? err.message : String(err);
|
|
390
394
|
}
|
|
391
395
|
|
|
396
|
+
function getTaskIsolationSegment(repoRoot: string, id: string): string {
|
|
397
|
+
const key = `${path.resolve(repoRoot)}\0${id}`;
|
|
398
|
+
const digest = Bun.hash(key).toString(16).padStart(16, "0").slice(-TASK_ISOLATION_DIR_DIGEST_CHARS);
|
|
399
|
+
return `${TASK_ISOLATION_DIR_PREFIX}${digest}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
392
402
|
export async function ensureIsolation(
|
|
393
403
|
baseCwd: string,
|
|
394
404
|
id: string,
|
|
395
405
|
preferred?: IsoBackendKind,
|
|
396
406
|
): Promise<IsolationHandle> {
|
|
397
407
|
const repoRoot = await getRepoRoot(baseCwd);
|
|
398
|
-
const baseDir = getWorktreeDir(
|
|
399
|
-
const mergedDir = path.join(baseDir,
|
|
400
|
-
|
|
408
|
+
const baseDir = getWorktreeDir(getTaskIsolationSegment(repoRoot, id));
|
|
409
|
+
const mergedDir = path.join(baseDir, TASK_ISOLATION_MOUNT_DIR);
|
|
401
410
|
const resolution = natives.isoResolve(preferred ?? null);
|
|
402
411
|
const candidates = resolution.candidates.length > 0 ? resolution.candidates : [resolution.kind];
|
|
403
412
|
let fallbackReason = resolution.reason ?? null;
|