@oh-my-pi/pi-coding-agent 15.10.11 → 15.10.12
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 +44 -0
- package/dist/cli.js +5349 -5328
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli-commands.d.ts +12 -0
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +18 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
- package/dist/types/eval/py/executor.d.ts +5 -0
- package/dist/types/eval/py/kernel.d.ts +6 -1
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/memory-backend/index.d.ts +1 -0
- package/dist/types/memory-backend/runtime.d.ts +4 -0
- package/dist/types/memory-backend/types.d.ts +66 -1
- package/dist/types/modes/index.d.ts +3 -3
- package/dist/types/modes/interactive-mode.d.ts +7 -2
- package/dist/types/modes/oauth-manual-input.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
- package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
- package/dist/types/modes/setup-wizard/index.d.ts +5 -1
- package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/secrets/index.d.ts +1 -1
- package/dist/types/secrets/obfuscator.d.ts +8 -2
- package/dist/types/session/agent-session.d.ts +14 -2
- package/dist/types/session/streaming-output.d.ts +23 -0
- package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
- package/dist/types/slash-commands/types.d.ts +1 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/index.d.ts +2 -2
- package/dist/types/task/types.d.ts +8 -0
- package/dist/types/thinking.d.ts +4 -0
- package/dist/types/tiny/title-client.d.ts +11 -0
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tools/index.d.ts +6 -0
- package/dist/types/utils/git.d.ts +15 -2
- package/dist/types/utils/title-generator.d.ts +3 -2
- package/package.json +10 -10
- package/src/auto-thinking/classifier.ts +1 -0
- package/src/cli/args.ts +3 -0
- package/src/cli-commands.ts +29 -0
- package/src/cli.ts +8 -9
- package/src/commands/launch.ts +4 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/api-key-resolver.ts +8 -6
- package/src/config/model-registry.ts +97 -30
- package/src/config/model-resolver.ts +60 -0
- package/src/config/settings-schema.ts +43 -15
- package/src/config/settings.ts +61 -3
- package/src/edit/hashline/execute.ts +39 -2
- package/src/edit/hashline/noop-loop-guard.ts +99 -0
- package/src/eval/completion-bridge.ts +1 -0
- package/src/eval/py/executor.ts +29 -7
- package/src/eval/py/index.ts +6 -1
- package/src/eval/py/kernel.ts +31 -11
- package/src/eval/py/runtime.ts +37 -0
- package/src/exec/bash-executor.ts +82 -3
- package/src/extensibility/extensions/get-commands-handler.ts +2 -1
- package/src/extensibility/extensions/runner.ts +6 -1
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/hindsight/bank.ts +17 -2
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/main.ts +18 -6
- package/src/memories/index.ts +2 -0
- package/src/memory-backend/index.ts +1 -0
- package/src/memory-backend/local-backend.ts +9 -0
- package/src/memory-backend/off-backend.ts +9 -0
- package/src/memory-backend/runtime.ts +66 -0
- package/src/memory-backend/types.ts +81 -1
- package/src/mnemopi/backend.ts +151 -4
- package/src/modes/acp/acp-agent.ts +119 -11
- package/src/modes/components/assistant-message.ts +19 -21
- package/src/modes/components/footer.ts +3 -1
- package/src/modes/components/status-line/component.ts +118 -34
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +1 -0
- package/src/modes/controllers/mcp-command-controller.ts +38 -3
- package/src/modes/index.ts +3 -21
- package/src/modes/interactive-mode.ts +39 -9
- package/src/modes/oauth-manual-input.ts +30 -3
- package/src/modes/rpc/rpc-client.ts +154 -3
- package/src/modes/rpc/rpc-mode.ts +97 -12
- package/src/modes/rpc/rpc-subagents.ts +265 -0
- package/src/modes/rpc/rpc-types.ts +81 -1
- package/src/modes/setup-wizard/index.ts +12 -2
- package/src/modes/setup-wizard/lazy.ts +16 -0
- package/src/modes/types.ts +2 -0
- package/src/sdk.ts +8 -1
- package/src/secrets/index.ts +8 -1
- package/src/secrets/obfuscator.ts +39 -18
- package/src/session/agent-session.ts +179 -54
- package/src/session/streaming-output.ts +166 -10
- package/src/slash-commands/acp-builtins.ts +24 -0
- package/src/slash-commands/builtin-registry.ts +20 -0
- package/src/slash-commands/types.ts +1 -1
- package/src/system-prompt.ts +14 -0
- package/src/task/executor.ts +13 -12
- package/src/task/index.ts +9 -8
- package/src/task/render.ts +18 -3
- package/src/task/types.ts +9 -0
- package/src/thinking.ts +7 -0
- package/src/tiny/title-client.ts +34 -5
- package/src/tiny/title-protocol.ts +1 -1
- package/src/tiny/worker.ts +6 -4
- package/src/tools/bash.ts +46 -5
- package/src/tools/image-gen.ts +11 -4
- package/src/tools/index.ts +13 -1
- package/src/tools/inspect-image.ts +1 -0
- package/src/utils/commit-message-generator.ts +1 -0
- package/src/utils/git.ts +267 -13
- package/src/utils/title-generator.ts +24 -5
|
@@ -71,7 +71,12 @@ import {
|
|
|
71
71
|
type SessionInfo as StoredSessionInfo,
|
|
72
72
|
type UsageStatistics,
|
|
73
73
|
} from "../../session/session-manager";
|
|
74
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
ACP_BUILTIN_RESERVED_NAMES,
|
|
76
|
+
ACP_BUILTIN_SLASH_COMMANDS,
|
|
77
|
+
executeAcpBuiltinSlashCommand,
|
|
78
|
+
isAcpBuiltinShadowedName,
|
|
79
|
+
} from "../../slash-commands/acp-builtins";
|
|
75
80
|
import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
|
|
76
81
|
import { normalizeLocalScheme } from "../../tools/path-utils";
|
|
77
82
|
import { runResolveInvocation } from "../../tools/resolve";
|
|
@@ -117,6 +122,7 @@ type PromptQueueState = {
|
|
|
117
122
|
promise: Promise<void>;
|
|
118
123
|
release: (() => void) | undefined;
|
|
119
124
|
};
|
|
125
|
+
type PromptLifecycleError = Error & { readonly code: "ACP_SESSION_CLOSED" };
|
|
120
126
|
|
|
121
127
|
type PromptTurnState = {
|
|
122
128
|
userMessageId: string;
|
|
@@ -158,6 +164,9 @@ type ManagedSessionRecord = {
|
|
|
158
164
|
// Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
|
|
159
165
|
// in `#disposeSessionRecord`. Lives independent of any prompt turn.
|
|
160
166
|
lifetimeUnsubscribe: (() => void) | undefined;
|
|
167
|
+
closedError: PromptLifecycleError | undefined;
|
|
168
|
+
promptEventHandlers: Set<Promise<void>>;
|
|
169
|
+
extensionUserMessageTasks: Set<Promise<void>>;
|
|
161
170
|
};
|
|
162
171
|
|
|
163
172
|
type ReplayableMessage = {
|
|
@@ -594,7 +603,23 @@ export class AcpAgent implements Agent {
|
|
|
594
603
|
const record = this.#getSessionRecord(params.sessionId);
|
|
595
604
|
const activeTurn = record.promptTurn;
|
|
596
605
|
if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
|
|
597
|
-
|
|
606
|
+
// New prompt arrived while the previous turn is still in-flight (e.g. the
|
|
607
|
+
// client sent a message immediately after pressing stop, before or without
|
|
608
|
+
// a preceding session/cancel notification). Implicitly cancel the running
|
|
609
|
+
// turn so the new prompt can queue behind the abort cleanup — identical to
|
|
610
|
+
// what cancel() does when called explicitly. #beginCancelCleanup is
|
|
611
|
+
// idempotent, so a concurrent session/cancel notification is harmless.
|
|
612
|
+
// Mirror cancel()'s timeout handling: if abort() hangs past the cleanup
|
|
613
|
+
// timeout, close the managed session instead of leaving it registered
|
|
614
|
+
// with a still-streaming AgentSession. The queued prompt below observes
|
|
615
|
+
// the same cleanup rejection and fails accordingly.
|
|
616
|
+
this.#beginCancelCleanup(record, activeTurn).catch(async (error: unknown) => {
|
|
617
|
+
logger.warn("ACP cancel cleanup timed out; closing session", {
|
|
618
|
+
sessionId: record.session.sessionId,
|
|
619
|
+
error,
|
|
620
|
+
});
|
|
621
|
+
await this.#closeManagedSession(params.sessionId, record);
|
|
622
|
+
});
|
|
598
623
|
}
|
|
599
624
|
return await this.#queuePrompt(record, async () => {
|
|
600
625
|
const previousTurn = record.promptTurn;
|
|
@@ -607,6 +632,7 @@ export class AcpAgent implements Agent {
|
|
|
607
632
|
await previousTurn.promise.catch(() => undefined);
|
|
608
633
|
await previousTurn.cleanup;
|
|
609
634
|
}
|
|
635
|
+
this.#throwIfRecordClosed(record);
|
|
610
636
|
|
|
611
637
|
const converted = this.#convertPromptBlocks(params.prompt);
|
|
612
638
|
const pendingPrompt = Promise.withResolvers<PromptResponse>();
|
|
@@ -623,7 +649,7 @@ export class AcpAgent implements Agent {
|
|
|
623
649
|
};
|
|
624
650
|
|
|
625
651
|
record.promptTurn.unsubscribe = record.session.subscribe(event => {
|
|
626
|
-
|
|
652
|
+
this.#trackPromptEvent(record, event);
|
|
627
653
|
});
|
|
628
654
|
|
|
629
655
|
this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
|
|
@@ -643,6 +669,7 @@ export class AcpAgent implements Agent {
|
|
|
643
669
|
release: releaseQueue,
|
|
644
670
|
};
|
|
645
671
|
await previousQueue.promise;
|
|
672
|
+
this.#throwIfRecordClosed(record);
|
|
646
673
|
try {
|
|
647
674
|
return await run();
|
|
648
675
|
} finally {
|
|
@@ -653,6 +680,55 @@ export class AcpAgent implements Agent {
|
|
|
653
680
|
}
|
|
654
681
|
}
|
|
655
682
|
|
|
683
|
+
#throwIfRecordClosed(record: ManagedSessionRecord): void {
|
|
684
|
+
if (record.closedError) {
|
|
685
|
+
throw record.closedError;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
#createPromptLifecycleError(message: string): PromptLifecycleError {
|
|
690
|
+
return Object.assign(new Error(message), { code: "ACP_SESSION_CLOSED" as const });
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
#trackPromptEvent(record: ManagedSessionRecord, event: AgentSessionEvent): void {
|
|
694
|
+
const handling = this.#handlePromptEvent(record, event).catch((error: unknown) => {
|
|
695
|
+
logger.warn("ACP prompt event handler failed", { error });
|
|
696
|
+
});
|
|
697
|
+
record.promptEventHandlers.add(handling);
|
|
698
|
+
void handling.finally(() => {
|
|
699
|
+
record.promptEventHandlers.delete(handling);
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async #waitForPromptEventHandlers(record: ManagedSessionRecord): Promise<void> {
|
|
704
|
+
while (record.promptEventHandlers.size > 0) {
|
|
705
|
+
await Promise.allSettled(Array.from(record.promptEventHandlers));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
#trackExtensionUserMessage(record: ManagedSessionRecord, task: Promise<void>): void {
|
|
710
|
+
const tracked = task.catch((error: unknown) => {
|
|
711
|
+
logger.warn("ACP extension sendUserMessage failed", { error });
|
|
712
|
+
});
|
|
713
|
+
record.extensionUserMessageTasks.add(tracked);
|
|
714
|
+
void tracked.finally(() => {
|
|
715
|
+
record.extensionUserMessageTasks.delete(tracked);
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async #waitForExtensionUserMessages(
|
|
720
|
+
record: ManagedSessionRecord,
|
|
721
|
+
baseline: ReadonlySet<Promise<void>>,
|
|
722
|
+
): Promise<void> {
|
|
723
|
+
while (true) {
|
|
724
|
+
const pending = Array.from(record.extensionUserMessageTasks).filter(task => !baseline.has(task));
|
|
725
|
+
if (pending.length === 0) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
await Promise.allSettled(pending);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
656
732
|
async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
|
|
657
733
|
const skillResult = await this.#tryRunSkillCommand(record, text);
|
|
658
734
|
if (skillResult) {
|
|
@@ -699,7 +775,18 @@ export class AcpAgent implements Agent {
|
|
|
699
775
|
return;
|
|
700
776
|
}
|
|
701
777
|
|
|
702
|
-
|
|
778
|
+
const extensionPromptBaseline = new Set(record.extensionUserMessageTasks);
|
|
779
|
+
const agentInvoked = await record.session.prompt(text, { images });
|
|
780
|
+
// Extension and custom-TS commands are handled locally inside session.prompt().
|
|
781
|
+
// An ACP extension command can still call pi.sendUserMessage(), which starts
|
|
782
|
+
// an async nested prompt through the extension runtime. Keep the ACP turn
|
|
783
|
+
// subscribed until those scheduled prompts and their event handlers drain;
|
|
784
|
+
// only then is `false` proof that the slash command was purely local.
|
|
785
|
+
if (!agentInvoked) {
|
|
786
|
+
await this.#waitForExtensionUserMessages(record, extensionPromptBaseline);
|
|
787
|
+
await this.#waitForPromptEventHandlers(record);
|
|
788
|
+
this.#finishPrompt(record, { stopReason: "end_turn" });
|
|
789
|
+
}
|
|
703
790
|
}
|
|
704
791
|
|
|
705
792
|
async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise<boolean> {
|
|
@@ -991,6 +1078,9 @@ export class AcpAgent implements Agent {
|
|
|
991
1078
|
liveMessageProgress: undefined,
|
|
992
1079
|
toolArgsById: new Map(),
|
|
993
1080
|
extensionsConfigured: false,
|
|
1081
|
+
closedError: undefined,
|
|
1082
|
+
promptEventHandlers: new Set(),
|
|
1083
|
+
extensionUserMessageTasks: new Set(),
|
|
994
1084
|
lifetimeUnsubscribe: undefined,
|
|
995
1085
|
};
|
|
996
1086
|
}
|
|
@@ -1582,10 +1672,12 @@ export class AcpAgent implements Agent {
|
|
|
1582
1672
|
commands.push(command);
|
|
1583
1673
|
};
|
|
1584
1674
|
|
|
1585
|
-
// Advertise in the order dispatch resolves them
|
|
1586
|
-
//
|
|
1587
|
-
//
|
|
1588
|
-
// commands
|
|
1675
|
+
// Advertise in the order dispatch resolves them (mirrors AgentSession
|
|
1676
|
+
// dispatch: builtins → skills → extensions → custom TS → file-based).
|
|
1677
|
+
// `appendCommand` dedupes by name so earlier entries win; extension
|
|
1678
|
+
// commands therefore correctly shadow custom TS commands of the same
|
|
1679
|
+
// name, matching the runtime behaviour of #tryExecuteExtensionCommand
|
|
1680
|
+
// running before #tryExecuteCustomCommand.
|
|
1589
1681
|
for (const command of ACP_BUILTIN_SLASH_COMMANDS) {
|
|
1590
1682
|
appendCommand(command);
|
|
1591
1683
|
}
|
|
@@ -1600,6 +1692,20 @@ export class AcpAgent implements Agent {
|
|
|
1600
1692
|
}
|
|
1601
1693
|
}
|
|
1602
1694
|
|
|
1695
|
+
for (const command of session.extensionRunner?.getRegisteredCommands(ACP_BUILTIN_RESERVED_NAMES) ?? []) {
|
|
1696
|
+
// Reserved-set filtering in getRegisteredCommands only covers exact
|
|
1697
|
+
// names; colon-namespaced names whose prefix is a builtin (e.g.
|
|
1698
|
+
// `model:foo`) would still dispatch to the builtin in ACP.
|
|
1699
|
+
if (isAcpBuiltinShadowedName(command.name)) {
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
appendCommand({
|
|
1703
|
+
name: command.name,
|
|
1704
|
+
description: command.description ?? "(extension command)",
|
|
1705
|
+
input: { hint: "arguments" },
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1603
1709
|
for (const command of session.customCommands) {
|
|
1604
1710
|
appendCommand({
|
|
1605
1711
|
name: command.command.name,
|
|
@@ -2069,9 +2175,7 @@ export class AcpAgent implements Agent {
|
|
|
2069
2175
|
});
|
|
2070
2176
|
},
|
|
2071
2177
|
sendUserMessage: (content, options) => {
|
|
2072
|
-
record.session.sendUserMessage(content, options)
|
|
2073
|
-
logger.warn("ACP extension sendUserMessage failed", { error });
|
|
2074
|
-
});
|
|
2178
|
+
this.#trackExtensionUserMessage(record, record.session.sendUserMessage(content, options));
|
|
2075
2179
|
},
|
|
2076
2180
|
appendEntry: (customType, data) => {
|
|
2077
2181
|
record.session.sessionManager.appendCustomEntry(customType, data);
|
|
@@ -2224,6 +2328,7 @@ export class AcpAgent implements Agent {
|
|
|
2224
2328
|
}
|
|
2225
2329
|
|
|
2226
2330
|
async #closeManagedSession(sessionId: string, record: ManagedSessionRecord): Promise<void> {
|
|
2331
|
+
record.closedError ??= this.#createPromptLifecycleError("ACP session closed before queued prompt could run");
|
|
2227
2332
|
this.#sessions.delete(sessionId);
|
|
2228
2333
|
await this.#cancelPromptForClose(record);
|
|
2229
2334
|
await this.#disposeSessionRecord(record);
|
|
@@ -2279,6 +2384,9 @@ export class AcpAgent implements Agent {
|
|
|
2279
2384
|
await Promise.all(
|
|
2280
2385
|
records.map(async ([sessionId, record]) => {
|
|
2281
2386
|
try {
|
|
2387
|
+
record.closedError ??= this.#createPromptLifecycleError(
|
|
2388
|
+
"ACP agent disposed before queued prompt could run",
|
|
2389
|
+
);
|
|
2282
2390
|
await this.#cancelPromptForClose(record);
|
|
2283
2391
|
await this.#disposeSessionRecord(record);
|
|
2284
2392
|
} catch (error) {
|
|
@@ -222,7 +222,9 @@ export class AssistantMessageComponent extends Container {
|
|
|
222
222
|
this.#contentContainer.clear();
|
|
223
223
|
|
|
224
224
|
const hasVisibleContent = message.content.some(
|
|
225
|
-
c =>
|
|
225
|
+
c =>
|
|
226
|
+
(c.type === "text" && c.text.trim()) ||
|
|
227
|
+
(!this.hideThinkingBlock && c.type === "thinking" && c.thinking.trim()),
|
|
226
228
|
);
|
|
227
229
|
|
|
228
230
|
// Render content in order
|
|
@@ -236,32 +238,28 @@ export class AssistantMessageComponent extends Container {
|
|
|
236
238
|
markdown.transientRenderCache = this.#lastUpdateTransient;
|
|
237
239
|
this.#contentContainer.addChild(markdown);
|
|
238
240
|
} else if (content.type === "thinking" && content.thinking.trim()) {
|
|
241
|
+
if (this.hideThinkingBlock) {
|
|
242
|
+
thinkingIndex += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
239
245
|
// Add spacing only when another visible assistant content block follows.
|
|
240
246
|
// This avoids a superfluous blank line before separately-rendered tool execution blocks.
|
|
241
247
|
const hasVisibleContentAfter = message.content
|
|
242
248
|
.slice(i + 1)
|
|
243
249
|
.some(c => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()));
|
|
244
250
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
});
|
|
258
|
-
thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
|
|
259
|
-
this.#contentContainer.addChild(thinkingMarkdown);
|
|
260
|
-
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
261
|
-
thinkingIndex += 1;
|
|
262
|
-
if (hasVisibleContentAfter) {
|
|
263
|
-
this.#contentContainer.addChild(new Spacer(1));
|
|
264
|
-
}
|
|
251
|
+
const thinkingText = content.thinking.trim();
|
|
252
|
+
// Thinking traces in thinkingText color, italic
|
|
253
|
+
const thinkingMarkdown = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
|
|
254
|
+
color: (text: string) => theme.fg("thinkingText", text),
|
|
255
|
+
italic: true,
|
|
256
|
+
});
|
|
257
|
+
thinkingMarkdown.transientRenderCache = this.#lastUpdateTransient;
|
|
258
|
+
this.#contentContainer.addChild(thinkingMarkdown);
|
|
259
|
+
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
260
|
+
thinkingIndex += 1;
|
|
261
|
+
if (hasVisibleContentAfter) {
|
|
262
|
+
this.#contentContainer.addChild(new Spacer(1));
|
|
265
263
|
}
|
|
266
264
|
}
|
|
267
265
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { stripVTControlCharacters } from "node:util";
|
|
3
4
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
4
5
|
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
@@ -65,7 +66,8 @@ export class FooterComponent implements Component {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
+
const watchPath = head.isReftable ? path.join(head.gitDir, "reftable") : head.headPath;
|
|
70
|
+
this.#gitWatcher = fs.watch(watchPath, () => {
|
|
69
71
|
this.#cachedBranch = undefined; // Invalidate cache
|
|
70
72
|
if (this.#onBranchChange) {
|
|
71
73
|
this.#onBranchChange();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
3
4
|
import { estimateTokens } from "@oh-my-pi/pi-agent-core/compaction";
|
|
4
5
|
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
@@ -120,6 +121,18 @@ function tokensForMessage(msg: AgentMessage): number {
|
|
|
120
121
|
return tokens;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
interface MessageTokenTotalsCache {
|
|
125
|
+
messagesRef: readonly AgentMessage[];
|
|
126
|
+
stableCount: number;
|
|
127
|
+
stableTokens: number;
|
|
128
|
+
lastStableMessage: AgentMessage | undefined;
|
|
129
|
+
lastStableFingerprint: string | undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
|
|
133
|
+
return segments.includes("context_pct") || segments.includes("context_total");
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
124
137
|
// StatusLineComponent
|
|
125
138
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -129,6 +142,7 @@ export class StatusLineComponent implements Component {
|
|
|
129
142
|
#effectiveSettings: EffectiveStatusLineSettings | undefined;
|
|
130
143
|
#cachedBranch: string | null | undefined = undefined;
|
|
131
144
|
#cachedBranchRepoId: string | null | undefined = undefined;
|
|
145
|
+
#cachedBranchCwd: string | undefined = undefined;
|
|
132
146
|
#gitWatcher: fs.FSWatcher | null = null;
|
|
133
147
|
#onBranchChange: (() => void) | null = null;
|
|
134
148
|
#autoCompactEnabled: boolean = true;
|
|
@@ -159,20 +173,19 @@ export class StatusLineComponent implements Component {
|
|
|
159
173
|
} | null = null;
|
|
160
174
|
#usageFetchedAt = 0;
|
|
161
175
|
#usageInFlight = false;
|
|
162
|
-
// Context breakdown — incremental cache.
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// branch rebuild, replaceMessages with the same length) are detected
|
|
170
|
-
// and recomputed.
|
|
176
|
+
// Context breakdown — incremental rolling cache. The status line refreshes
|
|
177
|
+
// on every agent event, so the hot path must not re-tokenize the full
|
|
178
|
+
// message list. Stable messages are accumulated once; normal streaming
|
|
179
|
+
// refreshes only recompute the current tail message and newly appended
|
|
180
|
+
// entries. History rewrites/compaction replace or shrink the message array
|
|
181
|
+
// and rebuild this cache. Stable messages are treated as immutable after
|
|
182
|
+
// promotion, matching the normal append-only session flow.
|
|
171
183
|
// Cached non-message total (system prompt + tools + skills). Invalidated
|
|
172
184
|
// when the inputs-identity fingerprint changes (model swap, skill toggle,
|
|
173
185
|
// tool registration).
|
|
174
186
|
#nonMessageTokensCache: number | undefined;
|
|
175
187
|
#nonMessageInputsKey: string | undefined;
|
|
188
|
+
#messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
|
|
176
189
|
|
|
177
190
|
constructor(private readonly session: AgentSession) {
|
|
178
191
|
this.#settings = {
|
|
@@ -238,11 +251,15 @@ export class StatusLineComponent implements Component {
|
|
|
238
251
|
this.#gitWatcher = null;
|
|
239
252
|
}
|
|
240
253
|
|
|
241
|
-
const
|
|
242
|
-
if (!
|
|
254
|
+
const repository = git.repo.resolveSync(getProjectDir());
|
|
255
|
+
if (!repository) return;
|
|
256
|
+
|
|
257
|
+
const watchPath = git.repo.isReftableSync(repository)
|
|
258
|
+
? path.join(repository.gitDir, "reftable")
|
|
259
|
+
: repository.headPath;
|
|
243
260
|
|
|
244
261
|
try {
|
|
245
|
-
this.#gitWatcher = fs.watch(
|
|
262
|
+
this.#gitWatcher = fs.watch(watchPath, () => {
|
|
246
263
|
this.#invalidateGitCaches();
|
|
247
264
|
if (this.#onBranchChange) {
|
|
248
265
|
this.#onBranchChange();
|
|
@@ -267,15 +284,18 @@ export class StatusLineComponent implements Component {
|
|
|
267
284
|
#invalidateGitCaches(): void {
|
|
268
285
|
this.#cachedBranch = undefined;
|
|
269
286
|
this.#cachedBranchRepoId = undefined;
|
|
287
|
+
this.#cachedBranchCwd = undefined;
|
|
270
288
|
this.#cachedPrContext = undefined;
|
|
271
289
|
}
|
|
272
290
|
#getCurrentBranch(): string | null {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
if (this.#cachedBranch !== undefined && this.#cachedBranchRepoId === gitHeadPath) {
|
|
291
|
+
const cwd = getProjectDir();
|
|
292
|
+
if (this.#cachedBranch !== undefined && this.#cachedBranchCwd === cwd) {
|
|
276
293
|
return this.#cachedBranch;
|
|
277
294
|
}
|
|
278
295
|
|
|
296
|
+
const head = git.head.resolveSync(cwd);
|
|
297
|
+
const gitHeadPath = head?.headPath ?? null;
|
|
298
|
+
this.#cachedBranchCwd = cwd;
|
|
279
299
|
this.#cachedBranchRepoId = gitHeadPath;
|
|
280
300
|
if (!head) {
|
|
281
301
|
this.#cachedBranch = null;
|
|
@@ -503,24 +523,79 @@ export class StatusLineComponent implements Component {
|
|
|
503
523
|
this.#nonMessageInputsKey = inputsKey;
|
|
504
524
|
}
|
|
505
525
|
|
|
506
|
-
// 2) Message tokens — incremental. The sidecar cache lives
|
|
507
|
-
// message object
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
// mismatch. The LAST message is always recomputed because it
|
|
513
|
-
// may still be growing during streaming.
|
|
514
|
-
let messagesTokens = 0;
|
|
515
|
-
const lastIdx = messages.length - 1;
|
|
516
|
-
for (let i = 0; i < messages.length; i++) {
|
|
517
|
-
messagesTokens += i === lastIdx ? estimateTokens(messages[i]) : tokensForMessage(messages[i]);
|
|
518
|
-
}
|
|
526
|
+
// 2) Message tokens — incremental rolling total. The sidecar cache lives
|
|
527
|
+
// on each stable message object (all but the current tail). Normal
|
|
528
|
+
// streaming turns only recompute the last message and newly appended
|
|
529
|
+
// entries. Full rebuild only when the message array is replaced,
|
|
530
|
+
// shrinks, or the recently-promoted stable tail mutates in place.
|
|
531
|
+
const messagesTokens = this.#getCachedMessageTokens(messages);
|
|
519
532
|
|
|
520
533
|
const usedTokens = this.#nonMessageTokensCache + messagesTokens;
|
|
521
534
|
return { usedTokens, contextWindow };
|
|
522
535
|
}
|
|
523
536
|
|
|
537
|
+
#getCachedMessageTokens(messages: readonly AgentMessage[]): number {
|
|
538
|
+
const cache = this.#messageTokenTotalsCache;
|
|
539
|
+
if (!cache || cache.messagesRef !== messages || messages.length <= cache.stableCount) {
|
|
540
|
+
return this.#rebuildMessageTokenTotals(messages);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let stableTokens = cache.stableTokens;
|
|
544
|
+
let stableCount = cache.stableCount;
|
|
545
|
+
const stableLimit = Math.max(0, messages.length - 1);
|
|
546
|
+
|
|
547
|
+
if (
|
|
548
|
+
cache.lastStableMessage &&
|
|
549
|
+
stableCount > 0 &&
|
|
550
|
+
messages[stableCount - 1] === cache.lastStableMessage &&
|
|
551
|
+
cache.lastStableFingerprint !== undefined &&
|
|
552
|
+
cache.lastStableFingerprint !== messageFingerprint(cache.lastStableMessage)
|
|
553
|
+
) {
|
|
554
|
+
return this.#rebuildMessageTokenTotals(messages);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
while (stableCount < stableLimit) {
|
|
558
|
+
const promoted = messages[stableCount]!;
|
|
559
|
+
stableTokens += tokensForMessage(promoted);
|
|
560
|
+
stableCount++;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const lastStableMessage = stableCount > 0 ? messages[stableCount - 1] : undefined;
|
|
564
|
+
const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
|
|
565
|
+
const lastMessage = messages.at(-1);
|
|
566
|
+
const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
|
|
567
|
+
this.#messageTokenTotalsCache = {
|
|
568
|
+
messagesRef: messages,
|
|
569
|
+
stableCount,
|
|
570
|
+
stableTokens,
|
|
571
|
+
lastStableMessage,
|
|
572
|
+
lastStableFingerprint,
|
|
573
|
+
};
|
|
574
|
+
return stableTokens + lastTokens;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
#rebuildMessageTokenTotals(messages: readonly AgentMessage[]): number {
|
|
578
|
+
let stableTokens = 0;
|
|
579
|
+
const stableLimit = Math.max(0, messages.length - 1);
|
|
580
|
+
for (let i = 0; i < stableLimit; i++) {
|
|
581
|
+
stableTokens += tokensForMessage(messages[i]!);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const lastStableMessage = stableLimit > 0 ? messages[stableLimit - 1] : undefined;
|
|
585
|
+
const lastStableFingerprint = lastStableMessage ? messageFingerprint(lastStableMessage) : undefined;
|
|
586
|
+
const lastMessage = messages.at(-1);
|
|
587
|
+
const lastTokens = lastMessage ? estimateTokens(lastMessage) : 0;
|
|
588
|
+
|
|
589
|
+
this.#messageTokenTotalsCache = {
|
|
590
|
+
messagesRef: messages,
|
|
591
|
+
stableCount: stableLimit,
|
|
592
|
+
stableTokens,
|
|
593
|
+
lastStableMessage,
|
|
594
|
+
lastStableFingerprint,
|
|
595
|
+
};
|
|
596
|
+
return stableTokens + lastTokens;
|
|
597
|
+
}
|
|
598
|
+
|
|
524
599
|
/**
|
|
525
600
|
* Build an identity fingerprint for the non-message inputs (system prompt,
|
|
526
601
|
* tools, skills). When this changes, the non-message token cache must be
|
|
@@ -535,7 +610,11 @@ export class StatusLineComponent implements Component {
|
|
|
535
610
|
return `${modelId}|${sp.length}:${sp[0]?.length ?? 0}|${tools.length}|${skills.length}`;
|
|
536
611
|
}
|
|
537
612
|
|
|
538
|
-
#buildSegmentContext(
|
|
613
|
+
#buildSegmentContext(
|
|
614
|
+
width: number,
|
|
615
|
+
segmentOptions: StatusLineSettings["segmentOptions"],
|
|
616
|
+
includeContext: boolean,
|
|
617
|
+
): SegmentContext {
|
|
539
618
|
const state = this.session.state;
|
|
540
619
|
|
|
541
620
|
// Trigger background fetch (5-min TTL); render uses cached value
|
|
@@ -555,10 +634,13 @@ export class StatusLineComponent implements Component {
|
|
|
555
634
|
tokensPerSecond: this.#getTokensPerSecond(),
|
|
556
635
|
};
|
|
557
636
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
637
|
+
let contextTokens = 0;
|
|
638
|
+
let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
|
|
639
|
+
if (includeContext) {
|
|
640
|
+
const breakdown = this.getCachedContextBreakdown();
|
|
641
|
+
contextTokens = breakdown.usedTokens;
|
|
642
|
+
contextWindow = breakdown.contextWindow || contextWindow;
|
|
643
|
+
}
|
|
562
644
|
const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
563
645
|
|
|
564
646
|
return {
|
|
@@ -626,7 +708,9 @@ export class StatusLineComponent implements Component {
|
|
|
626
708
|
|
|
627
709
|
#buildStatusLine(width: number): string {
|
|
628
710
|
const effectiveSettings = this.#resolveSettings();
|
|
629
|
-
const
|
|
711
|
+
const includeContext =
|
|
712
|
+
hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
|
|
713
|
+
const ctx = this.#buildSegmentContext(width, effectiveSettings.segmentOptions, includeContext);
|
|
630
714
|
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
|
|
631
715
|
|
|
632
716
|
const bgAnsi = theme.getBgAnsi("statusLineBg");
|
|
@@ -467,6 +467,7 @@ export class InputController {
|
|
|
467
467
|
this.ctx.session.sessionId,
|
|
468
468
|
this.ctx.session.model,
|
|
469
469
|
provider => this.ctx.session.agent.metadataForProvider(provider),
|
|
470
|
+
this.ctx.titleSystemPrompt,
|
|
470
471
|
)
|
|
471
472
|
.then(async title => {
|
|
472
473
|
// Re-check: a concurrent attempt for an earlier message may have
|
|
@@ -46,9 +46,14 @@ import { theme } from "../theme/theme";
|
|
|
46
46
|
import type { InteractiveModeContext } from "../types";
|
|
47
47
|
import { groupBySource, parseRemoveArgs, readScopeFlag, showCommandMessage } from "./command-controller-shared";
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
const MCP_MANUAL_INPUT_PROVIDER_ID = "mcp";
|
|
50
|
+
const MCP_MANUAL_LOGIN_TIP = "Headless? Paste the redirect URL or code with /login <value>.";
|
|
51
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string, onTimeout?: () => void): Promise<T> {
|
|
50
52
|
const { promise: timeoutPromise, reject } = Promise.withResolvers<T>();
|
|
51
|
-
const timer = setTimeout(() =>
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
onTimeout?.();
|
|
55
|
+
reject(new Error(message));
|
|
56
|
+
}, timeoutMs);
|
|
52
57
|
return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
|
|
53
58
|
}
|
|
54
59
|
|
|
@@ -591,6 +596,15 @@ export class MCPCommandController {
|
|
|
591
596
|
const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
|
|
592
597
|
const resolvedClientSecret = clientSecret.trim() || undefined;
|
|
593
598
|
|
|
599
|
+
const manualInput = this.ctx.oauthManualInput;
|
|
600
|
+
if (manualInput.hasPending()) {
|
|
601
|
+
const pendingProvider = manualInput.pendingProviderId ?? "another provider";
|
|
602
|
+
throw new Error(
|
|
603
|
+
`OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
let manualInputClaim: { promise: Promise<string>; clear: (reason?: string) => void } | undefined;
|
|
607
|
+
const oauthTimeout = new AbortController();
|
|
594
608
|
try {
|
|
595
609
|
// Create OAuth flow
|
|
596
610
|
const flow = new MCPOAuthFlow(
|
|
@@ -620,6 +634,7 @@ export class MCPCommandController {
|
|
|
620
634
|
0,
|
|
621
635
|
),
|
|
622
636
|
);
|
|
637
|
+
block.addChild(new Text(theme.fg("muted", MCP_MANUAL_LOGIN_TIP), 1, 0));
|
|
623
638
|
block.addChild(new Spacer(1));
|
|
624
639
|
block.addChild(new Text(theme.fg("accent", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"), 1, 0));
|
|
625
640
|
// Try to open browser automatically
|
|
@@ -644,11 +659,29 @@ export class MCPCommandController {
|
|
|
644
659
|
onProgress: (message: string) => {
|
|
645
660
|
this.ctx.present([new Spacer(1), new Text(theme.fg("muted", message), 1, 0)]);
|
|
646
661
|
},
|
|
662
|
+
onManualCodeInput: () => {
|
|
663
|
+
if (manualInputClaim) return manualInputClaim.promise;
|
|
664
|
+
const pendingInput = manualInput.tryClaimInput(MCP_MANUAL_INPUT_PROVIDER_ID);
|
|
665
|
+
if (!pendingInput) {
|
|
666
|
+
const pendingProvider = manualInput.pendingProviderId ?? "another provider";
|
|
667
|
+
throw new Error(
|
|
668
|
+
`OAuth login already in progress for ${pendingProvider}. Complete or cancel it before starting MCP OAuth.`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
manualInputClaim = pendingInput;
|
|
672
|
+
return pendingInput.promise;
|
|
673
|
+
},
|
|
674
|
+
signal: oauthTimeout.signal,
|
|
647
675
|
},
|
|
648
676
|
);
|
|
649
677
|
|
|
650
678
|
// Execute OAuth flow with 5 minute timeout
|
|
651
|
-
const credentials = await withTimeout(
|
|
679
|
+
const credentials = await withTimeout(
|
|
680
|
+
flow.login(),
|
|
681
|
+
5 * 60 * 1000,
|
|
682
|
+
"OAuth flow timed out after 5 minutes",
|
|
683
|
+
() => oauthTimeout.abort("MCP OAuth flow timed out"),
|
|
684
|
+
);
|
|
652
685
|
|
|
653
686
|
this.ctx.present([
|
|
654
687
|
new Spacer(1),
|
|
@@ -687,6 +720,8 @@ export class MCPCommandController {
|
|
|
687
720
|
} else {
|
|
688
721
|
throw new Error(`OAuth authentication failed: ${errorMsg}`);
|
|
689
722
|
}
|
|
723
|
+
} finally {
|
|
724
|
+
manualInputClaim?.clear("Manual MCP OAuth input cleared");
|
|
690
725
|
}
|
|
691
726
|
}
|
|
692
727
|
|