@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3004 -2976
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/collab/host.d.ts +2 -2
  5. package/dist/types/collab/protocol.d.ts +4 -5
  6. package/dist/types/commands/launch.d.ts +3 -0
  7. package/dist/types/config/model-resolver.d.ts +11 -2
  8. package/dist/types/config/settings-schema.d.ts +12 -2
  9. package/dist/types/goals/runtime.d.ts +4 -1
  10. package/dist/types/modes/print-mode.d.ts +2 -0
  11. package/dist/types/session/agent-session.d.ts +13 -0
  12. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  13. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  14. package/dist/types/tools/index.d.ts +9 -1
  15. package/dist/types/utils/image-loading.d.ts +12 -0
  16. package/dist/types/utils/qrcode.d.ts +48 -0
  17. package/package.json +12 -12
  18. package/src/cli/args.ts +10 -1
  19. package/src/cli/flag-tables.ts +1 -0
  20. package/src/collab/host.ts +4 -4
  21. package/src/collab/protocol.ts +48 -15
  22. package/src/commands/launch.ts +3 -0
  23. package/src/config/config-file.ts +1 -1
  24. package/src/config/keybindings.ts +2 -2
  25. package/src/config/model-registry.ts +16 -4
  26. package/src/config/model-resolver.ts +193 -35
  27. package/src/config/settings-schema.ts +14 -2
  28. package/src/config/settings.ts +3 -3
  29. package/src/goals/runtime.ts +19 -7
  30. package/src/internal-urls/docs-index.generated.txt +1 -1
  31. package/src/main.ts +10 -2
  32. package/src/modes/components/oauth-selector.ts +31 -2
  33. package/src/modes/interactive-mode.ts +7 -3
  34. package/src/modes/print-mode.ts +5 -1
  35. package/src/prompts/advisor/advise-tool.md +3 -1
  36. package/src/prompts/advisor/system.md +55 -12
  37. package/src/prompts/tools/inspect-image.md +1 -1
  38. package/src/sdk.ts +26 -7
  39. package/src/session/agent-session.ts +103 -16
  40. package/src/slash-commands/builtin-registry.ts +29 -11
  41. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  42. package/src/thinking.ts +25 -5
  43. package/src/tools/index.ts +10 -1
  44. package/src/tools/inspect-image.ts +72 -9
  45. package/src/utils/file-mentions.ts +5 -2
  46. package/src/utils/image-loading.ts +58 -0
  47. 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(selector: string): RetryFallbackSelector | undefined {
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: this.#autoCompactionAbortController.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 { AutocompleteItem } from "@oh-my-pi/pi-tui";
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 [first = ""] = args.split(/\s+/, 1);
593
- if (first === "stop") {
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 (first === "status") {
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 view = first === "view";
637
+ const knownStartVerb = verb === "start" || verb === "view";
638
+ const view = verb === "view";
624
639
  if (ctx.collabHost) {
625
- ctx.showStatus(
626
- collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view),
627
- { dim: false },
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 = first === "start" || view ? args.slice(first.length).trim() : args;
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.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false });
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: "xhigh",
35
+ label: "max",
36
36
  description: "Maximum reasoning (~32k tokens)",
37
37
  },
38
38
  };
39
39
 
40
- const THINKING_LEVELS = new Set<string>([ThinkingLevel.Inherit, ThinkingLevel.Off, ...THINKING_EFFORTS]);
41
- const EFFORT_LEVELS = new Set<string>(THINKING_EFFORTS);
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 value !== undefined && value !== null && EFFORT_LEVELS.has(value) ? (value as Effort) : undefined;
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 value !== undefined && value !== null && THINKING_LEVELS.has(value) ? (value as ThinkingLevel) : undefined;
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
 
@@ -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
- imageInput = await loadImageInput({
134
- path: params.path,
135
- cwd: this.session.cwd,
136
- autoResize: this.session.settings.get("images.autoResize"),
137
- maxBytes: MAX_IMAGE_INPUT_BYTES,
138
- excludeWebP: webpExclusionForModel(model),
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 cleaned = sanitizeMentionPath(match[1]);
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);