@oh-my-pi/pi-coding-agent 16.1.20 → 16.1.21

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 (34) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/cli.js +2804 -2793
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  5. package/dist/types/mcp/oauth-flow.d.ts +42 -5
  6. package/dist/types/modes/components/custom-editor.d.ts +32 -0
  7. package/dist/types/modes/components/tool-execution.d.ts +5 -5
  8. package/dist/types/modes/controllers/event-controller.d.ts +10 -0
  9. package/dist/types/modes/controllers/input-controller.d.ts +2 -1
  10. package/dist/types/utils/clipboard.d.ts +10 -0
  11. package/package.json +12 -12
  12. package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
  13. package/src/advisor/__tests__/advisor.test.ts +44 -0
  14. package/src/advisor/advise-tool.ts +33 -0
  15. package/src/autolearn/controller.ts +17 -2
  16. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
  17. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
  18. package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
  19. package/src/mcp/manager.ts +12 -3
  20. package/src/mcp/oauth-flow.ts +121 -7
  21. package/src/modes/components/chat-transcript-builder.ts +31 -0
  22. package/src/modes/components/custom-editor.test.ts +80 -0
  23. package/src/modes/components/custom-editor.ts +86 -6
  24. package/src/modes/components/tool-execution.ts +50 -25
  25. package/src/modes/controllers/event-controller.ts +57 -8
  26. package/src/modes/controllers/input-controller.ts +70 -27
  27. package/src/modes/controllers/mcp-command-controller.ts +18 -2
  28. package/src/modes/utils/ui-helpers.ts +40 -0
  29. package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
  30. package/src/prompts/system/autolearn-nudge.md +4 -2
  31. package/src/prompts/tools/todo.md +1 -1
  32. package/src/session/agent-session.ts +4 -0
  33. package/src/tools/todo.ts +20 -10
  34. package/src/utils/clipboard.ts +57 -0
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import { isBuiltin } from "node:module";
2
3
  import * as path from "node:path";
3
4
  import * as url from "node:url";
4
5
  import { isCompiledBinary } from "@oh-my-pi/pi-utils";
@@ -456,10 +457,11 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
456
457
 
457
458
  /**
458
459
  * Rewrite the extension-owned specifiers OMP must host-resolve — legacy
459
- * `@(scope)/pi-*`, bare TypeBox packages, and package `imports` aliases like
460
- * `#src/*` — to absolute `file://` URLs. Every other specifier (relative
461
- * siblings and third-party dependencies) is left untouched so Bun resolves it
462
- * natively from the extension's real on-disk location.
460
+ * `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like
461
+ * `#src/*`, and extension-local bare dependencies — to absolute `file://` URLs
462
+ * or compiled-mode virtual specifiers. Relative siblings and built-in modules
463
+ * are left untouched so Bun resolves them from the extension's real on-disk
464
+ * location.
463
465
  */
464
466
  async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
465
467
  const withPi = rewriteLegacyPiImports(source);
@@ -474,7 +476,12 @@ async function rewriteLegacyExtensionSource(source: string, importerPath: string
474
476
  `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
475
477
  )
476
478
  : withPi;
477
- return rewriteExtensionPackageImports(withTypeBox, importerPath);
479
+ return rewriteExtensionBareImports(await rewriteExtensionPackageImports(withTypeBox, importerPath), importerPath);
480
+ }
481
+
482
+ /** Test seam for compiled-binary legacy extension source rewriting. */
483
+ export async function __rewriteLegacyExtensionSourceForTests(source: string, importerPath: string): Promise<string> {
484
+ return rewriteLegacyExtensionSource(source, importerPath);
478
485
  }
479
486
 
480
487
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -688,6 +695,187 @@ async function rewriteExtensionPackageImports(source: string, importerPath: stri
688
695
  return `${rewritten}${source.slice(lastIndex)}`;
689
696
  }
690
697
 
698
+ const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g;
699
+
700
+ function isBareExtensionDependencySpecifier(specifier: string): boolean {
701
+ if (
702
+ specifier.startsWith(".") ||
703
+ specifier.startsWith("/") ||
704
+ specifier.startsWith("#") ||
705
+ specifier.startsWith("node:") ||
706
+ specifier.startsWith("bun:") ||
707
+ /^[a-z][a-z0-9+.-]*:/i.test(specifier)
708
+ ) {
709
+ return false;
710
+ }
711
+ const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
712
+ return Boolean(packageName && !isBuiltin(packageName));
713
+ }
714
+
715
+ interface BarePackageSpecifier {
716
+ readonly name: string;
717
+ readonly subpath: string | null;
718
+ }
719
+
720
+ function splitBarePackageSpecifier(specifier: string): BarePackageSpecifier | null {
721
+ const parts = specifier.split("/");
722
+ if (specifier.startsWith("@")) {
723
+ const [scope, name, ...rest] = parts;
724
+ if (!scope || !name) return null;
725
+ return { name: `${scope}/${name}`, subpath: rest.length > 0 ? rest.join("/") : null };
726
+ }
727
+ const [name, ...rest] = parts;
728
+ if (!name) return null;
729
+ return { name, subpath: rest.length > 0 ? rest.join("/") : null };
730
+ }
731
+
732
+ async function findNodePackageRoot(packageName: string, importerPath: string): Promise<string | null> {
733
+ let dir = path.dirname(importerPath);
734
+ while (true) {
735
+ const candidate = path.join(dir, "node_modules", packageName);
736
+ if (await pathExists(path.join(candidate, "package.json"))) {
737
+ return candidate;
738
+ }
739
+ const parent = path.dirname(dir);
740
+ if (parent === dir) {
741
+ return null;
742
+ }
743
+ dir = parent;
744
+ }
745
+ }
746
+
747
+ async function readPackageManifest(packageRoot: string): Promise<Record<string, unknown> | null> {
748
+ try {
749
+ const manifest = await Bun.file(path.join(packageRoot, "package.json")).json();
750
+ return isRecord(manifest) ? manifest : null;
751
+ } catch {
752
+ return null;
753
+ }
754
+ }
755
+
756
+ async function resolvePackageExportTarget(
757
+ packageRoot: string,
758
+ target: string,
759
+ wildcard: string | null,
760
+ ): Promise<string | null> {
761
+ if (!target.startsWith("./")) {
762
+ return null;
763
+ }
764
+ const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
765
+ return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
766
+ }
767
+
768
+ async function resolveNodePackageExport(
769
+ packageRoot: string,
770
+ subpath: string | null,
771
+ manifest: Record<string, unknown>,
772
+ ): Promise<string | null> {
773
+ const exportsField = manifest.exports;
774
+ const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null;
775
+ if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) {
776
+ return resolvePackageExportTarget(packageRoot, rootTarget, null);
777
+ }
778
+ if (!isRecord(exportsField)) {
779
+ return null;
780
+ }
781
+
782
+ const exactKey = subpath === null ? "." : `./${subpath}`;
783
+ const exactTarget = selectPackageImportTarget(exportsField[exactKey]);
784
+ if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) {
785
+ return resolvePackageExportTarget(packageRoot, exactTarget, null);
786
+ }
787
+
788
+ for (const [key, entry] of Object.entries(exportsField)) {
789
+ const starIndex = key.indexOf("*");
790
+ if (starIndex === -1 || subpath === null) continue;
791
+ const prefix = key.slice(2, starIndex);
792
+ const suffix = key.slice(starIndex + 1);
793
+ if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
794
+ continue;
795
+ }
796
+ const target = selectPackageImportTarget(entry);
797
+ if (target === null || target === PACKAGE_IMPORT_EXCLUDED) {
798
+ continue;
799
+ }
800
+ return resolvePackageExportTarget(
801
+ packageRoot,
802
+ target,
803
+ subpath.slice(prefix.length, subpath.length - suffix.length),
804
+ );
805
+ }
806
+ return null;
807
+ }
808
+
809
+ async function resolveNodePackageFallback(
810
+ packageRoot: string,
811
+ subpath: string | null,
812
+ manifest: Record<string, unknown>,
813
+ ): Promise<string | null> {
814
+ if (subpath !== null) {
815
+ return resolveSourceModuleFile(path.join(packageRoot, subpath));
816
+ }
817
+ for (const field of ["module", "main"]) {
818
+ const target = manifest[field];
819
+ if (typeof target === "string") {
820
+ const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target));
821
+ if (resolved) return resolved;
822
+ }
823
+ }
824
+ return resolveSourceModuleFile(path.join(packageRoot, "index"));
825
+ }
826
+
827
+ async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise<string | null> {
828
+ const parsed = splitBarePackageSpecifier(specifier);
829
+ if (!parsed) return null;
830
+ const packageRoot = await findNodePackageRoot(parsed.name, importerPath);
831
+ if (!packageRoot) return null;
832
+ const manifest = await readPackageManifest(packageRoot);
833
+ if (!manifest) return null;
834
+ return (
835
+ (await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ??
836
+ (await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest))
837
+ );
838
+ }
839
+
840
+ async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise<string | null> {
841
+ if (!isBareExtensionDependencySpecifier(specifier)) {
842
+ return null;
843
+ }
844
+ try {
845
+ const resolved = Bun.resolveSync(specifier, path.dirname(importerPath));
846
+ if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) {
847
+ return resolved;
848
+ }
849
+ } catch {
850
+ // Compiled binaries do not reliably resolve runtime extension node_modules.
851
+ }
852
+ return resolveNodePackageDependency(specifier, importerPath);
853
+ }
854
+
855
+ async function rewriteExtensionBareImports(source: string, importerPath: string): Promise<string> {
856
+ let rewritten = "";
857
+ let lastIndex = 0;
858
+ for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) {
859
+ const matchIndex = match.index;
860
+ if (matchIndex === undefined) continue;
861
+
862
+ const [fullMatch, prefix, specifier, suffix] = match;
863
+ if (!prefix || !specifier || !suffix) continue;
864
+
865
+ const resolved = await resolveExtensionBareDependency(specifier, importerPath);
866
+ if (!resolved) continue;
867
+
868
+ rewritten += source.slice(lastIndex, matchIndex);
869
+ rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
870
+ lastIndex = matchIndex + fullMatch.length;
871
+ }
872
+
873
+ if (lastIndex === 0) {
874
+ return source;
875
+ }
876
+ return `${rewritten}${source.slice(lastIndex)}`;
877
+ }
878
+
691
879
  function escapeRegExp(value: string): string {
692
880
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
693
881
  }
@@ -1229,8 +1229,15 @@ export class MCPManager {
1229
1229
  const tokenUrl = material?.tokenUrl;
1230
1230
  const clientId = material?.clientId;
1231
1231
  const clientSecret = material?.clientSecret;
1232
- const resource =
1233
- material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
1232
+ // `authorizationUrl` only lives on the embedded credential form;
1233
+ // legacy `MCPAuthConfig` rows never carried it. Required to filter
1234
+ // same-origin resource indicators on refresh when the authorize and
1235
+ // token endpoints sit on different origins (issue #3502 review
1236
+ // follow-up).
1237
+ const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
1238
+ const resourceIsFallback =
1239
+ !material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
1240
+ const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
1234
1241
  // Proactive refresh: 5-minute buffer before expiry
1235
1242
  // Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
1236
1243
  const REFRESH_BUFFER_MS = 5 * 60_000;
@@ -1244,6 +1251,7 @@ export class MCPManager {
1244
1251
  clientId,
1245
1252
  clientSecret,
1246
1253
  resource,
1254
+ { authorizationUrl, stripSameOriginResource: resourceIsFallback },
1247
1255
  );
1248
1256
  // Spread the old credential first so embedded refresh material survives rotation.
1249
1257
  const refreshedCredential: MCPStoredOAuthCredential = {
@@ -1252,7 +1260,8 @@ export class MCPManager {
1252
1260
  tokenUrl,
1253
1261
  clientId,
1254
1262
  clientSecret,
1255
- resource,
1263
+ resource: resourceIsFallback ? undefined : resource,
1264
+ authorizationUrl,
1256
1265
  };
1257
1266
  await this.#authStorage.set(credentialId, refreshedCredential);
1258
1267
  credential = refreshedCredential;
@@ -62,6 +62,14 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
62
62
  clientId?: string;
63
63
  clientSecret?: string;
64
64
  resource?: string;
65
+ /**
66
+ * Authorization-server URL (the issuer the grant was minted against). Used
67
+ * to filter same-origin resource indicators on refresh: RFC 8414 lets the
68
+ * authorize and token endpoints sit on different origins, so refresh
69
+ * cannot infer the original auth-server origin from `tokenUrl` alone.
70
+ * Unset on legacy credentials minted before issue #3502's fix.
71
+ */
72
+ authorizationUrl?: string;
65
73
  }
66
74
 
67
75
  const DEFAULT_PORT = 3000;
@@ -169,6 +177,42 @@ function resolveResourceUri(resource: string | undefined): string | undefined {
169
177
  return trimmed;
170
178
  }
171
179
 
180
+ interface ResourceIndicatorFilterOptions {
181
+ /** Strip any resource URL on the same origin as the authorization server. */
182
+ stripSameOriginResource?: boolean;
183
+ }
184
+
185
+ /**
186
+ * Drop a redundant fallback resource indicator relative to {@link serverUrl}.
187
+ *
188
+ * Provider-advertised resource indicators are authoritative even when they are
189
+ * origin-only (`https://gateway.example.com`) or path-scoped same-origin
190
+ * (`https://gateway.example.com/my-service/mcp`): servers can use either form
191
+ * as the audience they require for the grant.
192
+ *
193
+ * Plane is stricter for OMP-synthesized fallback resources (e.g. using the
194
+ * configured server URL `https://mcp.plane.so/http/mcp` as `resource`), so
195
+ * fallback callers opt into `stripSameOriginResource`. Provider-advertised
196
+ * `oauth.resource` values and authorization-URL `?resource=` values keep the
197
+ * preserving default.
198
+ */
199
+ function filterResourceIndicator(
200
+ resource: string | undefined,
201
+ serverUrl: string,
202
+ options: ResourceIndicatorFilterOptions = {},
203
+ ): string | undefined {
204
+ if (!resource) return undefined;
205
+ try {
206
+ const origin = new URL(serverUrl).origin;
207
+ const parsedResource = new URL(resource);
208
+ if (parsedResource.origin !== origin) return resource;
209
+ if (options.stripSameOriginResource) return undefined;
210
+ } catch {
211
+ // Malformed serverUrl will fail elsewhere; fall through.
212
+ }
213
+ return resource;
214
+ }
215
+
172
216
  export interface MCPOAuthConfig {
173
217
  /** Authorization endpoint URL */
174
218
  authorizationUrl: string;
@@ -197,6 +241,13 @@ export interface MCPOAuthConfig {
197
241
  callbackPath?: string;
198
242
  /** MCP resource URI for RFC 8707 resource indicators */
199
243
  resource?: string;
244
+ /**
245
+ * True when `resource` was synthesized from the server URL fallback rather
246
+ * than advertised by OAuth/protected-resource metadata. Fallback resources
247
+ * are stripped when same-origin with the authorization server; advertised
248
+ * path-scoped resources are preserved.
249
+ */
250
+ stripSameOriginResource?: boolean;
200
251
  /** Fetch implementation for token exchange and discovery requests. */
201
252
  fetch?: FetchImpl;
202
253
  }
@@ -219,8 +270,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
219
270
  super(ctrl, resolveCallbackOptions(config));
220
271
  this.#resolvedClientId = this.#resolveClientId(config);
221
272
  this.#fetch = config.fetch ?? ctrl.fetch ?? fetch;
222
- this.#resource = resolveResourceUri(
223
- config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl),
273
+ this.#resource = this.#filterResourceIndicator(
274
+ resolveResourceUri(config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl)),
224
275
  );
225
276
  }
226
277
 
@@ -246,6 +297,15 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
246
297
  get resource(): string | undefined {
247
298
  return this.#resource;
248
299
  }
300
+ /**
301
+ * Authorization-server URL the flow used. Persist alongside the credential
302
+ * so refresh can filter same-origin resource indicators against the issuer's
303
+ * origin even when `tokenUrl` lives on a different origin (RFC 8414 permits
304
+ * the split).
305
+ */
306
+ get authorizationUrl(): string {
307
+ return this.config.authorizationUrl;
308
+ }
249
309
 
250
310
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
251
311
  if (!this.#resolvedClientId) {
@@ -271,7 +331,20 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
271
331
  }
272
332
  const existingResource = params.get("resource")?.trim();
273
333
  if (existingResource) {
274
- this.#resource = resolveResourceUri(existingResource);
334
+ // A resource already embedded in the provider's authorization URL is
335
+ // provider-authored, not OMP's server-URL fallback. Preserve same-host
336
+ // values here even when the caller marked its separate
337
+ // `config.resource` as fallback; gateway-hosted MCP servers can use
338
+ // origin-only or path-scoped values as the token audience.
339
+ const filtered = filterResourceIndicator(resolveResourceUri(existingResource), this.config.authorizationUrl);
340
+ if (filtered) {
341
+ this.#resource = filtered;
342
+ } else {
343
+ // Defensive path for future policy additions: when filtering says
344
+ // "omit", drop it from both authorize and token requests.
345
+ params.delete("resource");
346
+ this.#resource = undefined;
347
+ }
275
348
  } else if (this.#resource) {
276
349
  params.set("resource", this.#resource);
277
350
  }
@@ -395,6 +468,17 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
395
468
  }
396
469
  }
397
470
 
471
+ /**
472
+ * Drop redundant resource indicators for this authorization server.
473
+ * Provider-advertised path-scoped values are preserved; fallback server-URL
474
+ * values opt into same-origin stripping via `stripSameOriginResource`.
475
+ */
476
+ #filterResourceIndicator(resource: string | undefined): string | undefined {
477
+ return filterResourceIndicator(resource, this.config.authorizationUrl, {
478
+ stripSameOriginResource: this.config.stripSameOriginResource,
479
+ });
480
+ }
481
+
398
482
  /**
399
483
  * Try OAuth dynamic client registration when provider requires a client_id.
400
484
  */
@@ -507,6 +591,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
507
591
  }
508
592
  }
509
593
 
594
+ /**
595
+ * Options for {@link refreshMCPOAuthToken}. Carried via the trailing object
596
+ * so positional callers keep working.
597
+ */
598
+ export interface RefreshMCPOAuthTokenOptions {
599
+ fetch?: FetchImpl;
600
+ /**
601
+ * Authorization-server URL the original grant was minted against. Used to
602
+ * filter same-origin resource indicators on refresh. Defaults to `tokenUrl`'s
603
+ * origin when omitted for legacy credentials.
604
+ */
605
+ authorizationUrl?: string;
606
+ /**
607
+ * True when the refresh `resource` was synthesized from the server URL
608
+ * fallback because the credential/auth material carried no resource.
609
+ * Preserved advertised resources leave this false/undefined.
610
+ */
611
+ stripSameOriginResource?: boolean;
612
+ }
613
+
510
614
  /**
511
615
  * Refresh an MCP OAuth token using the standard refresh_token grant.
512
616
  * Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
@@ -516,17 +620,27 @@ export async function refreshMCPOAuthToken(
516
620
  refreshToken: string,
517
621
  clientId?: string,
518
622
  clientSecret?: string,
519
- resourceOrOpts?: string | { fetch?: FetchImpl },
520
- opts?: { fetch?: FetchImpl },
623
+ resourceOrOpts?: string | RefreshMCPOAuthTokenOptions,
624
+ opts?: RefreshMCPOAuthTokenOptions,
521
625
  ): Promise<OAuthCredentials> {
522
- const fetchImpl: FetchImpl = (typeof resourceOrOpts === "string" ? opts?.fetch : resourceOrOpts?.fetch) ?? fetch;
626
+ const optsFromTrailing = typeof resourceOrOpts === "string" ? opts : resourceOrOpts;
627
+ const fetchImpl: FetchImpl = optsFromTrailing?.fetch ?? fetch;
523
628
  const resource = typeof resourceOrOpts === "string" ? resourceOrOpts : undefined;
629
+ // Filter against the authorization-server origin when known (RFC 8414
630
+ // permits authorize/token endpoints on separate origins). Fall back to
631
+ // `tokenUrl` for legacy credentials minted before the issuer was persisted
632
+ // — same-origin servers (the common case) still match correctly.
633
+ const filterAnchor = optsFromTrailing?.authorizationUrl ?? tokenUrl;
524
634
  const params = new URLSearchParams({
525
635
  grant_type: "refresh_token",
526
636
  refresh_token: refreshToken,
527
637
  });
528
638
  if (clientId) params.set("client_id", clientId);
529
- const resolvedResource = resolveResourceUri(resource);
639
+ // Drop redundant indicators so refresh stays consistent with the initial
640
+ // grant; see {@link filterResourceIndicator} for context.
641
+ const resolvedResource = filterResourceIndicator(resolveResourceUri(resource), filterAnchor, {
642
+ stripSameOriginResource: optsFromTrailing?.stripSameOriginResource,
643
+ });
530
644
  if (resolvedResource) params.set("resource", resolvedResource);
531
645
  if (clientSecret) params.set("client_secret", clientSecret);
532
646
 
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
83
83
  #pendingUsage: Usage | undefined;
84
84
  #lastAssistantUsage: Usage | undefined;
85
85
  #waitingPoll: ToolExecutionComponent | null = null;
86
+ #todoSnapshot: ToolExecutionComponent | null = null;
86
87
  #expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
87
88
  #expanded = false;
88
89
 
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
128
129
  this.#pendingUsage = undefined;
129
130
  this.#lastAssistantUsage = undefined;
130
131
  this.#waitingPoll = null;
132
+ this.#todoSnapshot = null;
131
133
  this.#expandables = [];
132
134
  this.container.dispose();
133
135
  this.container.clear();
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
153
155
  previous.seal();
154
156
  }
155
157
 
158
+ #resolveTodoSnapshot(nextToolName?: string): void {
159
+ const previous = this.#todoSnapshot;
160
+ if (!previous) return;
161
+ if (!previous.isDisplaceableBlock()) {
162
+ this.#todoSnapshot = null;
163
+ return;
164
+ }
165
+ if (previous.canBeDisplacedBy(nextToolName)) {
166
+ this.#todoSnapshot = null;
167
+ this.container.removeChild(previous);
168
+ previous.seal();
169
+ return;
170
+ }
171
+ if (nextToolName !== undefined) return;
172
+ this.#todoSnapshot = null;
173
+ previous.seal();
174
+ }
175
+
156
176
  #ensureReadGroup(): ReadToolGroupComponent {
157
177
  if (!this.#readGroup) {
158
178
  this.#readGroup = new ReadToolGroupComponent({
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
189
209
  case "developer": {
190
210
  // A user prompt closes the poll-displacement window, same as the live path.
191
211
  if (message.role === "user") this.#resolveWaitingPoll();
212
+ if (message.role === "user") this.#resolveTodoSnapshot();
192
213
  const textContent = message.role === "user" ? userMessageText(message) : "";
193
214
  if (textContent) {
194
215
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
345
366
  this.#pendingTools.delete(message.toolCallId);
346
367
  if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
347
368
  this.#waitingPoll = pending;
369
+ } else if (
370
+ message.toolName === "todo" &&
371
+ pending instanceof ToolExecutionComponent &&
372
+ pending.canBeDisplacedBy("todo")
373
+ ) {
374
+ // A successful todo result supersedes the prior live snapshot. Failed
375
+ // follow-ups return false from canBeDisplacedBy("todo"), so the
376
+ // last-good panel stays on screen.
377
+ this.#resolveTodoSnapshot("todo");
378
+ this.#todoSnapshot = pending;
348
379
  }
349
380
  }
350
381
 
@@ -5,6 +5,8 @@ import {
5
5
  CustomEditor,
6
6
  extractBracketedImagePastePaths,
7
7
  extractBracketedPastePaths,
8
+ extractImagePathFromText,
9
+ extractPastePathsFromText,
8
10
  SPACE_HOLD_MECHANICAL_RUN,
9
11
  SPACE_HOLD_RELEASE_MS,
10
12
  SPACE_REPEAT_MAX_GAP_MS,
@@ -88,6 +90,22 @@ describe("CustomEditor bracketed path paste", () => {
88
90
  ]);
89
91
  });
90
92
 
93
+ it("strips `file://` URLs to the local filesystem path before loading the image", () => {
94
+ // macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
95
+ // `public.file-url` representation when the user does Finder→Copy
96
+ // then Cmd+V. Without decoding, `loadImageInput` would try to read a
97
+ // literal `file:///…` path and fail.
98
+ expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
99
+ "/Users/me/Pictures/photo.png",
100
+ ]);
101
+ });
102
+
103
+ it("percent-decodes spaces inside `file://` URLs", () => {
104
+ expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
105
+ "/Users/me/My Pictures/photo.png",
106
+ ]);
107
+ });
108
+
91
109
  it("extracts explicit non-image paths without classifying them as image paths", () => {
92
110
  expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
93
111
  expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
@@ -107,6 +125,68 @@ describe("CustomEditor bracketed path paste", () => {
107
125
  });
108
126
  });
109
127
 
128
+ describe("extractImagePathFromText (issue #3506)", () => {
129
+ it("returns the path when the text is a single image file path", () => {
130
+ expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
131
+ expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
132
+ expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
133
+ });
134
+
135
+ it("ignores surrounding whitespace from a clipboard read", () => {
136
+ expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
137
+ });
138
+
139
+ it("returns undefined for a bare filename (no explicit directory)", () => {
140
+ // Mirrors the bracketed-paste contract: a bare `.png` filename is
141
+ // almost always a project-relative reference the user wants as text,
142
+ // not a clipboard-anchored attachment.
143
+ expect(extractImagePathFromText("icon.png")).toBeUndefined();
144
+ });
145
+
146
+ it("returns undefined for non-image extensions", () => {
147
+ expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
148
+ expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
149
+ });
150
+
151
+ it("returns undefined when the text contains anything beyond a single path", () => {
152
+ expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
153
+ expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
154
+ });
155
+
156
+ it("returns undefined for empty/whitespace-only input", () => {
157
+ expect(extractImagePathFromText("")).toBeUndefined();
158
+ expect(extractImagePathFromText(" ")).toBeUndefined();
159
+ });
160
+
161
+ it("decodes a `file://` URL to its filesystem path", () => {
162
+ expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
163
+ });
164
+
165
+ it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
166
+ const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
167
+ expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
168
+ expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
169
+ "~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
170
+ );
171
+ expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
172
+ "C:\\Users\\me\\My Pictures\\img with space.jpg",
173
+ );
174
+ });
175
+
176
+ it("does not hijack prose that happens to contain a path-shaped fragment", () => {
177
+ // The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
178
+ // non-anchored prefix ("see ...") never triggers it.
179
+ expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
180
+ });
181
+ });
182
+
183
+ describe("extractPastePathsFromText", () => {
184
+ it("delegates to the same logic the bracketed variant uses for path detection", () => {
185
+ expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
186
+ expect(extractPastePathsFromText("just text")).toBeUndefined();
187
+ });
188
+ });
189
+
110
190
  describe("CustomEditor space-hold push-to-talk", () => {
111
191
  beforeAll(async () => {
112
192
  await initTheme();