@oh-my-pi/pi-coding-agent 16.0.9 → 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 (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/cli.js +2822 -2872
  3. package/dist/types/collab/host.d.ts +2 -2
  4. package/dist/types/collab/protocol.d.ts +4 -5
  5. package/dist/types/config/model-resolver.d.ts +11 -2
  6. package/dist/types/config/settings-schema.d.ts +12 -2
  7. package/dist/types/session/agent-session.d.ts +13 -0
  8. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  9. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  10. package/dist/types/tools/index.d.ts +9 -1
  11. package/dist/types/utils/image-loading.d.ts +12 -0
  12. package/dist/types/utils/qrcode.d.ts +48 -0
  13. package/package.json +12 -12
  14. package/src/cli/args.ts +7 -1
  15. package/src/collab/host.ts +4 -4
  16. package/src/collab/protocol.ts +48 -15
  17. package/src/config/config-file.ts +1 -1
  18. package/src/config/keybindings.ts +2 -2
  19. package/src/config/model-registry.ts +16 -4
  20. package/src/config/model-resolver.ts +193 -35
  21. package/src/config/settings-schema.ts +14 -2
  22. package/src/config/settings.ts +3 -3
  23. package/src/internal-urls/docs-index.generated.txt +1 -1
  24. package/src/main.ts +2 -2
  25. package/src/modes/components/oauth-selector.ts +31 -2
  26. package/src/prompts/tools/inspect-image.md +1 -1
  27. package/src/sdk.ts +26 -7
  28. package/src/session/agent-session.ts +93 -14
  29. package/src/slash-commands/builtin-registry.ts +29 -11
  30. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  31. package/src/thinking.ts +25 -5
  32. package/src/tools/index.ts +10 -1
  33. package/src/tools/inspect-image.ts +72 -9
  34. package/src/utils/file-mentions.ts +5 -2
  35. package/src/utils/image-loading.ts +58 -0
  36. package/src/utils/qrcode.ts +535 -0
package/src/main.ts CHANGED
@@ -72,7 +72,7 @@ import { shouldShowStartupSplash } from "./startup-splash";
72
72
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "./system-prompt";
73
73
  import { createPersistedSubagentReviverFactory } from "./task/persisted-revive";
74
74
  import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
75
- import { AUTO_THINKING } from "./thinking";
75
+ import { AUTO_THINKING, parseConfiguredThinkingLevel } from "./thinking";
76
76
  import type { LspStartupServerInfo } from "./tools";
77
77
  import {
78
78
  getChangelogPath,
@@ -857,7 +857,7 @@ async function buildSessionOptions(
857
857
  if (scopedModels.length > 0) {
858
858
  // `auto` is a session-level concept only; per-scoped-model (Ctrl+P) thinking
859
859
  // overrides stay concrete, so coerce the auto default to "unset" here.
860
- const defaultThinkingLevelSetting = activeSettings.get("defaultThinkingLevel");
860
+ const defaultThinkingLevelSetting = parseConfiguredThinkingLevel(activeSettings.get("defaultThinkingLevel"));
861
861
  const defaultThinkingLevel =
862
862
  defaultThinkingLevelSetting === AUTO_THINKING ? undefined : defaultThinkingLevelSetting;
863
863
  options.scopedModels = scopedModels.map(scopedModel => ({
@@ -10,6 +10,7 @@ import {
10
10
  Spacer,
11
11
  TruncatedText,
12
12
  } from "@oh-my-pi/pi-tui";
13
+ import { settings } from "../../config/settings";
13
14
  import { theme } from "../../modes/theme/theme";
14
15
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
15
16
  import type { AuthStorage, CredentialOriginKind } from "../../session/auth-storage";
@@ -17,6 +18,20 @@ import { DynamicBorder } from "./dynamic-border";
17
18
 
18
19
  const OAUTH_SELECTOR_MAX_VISIBLE = 10;
19
20
 
21
+ /**
22
+ * Provider ids the user has disabled via settings. `/login` (login mode) hides
23
+ * these so a disabled provider's models stay out of reach end-to-end, mirroring
24
+ * the model picker's `disabledProviders` filtering. Reads the settings singleton
25
+ * defensively: it throws before `Settings.init()`, in which case nothing is disabled.
26
+ */
27
+ function getDisabledProviderIds(): ReadonlySet<string> {
28
+ try {
29
+ return new Set(settings.get("disabledProviders"));
30
+ } catch {
31
+ return new Set();
32
+ }
33
+ }
34
+
20
35
  /**
21
36
  * Rendered lines before the provider rows: top border, spacer, title, spacer
22
37
  * (must mirror the constructor's addChild order).
@@ -102,8 +117,22 @@ export class OAuthSelectorComponent extends Container {
102
117
 
103
118
  #loadProviders(): void {
104
119
  const providers = getOAuthProviders();
105
- this.#allProviders =
106
- this.#mode === "logout" ? providers.filter(provider => this.#hasSelectableAuth(provider.id)) : providers;
120
+ if (this.#mode === "logout") {
121
+ // Logout stays unfiltered by `disabledProviders`: a now-disabled
122
+ // provider may still hold stored credentials worth removing.
123
+ this.#allProviders = providers.filter(provider => this.#hasSelectableAuth(provider.id));
124
+ } else {
125
+ const disabled = getDisabledProviderIds();
126
+ // Hide a login entry when either its own id or the provider id it
127
+ // stores credentials under is disabled, so alias logins (e.g.
128
+ // `openai-codex-device` ⇒ `openai-codex`) disappear alongside the
129
+ // model provider they authenticate.
130
+ this.#allProviders = providers.filter(
131
+ provider =>
132
+ !disabled.has(provider.id) &&
133
+ !(provider.storeCredentialsAs && disabled.has(provider.storeCredentialsAs)),
134
+ );
135
+ }
107
136
  this.#filteredProviders = this.#allProviders;
108
137
  }
109
138
 
@@ -2,7 +2,7 @@ Inspects an image file with a vision-capable model and returns compact text anal
2
2
 
3
3
  <instruction>
4
4
  - Use this for image understanding tasks (OCR, UI/screenshot debugging, scene/object questions)
5
- - Provide `path` to the local image file
5
+ - Provide `path` as a local image file path, `Image #N` attachment label, or `attachment://N` URI
6
6
  - Write a specific `question`:
7
7
  - what to inspect
8
8
  - constraints (for example: "quote visible text verbatim", "only report confirmed findings")
package/src/sdk.ts CHANGED
@@ -144,6 +144,7 @@ import { AgentOutputManager } from "./task/output-manager";
144
144
  import {
145
145
  AUTO_THINKING,
146
146
  type ConfiguredThinkingLevel,
147
+ parseConfiguredThinkingLevel,
147
148
  parseThinkingLevel,
148
149
  resolveProvisionalAutoLevel,
149
150
  resolveThinkingLevelForModel,
@@ -1266,12 +1267,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1266
1267
  ? getRestorableSessionModels(existingSession.models, sessionManager.getLastModelChangeRole())
1267
1268
  : [];
1268
1269
  let restoredSessionModelIndex = -1;
1270
+ let restoredSessionThinkingLevel: ThinkingLevel | undefined;
1269
1271
  if (!hasExplicitModel && !model && sessionModelStrings.length > 0) {
1270
1272
  logger.time("restoreSessionModel", () => {
1271
1273
  let failedSessionModel: string | undefined;
1272
1274
  for (let i = 0; i < sessionModelStrings.length; i++) {
1273
1275
  const sessionModelStr = sessionModelStrings[i];
1274
- const parsedModel = parseModelString(sessionModelStr);
1276
+ const parsedModel = parseModelString(sessionModelStr, {
1277
+ allowMaxAlias: true,
1278
+ isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
1279
+ });
1275
1280
  if (!parsedModel) {
1276
1281
  failedSessionModel ??= sessionModelStr;
1277
1282
  continue;
@@ -1281,6 +1286,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1281
1286
  if (restoredModel && hasModelAuth(restoredModel)) {
1282
1287
  model = restoredModel;
1283
1288
  restoredSessionModelIndex = i;
1289
+ restoredSessionThinkingLevel = parsedModel.thinkingLevel;
1284
1290
  break;
1285
1291
  }
1286
1292
  failedSessionModel ??= sessionModelStr;
@@ -1305,15 +1311,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1305
1311
  const taskDepth = options.taskDepth ?? 0;
1306
1312
 
1307
1313
  // Resolves the session/agent thinking level using the same precedence we
1308
- // apply at startup: explicit option → persisted session entry → default
1309
- // role's explicit selector → selected model's defaultLevelglobal
1310
- // settings default. Run again after extension role reclaim so the final
1311
- // model's own defaults aren't masked by an earlier fallback model's.
1314
+ // apply at startup: explicit option → persisted session entry → restored
1315
+ // model selector suffix default role's explicit selector selected
1316
+ // model's defaultLevel → global settings default. Run again after extension
1317
+ // role reclaim so the final model's own defaults aren't masked by an earlier
1318
+ // fallback model's.
1312
1319
  const pickInitialThinkingLevel = (selectedModel: Model | undefined): ConfiguredThinkingLevel | undefined => {
1313
1320
  let level = options.thinkingLevel;
1314
1321
  if (level === undefined && hasExistingSession && hasThinkingEntry) {
1315
1322
  level = parseThinkingLevel(existingSession.thinkingLevel);
1316
1323
  }
1324
+ if (level === undefined && !hasThinkingEntry && restoredSessionThinkingLevel !== undefined) {
1325
+ level = restoredSessionThinkingLevel;
1326
+ }
1317
1327
  if (level === undefined && !hasExplicitModel && !hasThinkingEntry && defaultRoleSpec.explicitThinkingLevel) {
1318
1328
  level = defaultRoleSpec.thinkingLevel;
1319
1329
  }
@@ -1321,7 +1331,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1321
1331
  level = selectedModel.thinking.defaultLevel;
1322
1332
  }
1323
1333
  if (level === undefined) {
1324
- level = settings.get("defaultThinkingLevel");
1334
+ level = parseConfiguredThinkingLevel(settings.get("defaultThinkingLevel"));
1325
1335
  }
1326
1336
  return level;
1327
1337
  };
@@ -1533,6 +1543,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1533
1543
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
1534
1544
  getActiveModelString,
1535
1545
  getActiveModel: () => agent?.state.model ?? model,
1546
+ getImageAttachments: () => session?.getImageAttachments() ?? [],
1536
1547
  getPlanModeState: () => session?.getPlanModeState(),
1537
1548
  getPlanReferencePath: () => session?.getPlanReferencePath() ?? "local://PLAN.md",
1538
1549
  getGoalModeState: () => session?.getGoalModeState(),
@@ -1905,13 +1916,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1905
1916
  if (!hasExplicitModel && sessionRetryLimit > 0) {
1906
1917
  for (let i = 0; i < sessionRetryLimit; i++) {
1907
1918
  const sessionModelStr = sessionModelStrings[i];
1908
- const parsedModel = parseModelString(sessionModelStr);
1919
+ const parsedModel = parseModelString(sessionModelStr, {
1920
+ allowMaxAlias: true,
1921
+ isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
1922
+ });
1909
1923
  if (!parsedModel) continue;
1910
1924
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
1911
1925
  if (restoredModel && hasModelAuth(restoredModel)) {
1912
1926
  model = restoredModel;
1913
1927
  modelFallbackMessage = undefined;
1914
1928
  restoredSessionModelIndex = i;
1929
+ restoredSessionThinkingLevel = parsedModel.thinkingLevel;
1915
1930
  // Recompute thinking-level from scratch against the reclaimed
1916
1931
  // model: any value derived from the earlier fallback model's
1917
1932
  // `thinking.defaultLevel` must not become sticky.
@@ -2585,6 +2600,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2585
2600
  if (watchdogFiles && watchdogFiles.length > 0) {
2586
2601
  advisorWatchdogPrompt = watchdogFiles.join("\n\n");
2587
2602
  }
2603
+ // Owned only when this session created the manager; subagents receive a
2604
+ // parent's manager via `options.mcpManager` and MUST NOT disconnect it.
2605
+ const ownedMcpManager = options.mcpManager ? undefined : mcpManager;
2588
2606
  session = new AgentSession({
2589
2607
  advisorWatchdogPrompt,
2590
2608
  agent,
@@ -2630,6 +2648,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2630
2648
  return out;
2631
2649
  }
2632
2650
  : undefined,
2651
+ disconnectOwnedMcpManager: ownedMcpManager ? () => ownedMcpManager.disconnectAll() : undefined,
2633
2652
  mcpDiscoveryEnabled,
2634
2653
  initialSelectedMCPToolNames,
2635
2654
  defaultSelectedMCPToolNames,
@@ -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
  }
@@ -7729,7 +7788,17 @@ export class AgentSession {
7729
7788
  await this.sessionManager.flush();
7730
7789
  this.#cancelOwnAsyncJobs();
7731
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();
7732
7800
  this.agent.reset();
7801
+ this.agent.replaceQueues(preservedSteering, preservedFollowUp);
7733
7802
  this.#freshProviderSessionId = undefined;
7734
7803
  this.#syncAgentSessionId();
7735
7804
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -8785,14 +8854,20 @@ export class AgentSession {
8785
8854
  const existingRoleValue = this.settings.getModelRole(role);
8786
8855
  if (!existingRoleValue) return modelKey;
8787
8856
 
8788
- const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings);
8857
+ const thinkingLevel = extractExplicitThinkingSelector(existingRoleValue, this.settings, {
8858
+ isLiteralModelId: (provider, id) => this.#modelRegistry.find(provider, id) !== undefined,
8859
+ });
8789
8860
  return formatModelSelectorValue(modelKey, thinkingLevel);
8790
8861
  }
8791
8862
  #resolveContextPromotionConfiguredTarget(currentModel: Model, availableModels: Model[]): Model | undefined {
8792
8863
  const configuredTarget = currentModel.contextPromotionTarget?.trim();
8793
8864
  if (!configuredTarget) return undefined;
8794
8865
 
8795
- 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
+ });
8796
8871
  if (parsed) {
8797
8872
  const explicitModel = availableModels.find(m => m.provider === parsed.provider && m.id === parsed.id);
8798
8873
  if (explicitModel) return explicitModel;
@@ -9087,7 +9162,6 @@ export class AgentSession {
9087
9162
  );
9088
9163
  }
9089
9164
  }
9090
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9091
9165
  // Abort any older auto-compaction before installing this run's controller.
9092
9166
  this.#autoCompactionAbortController?.abort();
9093
9167
  const autoCompactionAbortController = new AbortController();
@@ -9095,11 +9169,16 @@ export class AgentSession {
9095
9169
  const autoCompactionSignal = autoCompactionAbortController.signal;
9096
9170
 
9097
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 });
9098
9177
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
9099
9178
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
9100
9179
  const handoffResult = await this.handoff(handoffFocus, {
9101
9180
  autoTriggered: true,
9102
- signal: this.#autoCompactionAbortController.signal,
9181
+ signal: autoCompactionSignal,
9103
9182
  });
9104
9183
  if (!handoffResult) {
9105
9184
  const aborted = autoCompactionSignal.aborted;
@@ -9531,12 +9610,12 @@ export class AgentSession {
9531
9610
  triggerContextTokens?: number,
9532
9611
  ): Promise<CompactionCheckResult | "fallback"> {
9533
9612
  const action = "shake";
9534
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9535
9613
  this.#autoCompactionAbortController?.abort();
9536
9614
  const controller = new AbortController();
9537
9615
  this.#autoCompactionAbortController = controller;
9538
9616
  const signal = controller.signal;
9539
9617
  try {
9618
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9540
9619
  const result = await this.shake("elide", { config: DEFAULT_SHAKE_CONFIG, signal });
9541
9620
  if (signal.aborted) {
9542
9621
  await this.#emitSessionEvent({
@@ -9834,7 +9913,7 @@ export class AgentSession {
9834
9913
  this.configWarnings.push(msg);
9835
9914
  continue;
9836
9915
  }
9837
- const parsed = parseRetryFallbackSelector(selectorStr);
9916
+ const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
9838
9917
  if (!parsed) {
9839
9918
  const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
9840
9919
  logger.warn(msg);
@@ -9857,7 +9936,7 @@ export class AgentSession {
9857
9936
 
9858
9937
  #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
9859
9938
  const configuredSelector = this.settings.getModelRole(role);
9860
- return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
9939
+ return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
9861
9940
  }
9862
9941
 
9863
9942
  #clearActiveRetryFallback(): void {
@@ -9878,7 +9957,7 @@ export class AgentSession {
9878
9957
  }
9879
9958
 
9880
9959
  #resolveRetryFallbackRole(currentSelector: string): string | undefined {
9881
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9960
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
9882
9961
  if (!parsedCurrent) return undefined;
9883
9962
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9884
9963
  const currentPlainSelector = this.model
@@ -9910,7 +9989,7 @@ export class AgentSession {
9910
9989
  const chain = [primarySelector];
9911
9990
  const seen = new Set<string>([primarySelector.raw]);
9912
9991
  for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
9913
- const parsed = parseRetryFallbackSelector(selector);
9992
+ const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
9914
9993
  if (!parsed || seen.has(parsed.raw)) continue;
9915
9994
  seen.add(parsed.raw);
9916
9995
  chain.push(parsed);
@@ -9921,7 +10000,7 @@ export class AgentSession {
9921
10000
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
9922
10001
  const chain = this.#getRetryFallbackEffectiveChain(role);
9923
10002
  if (chain.length <= 1) return [];
9924
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
10003
+ const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
9925
10004
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9926
10005
  const currentPlainSelector =
9927
10006
  this.model && parsedCurrent
@@ -10018,7 +10097,7 @@ export class AgentSession {
10018
10097
  originalThinkingLevel,
10019
10098
  lastAppliedFallbackThinkingLevel,
10020
10099
  } = this.#activeRetryFallback;
10021
- const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
10100
+ const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#modelRegistry);
10022
10101
  if (!originalSelector) {
10023
10102
  this.#clearActiveRetryFallback();
10024
10103
  return;
@@ -11070,7 +11149,7 @@ export class AgentSession {
11070
11149
  const hasServiceTierEntry = this.sessionManager
11071
11150
  .getBranch()
11072
11151
  .some(entry => entry.type === "service_tier_change");
11073
- const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
11152
+ const defaultThinkingLevel = parseConfiguredThinkingLevel(this.settings.get("defaultThinkingLevel"));
11074
11153
  const configuredServiceTier = this.settings.get("serviceTier");
11075
11154
  // Session log entries store only concrete levels. When `auto` has resolved
11076
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