@oh-my-pi/pi-coding-agent 16.0.8 → 16.0.10
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 +33 -0
- package/dist/cli.js +3004 -2976
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/collab/host.d.ts +2 -2
- package/dist/types/collab/protocol.d.ts +4 -5
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +11 -2
- package/dist/types/config/settings-schema.d.ts +12 -2
- package/dist/types/goals/runtime.d.ts +4 -1
- package/dist/types/modes/print-mode.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
- package/dist/types/tools/index.d.ts +9 -1
- package/dist/types/utils/image-loading.d.ts +12 -0
- package/dist/types/utils/qrcode.d.ts +48 -0
- package/package.json +12 -12
- package/src/cli/args.ts +10 -1
- package/src/cli/flag-tables.ts +1 -0
- package/src/collab/host.ts +4 -4
- package/src/collab/protocol.ts +48 -15
- package/src/commands/launch.ts +3 -0
- package/src/config/config-file.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-registry.ts +16 -4
- package/src/config/model-resolver.ts +193 -35
- package/src/config/settings-schema.ts +14 -2
- package/src/config/settings.ts +3 -3
- package/src/goals/runtime.ts +19 -7
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +10 -2
- package/src/modes/components/oauth-selector.ts +31 -2
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/print-mode.ts +5 -1
- package/src/prompts/advisor/advise-tool.md +3 -1
- package/src/prompts/advisor/system.md +55 -12
- package/src/prompts/tools/inspect-image.md +1 -1
- package/src/sdk.ts +26 -7
- package/src/session/agent-session.ts +103 -16
- package/src/slash-commands/builtin-registry.ts +29 -11
- package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
- package/src/thinking.ts +25 -5
- package/src/tools/index.ts +10 -1
- package/src/tools/inspect-image.ts +72 -9
- package/src/utils/file-mentions.ts +5 -2
- package/src/utils/image-loading.ts +58 -0
- package/src/utils/qrcode.ts +535 -0
|
@@ -116,6 +116,7 @@ import {
|
|
|
116
116
|
prompt,
|
|
117
117
|
relativePathWithinRoot,
|
|
118
118
|
Snowflake,
|
|
119
|
+
withTimeout,
|
|
119
120
|
} from "@oh-my-pi/pi-utils";
|
|
120
121
|
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
121
122
|
import {
|
|
@@ -235,6 +236,7 @@ import {
|
|
|
235
236
|
AUTO_THINKING,
|
|
236
237
|
type ConfiguredThinkingLevel,
|
|
237
238
|
clampAutoThinkingEffort,
|
|
239
|
+
parseConfiguredThinkingLevel,
|
|
238
240
|
resolveProvisionalAutoLevel,
|
|
239
241
|
resolveThinkingLevelForModel,
|
|
240
242
|
shouldDisableReasoning,
|
|
@@ -511,6 +513,13 @@ export interface AgentSessionConfig {
|
|
|
511
513
|
advisorReadOnlyTools?: AgentTool[];
|
|
512
514
|
/** Preloaded watchdog prompt content for the advisor. */
|
|
513
515
|
advisorWatchdogPrompt?: string;
|
|
516
|
+
/**
|
|
517
|
+
* Disconnect this session's OWNED MCP manager on dispose. Provided only when
|
|
518
|
+
* the session created the manager (top-level sessions); subagents reuse a
|
|
519
|
+
* parent's manager via `options.mcpManager` and omit this so a child's
|
|
520
|
+
* teardown never tears down the shared servers.
|
|
521
|
+
*/
|
|
522
|
+
disconnectOwnedMcpManager?: () => Promise<void>;
|
|
514
523
|
}
|
|
515
524
|
|
|
516
525
|
/** Options for AgentSession.prompt() */
|
|
@@ -664,10 +673,16 @@ interface ActiveRetryFallbackState {
|
|
|
664
673
|
pinned: boolean;
|
|
665
674
|
}
|
|
666
675
|
|
|
667
|
-
function parseRetryFallbackSelector(
|
|
676
|
+
function parseRetryFallbackSelector(
|
|
677
|
+
selector: string,
|
|
678
|
+
modelLookup?: { find(provider: string, id: string): Model | undefined },
|
|
679
|
+
): RetryFallbackSelector | undefined {
|
|
668
680
|
const trimmed = selector.trim();
|
|
669
681
|
if (!trimmed) return undefined;
|
|
670
|
-
const parsed = parseModelString(trimmed
|
|
682
|
+
const parsed = parseModelString(trimmed, {
|
|
683
|
+
allowMaxAlias: true,
|
|
684
|
+
isLiteralModelId: (provider, id) => modelLookup?.find(provider, id) !== undefined,
|
|
685
|
+
});
|
|
671
686
|
if (!parsed) return undefined;
|
|
672
687
|
return {
|
|
673
688
|
raw: trimmed,
|
|
@@ -1195,6 +1210,7 @@ export class AgentSession {
|
|
|
1195
1210
|
| undefined;
|
|
1196
1211
|
#getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
|
|
1197
1212
|
#reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
|
|
1213
|
+
#disconnectOwnedMcpManager: (() => Promise<void>) | undefined;
|
|
1198
1214
|
#requestedToolNames: ReadonlySet<string> | undefined;
|
|
1199
1215
|
#baseSystemPrompt: string[];
|
|
1200
1216
|
/**
|
|
@@ -1561,6 +1577,7 @@ export class AgentSession {
|
|
|
1561
1577
|
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
|
|
1562
1578
|
this.#getMcpServerInstructions = config.getMcpServerInstructions;
|
|
1563
1579
|
this.#reloadSshTool = config.reloadSshTool;
|
|
1580
|
+
this.#disconnectOwnedMcpManager = config.disconnectOwnedMcpManager;
|
|
1564
1581
|
this.#baseSystemPrompt = this.agent.state.systemPrompt;
|
|
1565
1582
|
this.#promptModelKey = this.#currentPromptModelKey();
|
|
1566
1583
|
this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
|
|
@@ -4044,6 +4061,30 @@ export class AgentSession {
|
|
|
4044
4061
|
this.#releasePowerAssertion();
|
|
4045
4062
|
await this.sessionManager.close();
|
|
4046
4063
|
this.#closeAllProviderSessions("dispose");
|
|
4064
|
+
// Disconnect the MCP manager this session OWNS so its stdio servers are
|
|
4065
|
+
// not orphaned at exit. Best-effort: a failure here must never throw out
|
|
4066
|
+
// of dispose. Only owning (top-level) sessions provide this callback;
|
|
4067
|
+
// subagents reuse a parent's manager and must not tear it down. Idempotent
|
|
4068
|
+
// with the deferred-discovery disconnect in `createAgentSession`.
|
|
4069
|
+
//
|
|
4070
|
+
// BOUNDED: an owned manager may hold an HTTP/SSE server whose session-
|
|
4071
|
+
// termination DELETE blocks up to the MCP request timeout (30s default,
|
|
4072
|
+
// unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()`
|
|
4073
|
+
// unbounded would stall /exit and print-mode shutdown on a broken remote
|
|
4074
|
+
// endpoint. Race it against a short deadline — stdio close (the subprocess
|
|
4075
|
+
// reap this targets) completes well within the bound; a slow transport
|
|
4076
|
+
// close is left to finish detached. Mirrors the bounded async-job teardown.
|
|
4077
|
+
if (this.#disconnectOwnedMcpManager) {
|
|
4078
|
+
try {
|
|
4079
|
+
await withTimeout(
|
|
4080
|
+
this.#disconnectOwnedMcpManager(),
|
|
4081
|
+
3_000,
|
|
4082
|
+
"Timed out disconnecting owned MCP manager during dispose",
|
|
4083
|
+
);
|
|
4084
|
+
} catch (error) {
|
|
4085
|
+
logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4047
4088
|
// Flush the retain queue BEFORE clearing the session's pointer so
|
|
4048
4089
|
// `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
|
|
4049
4090
|
// Reversed, the spliced batch survives just long enough to fail the
|
|
@@ -4938,6 +4979,24 @@ export class AgentSession {
|
|
|
4938
4979
|
return this.agent.state.messages;
|
|
4939
4980
|
}
|
|
4940
4981
|
|
|
4982
|
+
/** Latest image attachments addressable by tools as `Image #N` or `attachment://N`. */
|
|
4983
|
+
getImageAttachments(): { label: string; uri: string; image: ImageContent }[] {
|
|
4984
|
+
for (let i = this.agent.state.messages.length - 1; i >= 0; i--) {
|
|
4985
|
+
const message = this.agent.state.messages[i];
|
|
4986
|
+
if (!message || (message.role !== "user" && message.role !== "developer") || !Array.isArray(message.content)) {
|
|
4987
|
+
continue;
|
|
4988
|
+
}
|
|
4989
|
+
const images = message.content.filter((part): part is ImageContent => part.type === "image");
|
|
4990
|
+
if (images.length === 0) continue;
|
|
4991
|
+
return images.map((image, index) => ({
|
|
4992
|
+
label: `Image #${index + 1}`,
|
|
4993
|
+
uri: `attachment://${index + 1}`,
|
|
4994
|
+
image,
|
|
4995
|
+
}));
|
|
4996
|
+
}
|
|
4997
|
+
return [];
|
|
4998
|
+
}
|
|
4999
|
+
|
|
4941
5000
|
buildDisplaySessionContext(): SessionContext {
|
|
4942
5001
|
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
|
|
4943
5002
|
}
|
|
@@ -7375,7 +7434,7 @@ export class AgentSession {
|
|
|
7375
7434
|
throw new Error("Compaction already in progress");
|
|
7376
7435
|
}
|
|
7377
7436
|
this.#disconnectFromAgent();
|
|
7378
|
-
await this.abort();
|
|
7437
|
+
await this.abort({ goalReason: "internal" });
|
|
7379
7438
|
const compactionAbortController = new AbortController();
|
|
7380
7439
|
this.#compactionAbortController = compactionAbortController;
|
|
7381
7440
|
|
|
@@ -7543,6 +7602,10 @@ export class AgentSession {
|
|
|
7543
7602
|
const newEntries = this.sessionManager.getEntries();
|
|
7544
7603
|
const sessionContext = this.buildDisplaySessionContext();
|
|
7545
7604
|
this.agent.replaceMessages(sessionContext.messages);
|
|
7605
|
+
// Compaction discarded the conversation history that carried the approved
|
|
7606
|
+
// plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
|
|
7607
|
+
// the plan from disk and re-injects it on the next turn (issue #1246).
|
|
7608
|
+
this.#planReferenceSent = false;
|
|
7546
7609
|
this.#advisorRuntime?.reset();
|
|
7547
7610
|
this.#syncTodoPhasesFromBranch();
|
|
7548
7611
|
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
@@ -7725,7 +7788,17 @@ export class AgentSession {
|
|
|
7725
7788
|
await this.sessionManager.flush();
|
|
7726
7789
|
this.#cancelOwnAsyncJobs();
|
|
7727
7790
|
await this.sessionManager.newSession(previousSessionFile ? { parentSession: previousSessionFile } : undefined);
|
|
7791
|
+
// agent.reset() clears the core steering/follow-up queues. Preserve any queued
|
|
7792
|
+
// steers/follow-ups (RPC/SDK steer()/followUp() issued during the handoff, or a
|
|
7793
|
+
// pre-loader TUI steer) so they survive into the post-handoff session instead of
|
|
7794
|
+
// being silently dropped. Capture is synchronous immediately before reset and
|
|
7795
|
+
// restore is synchronous immediately after — no await gap — so a steer arriving
|
|
7796
|
+
// later (during ensureOnDisk/Bun.write below) appends to the restored queue
|
|
7797
|
+
// rather than being clobbered.
|
|
7798
|
+
const preservedSteering = this.agent.peekSteeringQueue().slice();
|
|
7799
|
+
const preservedFollowUp = this.agent.peekFollowUpQueue().slice();
|
|
7728
7800
|
this.agent.reset();
|
|
7801
|
+
this.agent.replaceQueues(preservedSteering, preservedFollowUp);
|
|
7729
7802
|
this.#freshProviderSessionId = undefined;
|
|
7730
7803
|
this.#syncAgentSessionId();
|
|
7731
7804
|
this.#rekeyHindsightMemoryForCurrentSessionId();
|
|
@@ -8781,14 +8854,20 @@ export class AgentSession {
|
|
|
8781
8854
|
const existingRoleValue = this.settings.getModelRole(role);
|
|
8782
8855
|
if (!existingRoleValue) return modelKey;
|
|
8783
8856
|
|
|
8784
|
-
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings
|
|
8857
|
+
const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, {
|
|
8858
|
+
isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined,
|
|
8859
|
+
});
|
|
8785
8860
|
return formatModelSelectorValue(modelKey, thinkingLevel);
|
|
8786
8861
|
}
|
|
8787
8862
|
#resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
|
|
8788
8863
|
const configuredTarget = currentModel.contextPromotionTarget?.trim();
|
|
8789
8864
|
if (!configuredTarget) return undefined;
|
|
8790
8865
|
|
|
8791
|
-
const parsed = parseModelString(configuredTarget
|
|
8866
|
+
const parsed = parseModelString(configuredTarget, {
|
|
8867
|
+
allowMaxAlias: true,
|
|
8868
|
+
isLiteralModelId: (provider, id) =>
|
|
8869
|
+
availableModels.some(model => model.provider === provider && model.id === id),
|
|
8870
|
+
});
|
|
8792
8871
|
if (parsed) {
|
|
8793
8872
|
const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
|
|
8794
8873
|
if (explicitModel) return explicitModel;
|
|
@@ -9083,7 +9162,6 @@ export class AgentSession {
|
|
|
9083
9162
|
);
|
|
9084
9163
|
}
|
|
9085
9164
|
}
|
|
9086
|
-
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9087
9165
|
// Abort any older auto-compaction before installing this run's controller.
|
|
9088
9166
|
this.#autoCompactionAbortController?.abort();
|
|
9089
9167
|
const autoCompactionAbortController = new AbortController();
|
|
@@ -9091,11 +9169,16 @@ export class AgentSession {
|
|
|
9091
9169
|
const autoCompactionSignal = autoCompactionAbortController.signal;
|
|
9092
9170
|
|
|
9093
9171
|
try {
|
|
9172
|
+
// Emit start AFTER the controller is installed so isCompacting is already true
|
|
9173
|
+
// for any listener — and for input routed during this emit's event-loop yield:
|
|
9174
|
+
// a message typed as the compaction loader appears must land in the compaction
|
|
9175
|
+
// queue, not the core steering queue (which handoff's agent.reset() would wipe).
|
|
9176
|
+
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9094
9177
|
if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
|
|
9095
9178
|
const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
|
|
9096
9179
|
const handoffResult = await this.handoff(handoffFocus, {
|
|
9097
9180
|
autoTriggered: true,
|
|
9098
|
-
signal:
|
|
9181
|
+
signal: autoCompactionSignal,
|
|
9099
9182
|
});
|
|
9100
9183
|
if (!handoffResult) {
|
|
9101
9184
|
const aborted = autoCompactionSignal.aborted;
|
|
@@ -9409,6 +9492,10 @@ export class AgentSession {
|
|
|
9409
9492
|
const newEntries = this.sessionManager.getEntries();
|
|
9410
9493
|
const sessionContext = this.buildDisplaySessionContext();
|
|
9411
9494
|
this.agent.replaceMessages(sessionContext.messages);
|
|
9495
|
+
// Compaction discarded the conversation history that carried the approved
|
|
9496
|
+
// plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
|
|
9497
|
+
// the plan from disk and re-injects it on the next turn (issue #1246).
|
|
9498
|
+
this.#planReferenceSent = false;
|
|
9412
9499
|
this.#advisorRuntime?.reset();
|
|
9413
9500
|
this.#syncTodoPhasesFromBranch();
|
|
9414
9501
|
this.#closeCodexProviderSessionsForHistoryRewrite();
|
|
@@ -9523,12 +9610,12 @@ export class AgentSession {
|
|
|
9523
9610
|
triggerContextTokens?: number,
|
|
9524
9611
|
): Promise<CompactionCheckResult | "fallback"> {
|
|
9525
9612
|
const action = "shake";
|
|
9526
|
-
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9527
9613
|
this.#autoCompactionAbortController?.abort();
|
|
9528
9614
|
const controller = new AbortController();
|
|
9529
9615
|
this.#autoCompactionAbortController = controller;
|
|
9530
9616
|
const signal = controller.signal;
|
|
9531
9617
|
try {
|
|
9618
|
+
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
|
|
9532
9619
|
const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
|
|
9533
9620
|
if (signal.aborted) {
|
|
9534
9621
|
await this.#emitSessionEvent({
|
|
@@ -9826,7 +9913,7 @@ export class AgentSession {
|
|
|
9826
9913
|
this.configWarnings.push(msg);
|
|
9827
9914
|
continue;
|
|
9828
9915
|
}
|
|
9829
|
-
const parsed = parseRetryFallbackSelector(selectorStr);
|
|
9916
|
+
const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
|
|
9830
9917
|
if (!parsed) {
|
|
9831
9918
|
const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
|
|
9832
9919
|
logger.warn(msg);
|
|
@@ -9849,7 +9936,7 @@ export class AgentSession {
|
|
|
9849
9936
|
|
|
9850
9937
|
#getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
|
|
9851
9938
|
const configuredSelector = this.settings.getModelRole(role);
|
|
9852
|
-
return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
|
|
9939
|
+
return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
|
|
9853
9940
|
}
|
|
9854
9941
|
|
|
9855
9942
|
#clearActiveRetryFallback(): void {
|
|
@@ -9870,7 +9957,7 @@ export class AgentSession {
|
|
|
9870
9957
|
}
|
|
9871
9958
|
|
|
9872
9959
|
#resolveRetryFallbackRole(currentSelector: string): string | undefined {
|
|
9873
|
-
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
|
9960
|
+
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
|
9874
9961
|
if (!parsedCurrent) return undefined;
|
|
9875
9962
|
const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
|
|
9876
9963
|
const currentPlainSelector = this.model
|
|
@@ -9902,7 +9989,7 @@ export class AgentSession {
|
|
|
9902
9989
|
const chain = [primarySelector];
|
|
9903
9990
|
const seen = new Set<string>([primarySelector.raw]);
|
|
9904
9991
|
for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
|
|
9905
|
-
const parsed = parseRetryFallbackSelector(selector);
|
|
9992
|
+
const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
|
|
9906
9993
|
if (!parsed || seen.has(parsed.raw)) continue;
|
|
9907
9994
|
seen.add(parsed.raw);
|
|
9908
9995
|
chain.push(parsed);
|
|
@@ -9913,7 +10000,7 @@ export class AgentSession {
|
|
|
9913
10000
|
#findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
|
|
9914
10001
|
const chain = this.#getRetryFallbackEffectiveChain(role);
|
|
9915
10002
|
if (chain.length <= 1) return [];
|
|
9916
|
-
const parsedCurrent = parseRetryFallbackSelector(currentSelector);
|
|
10003
|
+
const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
|
|
9917
10004
|
const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
|
|
9918
10005
|
const currentPlainSelector =
|
|
9919
10006
|
this.model && parsedCurrent
|
|
@@ -10010,7 +10097,7 @@ export class AgentSession {
|
|
|
10010
10097
|
originalThinkingLevel,
|
|
10011
10098
|
lastAppliedFallbackThinkingLevel,
|
|
10012
10099
|
} = this.#activeRetryFallback;
|
|
10013
|
-
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
|
|
10100
|
+
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry);
|
|
10014
10101
|
if (!originalSelector) {
|
|
10015
10102
|
this.#clearActiveRetryFallback();
|
|
10016
10103
|
return;
|
|
@@ -10960,7 +11047,7 @@ export class AgentSession {
|
|
|
10960
11047
|
}
|
|
10961
11048
|
|
|
10962
11049
|
this.#disconnectFromAgent();
|
|
10963
|
-
await this.abort();
|
|
11050
|
+
await this.abort({ goalReason: "internal" });
|
|
10964
11051
|
|
|
10965
11052
|
// Flush pending writes before switching so restore snapshots reflect committed state.
|
|
10966
11053
|
await this.sessionManager.flush();
|
|
@@ -11062,7 +11149,7 @@ export class AgentSession {
|
|
|
11062
11149
|
const hasServiceTierEntry = this.sessionManager
|
|
11063
11150
|
.getBranch()
|
|
11064
11151
|
.some(entry => entry.type === "service_tier_change");
|
|
11065
|
-
const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
|
|
11152
|
+
const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
|
|
11066
11153
|
const configuredServiceTier = this.settings.get("serviceTier");
|
|
11067
11154
|
// Session log entries store only concrete levels. When `auto` has resolved
|
|
11068
11155
|
// for a turn, the persisted context may already carry that concrete level
|
|
@@ -3,7 +3,7 @@ import * as os from "node:os";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
|
|
5
5
|
import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
|
|
6
|
-
import type
|
|
6
|
+
import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
|
|
7
7
|
import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
|
|
8
8
|
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
|
|
9
9
|
import { CollabHost } from "../collab/host";
|
|
@@ -30,6 +30,7 @@ import type { AgentSession, FreshSessionResult } from "../session/agent-session"
|
|
|
30
30
|
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
|
31
31
|
import { urlHyperlinkAlways } from "../tui";
|
|
32
32
|
import { getChangelogPath, parseChangelog } from "../utils/changelog";
|
|
33
|
+
import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
|
|
33
34
|
import { buildContextReportText } from "./helpers/context-report";
|
|
34
35
|
import { formatDuration } from "./helpers/format";
|
|
35
36
|
import { createMarketplaceManager } from "./helpers/marketplace-manager";
|
|
@@ -99,6 +100,19 @@ function collabLinkHint(host: CollabHost, heading: string, view = false): string
|
|
|
99
100
|
].join("\n");
|
|
100
101
|
}
|
|
101
102
|
|
|
103
|
+
function showCollabQrCode(ctx: InteractiveModeContext, webLink: string): void {
|
|
104
|
+
try {
|
|
105
|
+
ctx.present([new Spacer(1), new CollabQrCodeComponent(webLink)]);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
ctx.showError(`Failed to render collab QR code: ${errorMessage(err)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function showCollabLink(ctx: InteractiveModeContext, host: CollabHost, heading: string, view = false): void {
|
|
112
|
+
ctx.showStatus(collabLinkHint(host, heading, view), { dim: false });
|
|
113
|
+
showCollabQrCode(ctx, view ? host.webViewLink : host.webLink);
|
|
114
|
+
}
|
|
115
|
+
|
|
102
116
|
function formatFreshSessionResult(result: FreshSessionResult): string {
|
|
103
117
|
const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
|
|
104
118
|
return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
|
|
@@ -589,8 +603,8 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
589
603
|
const ctx = runtime.ctx;
|
|
590
604
|
ctx.editor.setText("");
|
|
591
605
|
const args = command.args.trim();
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
606
|
+
const { verb, rest } = parseSubcommand(args);
|
|
607
|
+
if (verb === "stop") {
|
|
594
608
|
if (!ctx.collabHost) {
|
|
595
609
|
ctx.showStatus("Not hosting a collab session");
|
|
596
610
|
return;
|
|
@@ -599,7 +613,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
599
613
|
ctx.showStatus("Collab stopped");
|
|
600
614
|
return;
|
|
601
615
|
}
|
|
602
|
-
if (
|
|
616
|
+
if (verb === "status") {
|
|
603
617
|
if (ctx.collabHost) {
|
|
604
618
|
const names = ctx.collabHost.participants.map(p =>
|
|
605
619
|
p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name,
|
|
@@ -620,15 +634,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
620
634
|
ctx.showError("Already in a collab session as a guest (/leave first)");
|
|
621
635
|
return;
|
|
622
636
|
}
|
|
623
|
-
const
|
|
637
|
+
const knownStartVerb = verb === "start" || verb === "view";
|
|
638
|
+
const view = verb === "view";
|
|
624
639
|
if (ctx.collabHost) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
640
|
+
showCollabLink(
|
|
641
|
+
ctx,
|
|
642
|
+
ctx.collabHost,
|
|
643
|
+
view ? "Read-only collab session active" : "Collab session active",
|
|
644
|
+
view,
|
|
628
645
|
);
|
|
629
646
|
return;
|
|
630
647
|
}
|
|
631
|
-
const explicitUrl =
|
|
648
|
+
const explicitUrl = knownStartVerb ? rest : args;
|
|
632
649
|
const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || "";
|
|
633
650
|
if (!relayInput) {
|
|
634
651
|
ctx.showError(
|
|
@@ -638,15 +655,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
638
655
|
}
|
|
639
656
|
// Scheme-less relay args default to wss (ws:// must be spelled out for localhost).
|
|
640
657
|
const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`;
|
|
658
|
+
const webUrl = ctx.settings.get("collab.webUrl") || "";
|
|
641
659
|
const host = new CollabHost(ctx);
|
|
642
660
|
try {
|
|
643
|
-
await host.start(relayUrl);
|
|
661
|
+
await host.start(relayUrl, webUrl);
|
|
644
662
|
} catch (err) {
|
|
645
663
|
ctx.showError(`Failed to start collab session: ${errorMessage(err)}`);
|
|
646
664
|
return;
|
|
647
665
|
}
|
|
648
666
|
ctx.collabHost = host;
|
|
649
|
-
ctx
|
|
667
|
+
showCollabLink(ctx, host, "Collab session started!", view);
|
|
650
668
|
},
|
|
651
669
|
},
|
|
652
670
|
{
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import { fgOrPlain } from "../../modes/theme/theme";
|
|
3
|
+
import { QrCode, renderQrHalfBlocks } from "../../utils/qrcode";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-shot transcript block that prints a collab browser-join URL as a
|
|
7
|
+
* scannable QR code. The symbol is encoded once at construction (byte mode,
|
|
8
|
+
* EC level M) and rendered as ANSI half-blocks; on terminals too narrow for
|
|
9
|
+
* the symbol it degrades to a one-line hint pointing at the printed URL.
|
|
10
|
+
*/
|
|
11
|
+
export class CollabQrCodeComponent implements Component {
|
|
12
|
+
readonly #lines: readonly string[];
|
|
13
|
+
readonly #minWidth: number;
|
|
14
|
+
|
|
15
|
+
constructor(readonly url: string) {
|
|
16
|
+
const rows = renderQrHalfBlocks(QrCode.encodeText(url, "M"));
|
|
17
|
+
this.#lines = rows.map(row => ` ${row}`);
|
|
18
|
+
this.#minWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row)), 0) + 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
render(width: number): readonly string[] {
|
|
22
|
+
if (width < this.#minWidth) {
|
|
23
|
+
const warning = `QR code hidden: terminal width ${width}; need ${this.#minWidth}. Use the browser URL above.`;
|
|
24
|
+
return [` ${fgOrPlain("warning", warning)}`];
|
|
25
|
+
}
|
|
26
|
+
return this.#lines;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/thinking.ts
CHANGED
|
@@ -32,26 +32,45 @@ const THINKING_LEVEL_METADATA: Record<ThinkingLevel, ThinkingLevelMetadata> = {
|
|
|
32
32
|
[ThinkingLevel.High]: { value: ThinkingLevel.High, label: "high", description: "Deep reasoning (~16k tokens)" },
|
|
33
33
|
[ThinkingLevel.XHigh]: {
|
|
34
34
|
value: ThinkingLevel.XHigh,
|
|
35
|
-
label: "
|
|
35
|
+
label: "max",
|
|
36
36
|
description: "Maximum reasoning (~32k tokens)",
|
|
37
37
|
},
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
const
|
|
41
|
-
|
|
40
|
+
const EFFORT_BY_SELECTOR: Readonly<Record<string, Effort>> = {
|
|
41
|
+
[Effort.Minimal]: Effort.Minimal,
|
|
42
|
+
[Effort.Low]: Effort.Low,
|
|
43
|
+
[Effort.Medium]: Effort.Medium,
|
|
44
|
+
[Effort.High]: Effort.High,
|
|
45
|
+
[Effort.XHigh]: Effort.XHigh,
|
|
46
|
+
max: Effort.XHigh,
|
|
47
|
+
};
|
|
48
|
+
const THINKING_LEVEL_BY_SELECTOR: Readonly<Record<string, ThinkingLevel>> = {
|
|
49
|
+
[ThinkingLevel.Inherit]: ThinkingLevel.Inherit,
|
|
50
|
+
[ThinkingLevel.Off]: ThinkingLevel.Off,
|
|
51
|
+
[ThinkingLevel.Minimal]: ThinkingLevel.Minimal,
|
|
52
|
+
[ThinkingLevel.Low]: ThinkingLevel.Low,
|
|
53
|
+
[ThinkingLevel.Medium]: ThinkingLevel.Medium,
|
|
54
|
+
[ThinkingLevel.High]: ThinkingLevel.High,
|
|
55
|
+
[ThinkingLevel.XHigh]: ThinkingLevel.XHigh,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getOwnSelector<T>(selectors: Readonly<Record<string, T>>, value: string | null | undefined): T | undefined {
|
|
59
|
+
return value === undefined || value === null || !Object.hasOwn(selectors, value) ? undefined : selectors[value];
|
|
60
|
+
}
|
|
42
61
|
|
|
43
62
|
/**
|
|
44
63
|
* Parses a provider-facing effort value.
|
|
45
64
|
*/
|
|
46
65
|
export function parseEffort(value: string | null | undefined): Effort | undefined {
|
|
47
|
-
return
|
|
66
|
+
return getOwnSelector(EFFORT_BY_SELECTOR, value);
|
|
48
67
|
}
|
|
49
68
|
|
|
50
69
|
/**
|
|
51
70
|
* Parses an agent-local thinking selector.
|
|
52
71
|
*/
|
|
53
72
|
export function parseThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined {
|
|
54
|
-
return
|
|
73
|
+
return getOwnSelector(THINKING_LEVEL_BY_SELECTOR, value);
|
|
55
74
|
}
|
|
56
75
|
|
|
57
76
|
/**
|
|
@@ -125,6 +144,7 @@ const AUTO_THINKING_METADATA: ConfiguredThinkingLevelMetadata = {
|
|
|
125
144
|
*/
|
|
126
145
|
export function parseConfiguredThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
|
|
127
146
|
if (value === AUTO_THINKING) return AUTO_THINKING;
|
|
147
|
+
if (value === "max") return ThinkingLevel.XHigh;
|
|
128
148
|
return parseThinkingLevel(value);
|
|
129
149
|
}
|
|
130
150
|
|
package/src/tools/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
2
2
|
import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
3
|
-
import type { FetchImpl, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import type { FetchImpl, ImageContent, Model, ToolChoice } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import type { AsyncJobManager } from "../async/job-manager";
|
|
6
6
|
import type { Rule } from "../capability/rule";
|
|
@@ -113,6 +113,13 @@ export type ContextFileEntry = {
|
|
|
113
113
|
depth?: number;
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
/** Image attachment handle exposed to tools for user-facing labels such as `Image #1`. */
|
|
117
|
+
export type ImageAttachmentEntry = {
|
|
118
|
+
label: string;
|
|
119
|
+
uri: string;
|
|
120
|
+
image: ImageContent;
|
|
121
|
+
};
|
|
122
|
+
|
|
116
123
|
export type {
|
|
117
124
|
DiscoverableTool,
|
|
118
125
|
DiscoverableToolSearchIndex,
|
|
@@ -353,6 +360,8 @@ export interface ToolSession {
|
|
|
353
360
|
/** Get the active OpenTelemetry config so subagent dispatch can forward
|
|
354
361
|
* the parent's tracer/hooks with the subagent's own identity stamped. */
|
|
355
362
|
getTelemetry?: () => AgentTelemetryConfig | undefined;
|
|
363
|
+
/** Return image attachments visible to tools for resolving labels such as `Image #1`. */
|
|
364
|
+
getImageAttachments?: () => ImageAttachmentEntry[];
|
|
356
365
|
}
|
|
357
366
|
|
|
358
367
|
export type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import { instrumentedCompleteSimple, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
|
|
3
|
-
import { type Api, completeSimple, type Model, type ToolExample } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { type Api, completeSimple, type ImageContent, type Model, type ToolExample } from "@oh-my-pi/pi-ai";
|
|
4
4
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { type } from "arktype";
|
|
6
6
|
import { extractTextContent } from "../commit/utils";
|
|
@@ -11,6 +11,7 @@ import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-sys
|
|
|
11
11
|
import {
|
|
12
12
|
ImageInputTooLargeError,
|
|
13
13
|
type LoadedImageInput,
|
|
14
|
+
loadImageAttachmentInput,
|
|
14
15
|
loadImageInput,
|
|
15
16
|
MAX_IMAGE_INPUT_BYTES,
|
|
16
17
|
webpExclusionForModel,
|
|
@@ -19,13 +20,62 @@ import type { ToolSession } from "./index";
|
|
|
19
20
|
import { ToolError } from "./tool-errors";
|
|
20
21
|
|
|
21
22
|
const inspectImageSchema = type({
|
|
22
|
-
path: type("string").describe("image path"),
|
|
23
|
+
path: type("string").describe("image file path, Image #N label, or attachment://N URI"),
|
|
23
24
|
question: type("string").describe("question about image"),
|
|
24
25
|
"+": "reject",
|
|
25
26
|
});
|
|
26
27
|
|
|
27
28
|
export type InspectImageParams = typeof inspectImageSchema.infer;
|
|
28
29
|
|
|
30
|
+
interface ImageAttachmentReference {
|
|
31
|
+
index: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const IMAGE_ATTACHMENT_REFERENCE_REGEX =
|
|
35
|
+
/^\s*(?:\[?Image #([1-9]\d*)(?:,[^\]\n]*)?\]?|(?:attachment|image):\/\/([1-9]\d*))\s*$/i;
|
|
36
|
+
|
|
37
|
+
function parseImageAttachmentReference(path: string): ImageAttachmentReference | null {
|
|
38
|
+
const match = IMAGE_ATTACHMENT_REFERENCE_REGEX.exec(path);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const rawIndex = match[1] ?? match[2];
|
|
41
|
+
if (!rawIndex) return null;
|
|
42
|
+
return { index: Number(rawIndex) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatAvailableImageAttachments(attachments: readonly { label: string; uri: string }[]): string {
|
|
46
|
+
if (attachments.length === 0) return "none";
|
|
47
|
+
return attachments.map(attachment => `${attachment.label} -> ${attachment.uri}`).join(", ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function loadAttachmentReferenceInput(options: {
|
|
51
|
+
path: string;
|
|
52
|
+
reference: ImageAttachmentReference;
|
|
53
|
+
attachments: readonly { label: string; uri: string; image: ImageContent }[];
|
|
54
|
+
autoResize: boolean;
|
|
55
|
+
excludeWebP: boolean | undefined;
|
|
56
|
+
}): Promise<LoadedImageInput | null> {
|
|
57
|
+
const attachment = options.attachments[options.reference.index - 1];
|
|
58
|
+
if (!attachment) {
|
|
59
|
+
const available = formatAvailableImageAttachments(options.attachments);
|
|
60
|
+
if (options.attachments.length === 0) {
|
|
61
|
+
throw new ToolError(
|
|
62
|
+
`No image attachments are available in this turn. path="${options.path}" must be a readable file path or attachment URI.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
throw new ToolError(
|
|
66
|
+
`Could not resolve image attachment '${options.path}'. Available image attachments: ${available}. Pass an attachment URI or a readable filesystem path.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return loadImageAttachmentInput({
|
|
70
|
+
image: attachment.image,
|
|
71
|
+
label: attachment.label,
|
|
72
|
+
uri: attachment.uri,
|
|
73
|
+
autoResize: options.autoResize,
|
|
74
|
+
maxBytes: MAX_IMAGE_INPUT_BYTES,
|
|
75
|
+
excludeWebP: options.excludeWebP,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
29
79
|
export interface InspectImageToolDetails {
|
|
30
80
|
model: string;
|
|
31
81
|
imagePath: string;
|
|
@@ -129,14 +179,27 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
|
|
|
129
179
|
}
|
|
130
180
|
|
|
131
181
|
let imageInput: LoadedImageInput | null;
|
|
182
|
+
const autoResize = this.session.settings.get("images.autoResize");
|
|
183
|
+
const excludeWebP = webpExclusionForModel(model);
|
|
184
|
+
const attachmentReference = parseImageAttachmentReference(params.path);
|
|
132
185
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
186
|
+
if (attachmentReference) {
|
|
187
|
+
imageInput = await loadAttachmentReferenceInput({
|
|
188
|
+
path: params.path,
|
|
189
|
+
reference: attachmentReference,
|
|
190
|
+
attachments: this.session.getImageAttachments?.() ?? [],
|
|
191
|
+
autoResize,
|
|
192
|
+
excludeWebP,
|
|
193
|
+
});
|
|
194
|
+
} else {
|
|
195
|
+
imageInput = await loadImageInput({
|
|
196
|
+
path: params.path,
|
|
197
|
+
cwd: this.session.cwd,
|
|
198
|
+
autoResize,
|
|
199
|
+
maxBytes: MAX_IMAGE_INPUT_BYTES,
|
|
200
|
+
excludeWebP,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
140
203
|
} catch (error) {
|
|
141
204
|
if (error instanceof ImageInputTooLargeError) {
|
|
142
205
|
throw new ToolError(error.message);
|
|
@@ -24,7 +24,7 @@ import { resolveReadPath } from "../tools/path-utils";
|
|
|
24
24
|
import { formatDimensionNote, resizeImage } from "./image-resize";
|
|
25
25
|
|
|
26
26
|
/** Regex to match @filepath patterns in text */
|
|
27
|
-
const FILE_MENTION_REGEX = /@([^\s@]+)/g;
|
|
27
|
+
const FILE_MENTION_REGEX = /@(?:"([^"]+)"|'([^']+)'|([^\s@]+))/g;
|
|
28
28
|
const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
|
|
29
29
|
const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
|
|
30
30
|
const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
|
|
@@ -168,7 +168,10 @@ export function extractFileMentions(text: string): string[] {
|
|
|
168
168
|
const index = match.index ?? 0;
|
|
169
169
|
if (!isMentionBoundary(text, index)) continue;
|
|
170
170
|
|
|
171
|
-
const
|
|
171
|
+
const rawPath = match[1] ?? match[2] ?? match[3];
|
|
172
|
+
if (!rawPath) continue;
|
|
173
|
+
|
|
174
|
+
const cleaned = match[1] !== undefined || match[2] !== undefined ? rawPath.trim() : sanitizeMentionPath(rawPath);
|
|
172
175
|
if (!cleaned) continue;
|
|
173
176
|
|
|
174
177
|
mentions.push(cleaned);
|