@oh-my-pi/pi-coding-agent 15.5.7 → 15.5.9

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 (63) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +10 -10
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/mcp/transports/http.d.ts +9 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  15. package/dist/types/session/agent-session.d.ts +3 -1
  16. package/dist/types/tools/match-line-format.d.ts +2 -2
  17. package/dist/types/tools/render-utils.d.ts +3 -1
  18. package/dist/types/tools/write.d.ts +2 -0
  19. package/dist/types/utils/file-mentions.d.ts +2 -0
  20. package/package.json +8 -8
  21. package/src/cli/args.ts +2 -0
  22. package/src/cli/auth-broker-cli.ts +2 -1
  23. package/src/cli/auth-gateway-cli.ts +210 -9
  24. package/src/commands/auth-gateway.ts +7 -1
  25. package/src/config/settings-schema.ts +12 -11
  26. package/src/edit/file-snapshot-store.ts +9 -6
  27. package/src/edit/hashline/diff.ts +26 -13
  28. package/src/edit/hashline/execute.ts +13 -9
  29. package/src/edit/renderer.ts +9 -9
  30. package/src/edit/streaming.ts +4 -6
  31. package/src/eval/py/index.ts +1 -1
  32. package/src/extensibility/custom-tools/types.ts +1 -1
  33. package/src/extensibility/shared-events.ts +1 -1
  34. package/src/internal-urls/docs-index.generated.ts +7 -7
  35. package/src/internal-urls/index.ts +1 -0
  36. package/src/internal-urls/router.ts +2 -0
  37. package/src/internal-urls/vault-protocol.ts +936 -0
  38. package/src/main.ts +1 -2
  39. package/src/mcp/transports/http.ts +29 -2
  40. package/src/modes/components/tool-execution.ts +6 -4
  41. package/src/modes/controllers/event-controller.ts +10 -3
  42. package/src/modes/interactive-mode.ts +10 -2
  43. package/src/modes/utils/ui-helpers.ts +2 -1
  44. package/src/prompts/system/system-prompt.md +3 -0
  45. package/src/prompts/tools/ast-edit.md +1 -1
  46. package/src/prompts/tools/ast-grep.md +1 -1
  47. package/src/prompts/tools/read.md +3 -3
  48. package/src/prompts/tools/search.md +1 -1
  49. package/src/sdk.ts +26 -1
  50. package/src/session/agent-session.ts +82 -11
  51. package/src/system-prompt.ts +2 -0
  52. package/src/tools/ast-edit.ts +10 -7
  53. package/src/tools/ast-grep.ts +12 -11
  54. package/src/tools/eval.ts +28 -3
  55. package/src/tools/match-line-format.ts +2 -2
  56. package/src/tools/path-utils.ts +2 -0
  57. package/src/tools/plan-mode-guard.ts +6 -1
  58. package/src/tools/read.ts +70 -55
  59. package/src/tools/render-utils.ts +15 -0
  60. package/src/tools/search.ts +12 -12
  61. package/src/tools/write.ts +61 -6
  62. package/src/utils/file-mentions.ts +11 -5
  63. package/src/web/search/providers/codex.ts +2 -1
@@ -19,6 +19,10 @@ import {
19
19
  type Api,
20
20
  AuthBrokerClient,
21
21
  AuthStorage,
22
+ type CompletionProbe,
23
+ type CompletionProbeInput,
24
+ type CredentialCompletionResult,
25
+ completeSimple,
22
26
  DEFAULT_AUTH_GATEWAY_BIND,
23
27
  type GeneratedProvider,
24
28
  getBundledModels,
@@ -46,6 +50,14 @@ export interface AuthGatewayCommandArgs {
46
50
  * to wire token-paste plumbing into every local client.
47
51
  */
48
52
  noAuth?: boolean;
53
+ /**
54
+ * Strict mode for `check` — additionally exercise every credential
55
+ * against its provider's chat-completion endpoint. The usage probe (run
56
+ * unconditionally) can pass while the chat endpoint still 401s the same
57
+ * bearer, so strict mode is the definitive "is this credential
58
+ * actually usable" signal. Slower and consumes a tiny amount of quota.
59
+ */
60
+ strict?: boolean;
49
61
  };
50
62
  }
51
63
 
@@ -342,12 +354,185 @@ export async function runAuthGatewayCommand(cmd: AuthGatewayCommandArgs): Promis
342
354
  }
343
355
  }
344
356
 
357
+ /**
358
+ * Providers whose chat endpoint expects a JSON-serialized credential blob
359
+ * (`{ token, projectId, refreshToken, expiresAt, … }`) rather than the raw
360
+ * access token. Mirrors `getOAuthApiKey` in `packages/ai/src/utils/oauth`.
361
+ */
362
+ const STRUCTURED_API_KEY_PROVIDERS: ReadonlySet<string> = new Set([
363
+ "github-copilot",
364
+ "google-gemini-cli",
365
+ "google-antigravity",
366
+ ]);
367
+
368
+ /**
369
+ * Provider API types that strict-mode chat probes intentionally skip:
370
+ * - `bedrock-converse-stream` resolves credentials from the AWS env/profile, not the broker bearer.
371
+ * - `google-vertex` uses Application Default Credentials; the broker bearer is not the right key.
372
+ * - `cursor-agent` and `pi-native` (gateway forwarding) have transport quirks
373
+ * that make a bearer-only "ping" a poor signal.
374
+ */
375
+ const STRICT_PROBE_SKIPPED_APIS: ReadonlySet<Api> = new Set<Api>([
376
+ "bedrock-converse-stream",
377
+ "google-vertex",
378
+ "cursor-agent",
379
+ ]);
380
+
381
+ /** Max chat models to try per credential before reporting failure. */
382
+ const STRICT_PROBE_MAX_CANDIDATES = 4;
383
+
384
+ /** Per-attempt deadline. Each candidate gets its own slice instead of sharing one budget. */
385
+ const STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS = 15_000;
386
+
387
+ /**
388
+ * Overall per-credential budget passed to {@link AuthStorage.checkCredentials}.
389
+ * Big enough to walk every candidate at the per-attempt cap with a small
390
+ * margin for refresh/network overhead.
391
+ */
392
+ const STRICT_PROBE_OVERALL_TIMEOUT_MS = STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS * (STRICT_PROBE_MAX_CANDIDATES + 1);
393
+
394
+ /** Match upstream errors that mean "this model is gone, try a different one" so we walk the catalog instead of declaring the credential bad. */
395
+ const RETRYABLE_MODEL_ERROR_RE =
396
+ /not[_ -]found|invalid[_ -]model|model[_ -]is[_ -]not[_ -]valid|no longer supported|deprecated|404|decommissioned/i;
397
+
398
+ /**
399
+ * Rank bundled models for a provider in probe order: cheapest first, then by
400
+ * id for determinism. Filters out non-bearer-auth APIs (Vertex/Bedrock),
401
+ * pi-native transport (would loop through the gateway), and placeholder /
402
+ * router entries with negative/missing cost.
403
+ */
404
+ function pickProbeCandidates(provider: string): Model<Api>[] {
405
+ const bundled = getBundledModels(provider as GeneratedProvider);
406
+ if (bundled.length === 0) return [];
407
+ const candidates = bundled.filter(model => {
408
+ if (model.transport === "pi-native") return false;
409
+ if (STRICT_PROBE_SKIPPED_APIS.has(model.api)) return false;
410
+ if (!model.input.includes("text")) return false;
411
+ const totalCost = (model.cost?.input ?? 0) + (model.cost?.output ?? 0);
412
+ if (!Number.isFinite(totalCost) || totalCost < 0) return false;
413
+ if (model.maxTokens <= 0) return false;
414
+ return true;
415
+ });
416
+ candidates.sort((a, b) => a.cost.input + a.cost.output - (b.cost.input + b.cost.output) || a.id.localeCompare(b.id));
417
+ return candidates;
418
+ }
419
+
420
+ /**
421
+ * Compose the apiKey bytes a provider's chat endpoint expects, given a
422
+ * post-refresh probe credential. Mirrors `getOAuthApiKey` for the providers
423
+ * that require a structured blob; otherwise returns the raw access token /
424
+ * API key.
425
+ */
426
+ function composeProbeApiKey(provider: string, credential: CompletionProbeInput["credential"]): string {
427
+ if (credential.type === "api_key") return credential.apiKey;
428
+ if (!STRUCTURED_API_KEY_PROVIDERS.has(provider)) return credential.accessToken;
429
+ return JSON.stringify({
430
+ token: credential.accessToken,
431
+ enterpriseUrl: credential.enterpriseUrl,
432
+ projectId: credential.projectId,
433
+ refreshToken: credential.refreshToken,
434
+ expiresAt: credential.expiresAt,
435
+ email: credential.email,
436
+ accountId: credential.accountId,
437
+ });
438
+ }
439
+
440
+ async function probeOneModel(
441
+ model: Model<Api>,
442
+ apiKey: string,
443
+ outerSignal: AbortSignal,
444
+ ): Promise<CredentialCompletionResult> {
445
+ const start = Date.now();
446
+ const attemptTimeoutSignal = AbortSignal.timeout(STRICT_PROBE_PER_ATTEMPT_TIMEOUT_MS);
447
+ const attemptSignal = AbortSignal.any([outerSignal, attemptTimeoutSignal]);
448
+ // `systemPrompt` is mandatory for some providers (Codex 400s "Instructions
449
+ // are required" without it). `disableReasoning` is intentionally NOT set:
450
+ // providers like Fireworks reject the "none" effort it maps to, and we'd
451
+ // rather burn 16 reasoning tokens than misdiagnose a healthy credential.
452
+ const response = await completeSimple(
453
+ model,
454
+ {
455
+ systemPrompt: ["Connectivity check. Reply with the single word 'pong'."],
456
+ messages: [{ role: "user", content: "ping", timestamp: start }],
457
+ },
458
+ {
459
+ apiKey,
460
+ maxTokens: 32,
461
+ signal: attemptSignal,
462
+ },
463
+ );
464
+ const latencyMs = Date.now() - start;
465
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
466
+ return {
467
+ ok: false,
468
+ reason: response.errorMessage ?? `chat probe ended with stopReason=${response.stopReason}`,
469
+ modelId: model.id,
470
+ latencyMs,
471
+ };
472
+ }
473
+ return { ok: true, modelId: model.id, latencyMs };
474
+ }
475
+
476
+ /**
477
+ * Build the {@link CompletionProbe} consumed by
478
+ * {@link AuthStorage.checkCredentials} in `--strict` mode. Walks the cheapest
479
+ * candidates per provider, retrying on "model not found / invalid model"
480
+ * errors so a stale catalog entry doesn't masquerade as a bad credential.
481
+ * Stops as soon as one model returns a successful response (the credential
482
+ * authenticated against at least one model in the catalog).
483
+ */
484
+ function createStrictCompletionProbe(): CompletionProbe {
485
+ return async (input: CompletionProbeInput): Promise<CredentialCompletionResult> => {
486
+ const candidates = pickProbeCandidates(input.provider).slice(0, STRICT_PROBE_MAX_CANDIDATES);
487
+ if (candidates.length === 0) {
488
+ return { ok: null, reason: `no bearer-compatible probe model bundled for provider ${input.provider}` };
489
+ }
490
+ const apiKey = composeProbeApiKey(input.provider, input.credential);
491
+ let lastFailure: CredentialCompletionResult | undefined;
492
+ for (const model of candidates) {
493
+ if (input.signal.aborted) {
494
+ return {
495
+ ok: false,
496
+ reason: "aborted",
497
+ modelId: model.id,
498
+ };
499
+ }
500
+ const result = await probeOneModel(model, apiKey, input.signal);
501
+ if (result.ok === true) return result;
502
+ lastFailure = result;
503
+ if (!RETRYABLE_MODEL_ERROR_RE.test(result.reason ?? "")) {
504
+ // Non-model error (401, 403, 5xx, network) — the credential is the
505
+ // issue, not the catalog. Stop walking.
506
+ return result;
507
+ }
508
+ }
509
+ return (
510
+ lastFailure ?? {
511
+ ok: false,
512
+ reason: `all ${candidates.length} probe models failed for provider ${input.provider}`,
513
+ }
514
+ );
515
+ };
516
+ }
517
+
518
+ function formatCompletionStatus(completion: CredentialCompletionResult | undefined): string {
519
+ if (!completion) return "";
520
+ if (completion.ok === true) return chalk.green(" [chat: ok]");
521
+ if (completion.ok === false) return chalk.red(" [chat: FAIL]");
522
+ return chalk.yellow(" [chat: skip]");
523
+ }
524
+
345
525
  /**
346
526
  * `omp auth-gateway check` — probe each broker-supplied credential and print
347
527
  * per-credential auth health. Use this when the gateway is returning 401s and
348
528
  * you need to find which row in a multi-account pool is the bad one. The
349
529
  * aggregate `/v1/usage` endpoint silently drops failed credentials, so a
350
530
  * dedicated diagnostic is the only way to see which credentials failed.
531
+ *
532
+ * Strict mode (`--strict`) additionally exercises each credential against a
533
+ * cheap chat model from its provider's bundled catalog. This catches the case
534
+ * where the usage endpoint reports 200 but the chat endpoint 401s the same
535
+ * bearer (revoked OAuth scope, mislabeled provider row, etc).
351
536
  */
352
537
  async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
353
538
  const brokerConfig = await resolveAuthBrokerConfig();
@@ -363,10 +548,16 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
363
548
  const storage = new AuthStorage(store, { sourceLabel: `broker ${brokerConfig.url}` });
364
549
  try {
365
550
  await storage.reload();
366
- const results = await storage.checkCredentials();
551
+ const results = await storage.checkCredentials(
552
+ flags.strict
553
+ ? { completionProbe: createStrictCompletionProbe(), completionTimeoutMs: STRICT_PROBE_OVERALL_TIMEOUT_MS }
554
+ : undefined,
555
+ );
367
556
 
368
557
  if (flags.json) {
369
- process.stdout.write(`${JSON.stringify({ broker: brokerConfig.url, credentials: results }, null, 2)}\n`);
558
+ process.stdout.write(
559
+ `${JSON.stringify({ broker: brokerConfig.url, strict: flags.strict === true, credentials: results }, null, 2)}\n`,
560
+ );
370
561
  } else {
371
562
  const grouped = new Map<string, typeof results>();
372
563
  for (const row of results) {
@@ -375,7 +566,7 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
375
566
  grouped.set(row.provider, list);
376
567
  }
377
568
  const providers = [...grouped.keys()].sort();
378
- process.stdout.write(`broker: ${brokerConfig.url}\n`);
569
+ process.stdout.write(`broker: ${brokerConfig.url}${flags.strict ? chalk.dim(" [strict]") : ""}\n`);
379
570
  for (const provider of providers) {
380
571
  const rows = grouped.get(provider) ?? [];
381
572
  process.stdout.write(`\n${chalk.bold(provider)} (${rows.length})\n`);
@@ -389,19 +580,29 @@ async function runCheck(flags: AuthGatewayCommandArgs["flags"]): Promise<void> {
389
580
  const identity =
390
581
  row.email ?? row.accountId ?? (row.type === "api_key" ? "(api key)" : "(no identity on credential)");
391
582
  const remote = row.remoteRefresh ? chalk.dim(" [remote-refresh]") : "";
392
- const reason = row.reason ? chalk.dim(` — ${row.reason}`) : "";
583
+ const reasonParts: string[] = [];
584
+ if (row.reason) reasonParts.push(row.reason);
585
+ if (row.completion?.reason) reasonParts.push(`chat: ${row.completion.reason}`);
586
+ const reason = reasonParts.length > 0 ? chalk.dim(` — ${reasonParts.join("; ")}`) : "";
587
+ const chat = formatCompletionStatus(row.completion);
393
588
  process.stdout.write(
394
- ` ${status} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
589
+ ` ${status}${chat} id=${row.id.toString().padStart(3)} ${row.type.padEnd(7)} ${identity}${remote}${reason}\n`,
395
590
  );
396
591
  }
397
592
  }
398
593
  const failed = results.filter(row => row.ok === false).length;
399
594
  const unverifiable = results.filter(row => row.ok === null).length;
400
595
  const passing = results.filter(row => row.ok === true).length;
401
- process.stdout.write(
402
- `\n${chalk.green(`${passing} ok`)}, ${chalk.red(`${failed} failed`)}, ${chalk.yellow(`${unverifiable} unverifiable`)}, ${results.length} total\n`,
403
- );
404
- if (failed > 0) process.exitCode = 1;
596
+ const chatFailed = flags.strict ? results.filter(row => row.completion?.ok === false).length : 0;
597
+ const summaryParts = [
598
+ chalk.green(`${passing} ok`),
599
+ chalk.red(`${failed} failed`),
600
+ chalk.yellow(`${unverifiable} unverifiable`),
601
+ ];
602
+ if (flags.strict) summaryParts.push(chalk.red(`${chatFailed} chat-failed`));
603
+ summaryParts.push(`${results.length} total`);
604
+ process.stdout.write(`\n${summaryParts.join(", ")}\n`);
605
+ if (failed > 0 || chatFailed > 0) process.exitCode = 1;
405
606
  }
406
607
  } finally {
407
608
  storage.close();
@@ -22,13 +22,17 @@ export default class AuthGateway extends Command {
22
22
  };
23
23
 
24
24
  static flags = {
25
- json: Flags.boolean({ description: "Output JSON (token/status)" }),
25
+ json: Flags.boolean({ description: "Output JSON (token/status/check)" }),
26
26
  bind: Flags.string({ description: "Bind address for `serve` (host:port)", char: "b" }),
27
27
  regenerate: Flags.boolean({ description: "Regenerate the gateway bearer token (token)" }),
28
28
  "no-auth": Flags.boolean({
29
29
  description:
30
30
  "Disable inbound bearer-token auth (serve). Useful when bound to loopback — any caller is allowed.",
31
31
  }),
32
+ strict: Flags.boolean({
33
+ description:
34
+ "For `check`: additionally probe each credential against its provider's chat-completion endpoint. Slower; consumes a tiny amount of quota per credential.",
35
+ }),
32
36
  };
33
37
 
34
38
  static examples = [
@@ -40,6 +44,7 @@ export default class AuthGateway extends Command {
40
44
  "# Show local gateway + broker config status\n omp auth-gateway status",
41
45
  "# Probe each broker credential to see which one is producing 401s\n omp auth-gateway check",
42
46
  "# Same, machine-readable for scripts\n omp auth-gateway check --json",
47
+ "# Strict check — also exercises each credential with a real chat-completion ping\n omp auth-gateway check --strict",
43
48
  ];
44
49
 
45
50
  async run(): Promise<void> {
@@ -55,6 +60,7 @@ export default class AuthGateway extends Command {
55
60
  bind: flags.bind,
56
61
  regenerate: flags.regenerate,
57
62
  noAuth: flags["no-auth"],
63
+ strict: flags.strict,
58
64
  },
59
65
  };
60
66
  await initTheme();
@@ -1568,16 +1568,6 @@ export const SETTINGS_SCHEMA = {
1568
1568
  },
1569
1569
  },
1570
1570
 
1571
- "edit.hashlineAutoDropPureInsertDuplicates": {
1572
- type: "boolean",
1573
- default: false,
1574
- ui: {
1575
- tab: "editing",
1576
- label: "Hashline Duplicate Insert Drop",
1577
- description:
1578
- "Drop payload lines that duplicate adjacent file context — 2+-line context echoes on `↑`/`↓` inserts, and a single boundary line at either edge of an `A-B:` replacement",
1579
- },
1580
- },
1581
1571
  "edit.blockAutoGenerated": {
1582
1572
  type: "boolean",
1583
1573
  default: true,
@@ -1605,7 +1595,7 @@ export const SETTINGS_SCHEMA = {
1605
1595
  tab: "editing",
1606
1596
  label: "Hash Lines",
1607
1597
  description:
1608
- "Include file-hash headers and line numbers in read output for hashline edit mode (¶PATH#hash plus LINE:content)",
1598
+ "Include snapshot-tag headers and line numbers in read output for hashline edit mode (¶PATH#tag plus LINE:content)",
1609
1599
  },
1610
1600
  },
1611
1601
 
@@ -2083,6 +2073,17 @@ export const SETTINGS_SCHEMA = {
2083
2073
  ui: { tab: "tools", label: "Read URLs", description: "Allow the read tool to fetch and process URLs" },
2084
2074
  },
2085
2075
 
2076
+ "vault.enabled": {
2077
+ type: "boolean",
2078
+ default: false,
2079
+ ui: {
2080
+ tab: "tools",
2081
+ label: "Obsidian Vault",
2082
+ description:
2083
+ "Enable the vault:// internal URL for reading and editing Obsidian vault content via the Obsidian CLI. When disabled, vault:// resolution is refused and the vault:// entry is omitted from the system prompt.",
2084
+ },
2085
+ },
2086
+
2086
2087
  "github.enabled": {
2087
2088
  type: "boolean",
2088
2089
  default: false,
@@ -2,21 +2,24 @@
2
2
  * Session-bound file snapshot store.
3
3
  *
4
4
  * Used by `read` and `search` to record exactly what the model saw, and by
5
- * the hashline patcher to recover from stale section hashes (file changed
6
- * externally between read and edit, or a prior in-session edit advanced
7
- * the hash). The store is the {@link InMemorySnapshotStore} implementation
5
+ * the hashline patcher to verify or recover from stale section tags (file
6
+ * changed externally between read and edit, or a prior in-session edit
7
+ * advanced the tag). The store is the {@link InMemorySnapshotStore}
8
8
  * from `@oh-my-pi/hashline`; the only coding-agent-specific concern here
9
- * is wiring it onto the per-session {@link ToolSession} object.
9
+ * is wiring it onto the per-session owner object.
10
10
  */
11
11
  import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
12
- import type { ToolSession } from "../tools";
12
+
13
+ interface FileSnapshotStoreOwner {
14
+ fileSnapshotStore?: InMemorySnapshotStore;
15
+ }
13
16
 
14
17
  /**
15
18
  * Look up (or lazily create) the file snapshot store attached to a session.
16
19
  * Storage lives on `session.fileSnapshotStore` so it ages out exactly with
17
20
  * the session itself.
18
21
  */
19
- export function getFileSnapshotStore(session: ToolSession): InMemorySnapshotStore {
22
+ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemorySnapshotStore {
20
23
  if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
21
24
  return session.fileSnapshotStore;
22
25
  }
@@ -5,16 +5,17 @@
5
5
  * pair to {@link generateDiffString} so the renderer can show the diff
6
6
  * while the tool call is still streaming.
7
7
  *
8
- * Validation is intentionally light: only the section file hash is checked
8
+ * Validation is intentionally light: only the section snapshot tag is checked
9
9
  * (so the preview goes red when anchors are stale), no plan-mode guards
10
10
  * and no auto-generated-file refusal — those belong on the write path.
11
11
  */
12
12
  import {
13
- computeFileHash,
14
13
  Patch as HashlinePatch,
15
14
  normalizeToLF,
16
15
  type Patch,
17
16
  type PatchSection,
17
+ type Snapshot,
18
+ type SnapshotStore,
18
19
  stripBom,
19
20
  } from "@oh-my-pi/hashline";
20
21
  import { resolveToCwd } from "../../tools/path-utils";
@@ -22,7 +23,6 @@ import { generateDiffString } from "../diff";
22
23
  import { readEditFileText } from "../read-file";
23
24
 
24
25
  export interface HashlineDiffOptions {
25
- autoDropPureInsertDuplicates?: boolean;
26
26
  /**
27
27
  * Use the streaming-tolerant applier ({@link PatchSection.applyPartialTo})
28
28
  * so trailing in-flight ops do not throw or emit phantom edits. Streaming
@@ -44,20 +44,34 @@ function hasAnchorScoped(section: PatchSection): boolean {
44
44
  return section.hasAnchorScopedEdit;
45
45
  }
46
46
 
47
- function validateSectionHash(section: PatchSection, text: string): string | null {
47
+ function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string, anchorLines: readonly number[]): boolean {
48
+ if (snapshot.fullText !== undefined) return snapshot.fullText === currentText;
49
+ for (const lineNumber of anchorLines) {
50
+ if (snapshot.get(lineNumber) === undefined) return false;
51
+ }
52
+ return snapshot.matchesLiveFile(currentText.split("\n"));
53
+ }
54
+
55
+ function validateSectionHash(
56
+ section: PatchSection,
57
+ absolutePath: string,
58
+ text: string,
59
+ snapshots: SnapshotStore,
60
+ ): string | null {
48
61
  if (section.fileHash === undefined) {
49
62
  return hasAnchorScoped(section)
50
- ? `Missing hashline file hash for anchored edit to ${section.path}; use \`¶${section.path}#hash\` from your latest read.`
63
+ ? `Missing hashline snapshot tag for anchored edit to ${section.path}; use \`¶${section.path}#tag\` from your latest read.`
51
64
  : null;
52
65
  }
53
- const currentHash = computeFileHash(text);
54
- if (currentHash === section.fileHash) return null;
55
- return `Hashline file hash mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file hashes to #${currentHash}; re-read and try again.`;
66
+ const snapshot = snapshots.byHash(absolutePath, section.fileHash);
67
+ if (snapshot && snapshotMatchesCurrent(snapshot, text, section.collectAnchorLines())) return null;
68
+ return `Hashline snapshot tag mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file does not match that snapshot; re-read and try again.`;
56
69
  }
57
70
 
58
71
  export async function computeHashlineSectionDiff(
59
72
  section: PatchSection,
60
73
  cwd: string,
74
+ snapshots: SnapshotStore,
61
75
  options: HashlineDiffOptions = {},
62
76
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
63
77
  try {
@@ -65,11 +79,9 @@ export async function computeHashlineSectionDiff(
65
79
  const rawContent = await readSectionText(absolutePath, section.path);
66
80
  const { text: content } = stripBom(rawContent);
67
81
  const normalized = normalizeToLF(content);
68
- const hashError = validateSectionHash(section, normalized);
82
+ const hashError = validateSectionHash(section, absolutePath, normalized, snapshots);
69
83
  if (hashError) return { error: hashError };
70
- const result = options.streaming
71
- ? section.applyPartialTo(normalized, options)
72
- : section.applyTo(normalized, options);
84
+ const result = options.streaming ? section.applyPartialTo(normalized) : section.applyTo(normalized);
73
85
  if (normalized === result.text) return { error: `No changes would be made to ${section.path}.` };
74
86
  return generateDiffString(normalized, result.text);
75
87
  } catch (err) {
@@ -80,6 +92,7 @@ export async function computeHashlineSectionDiff(
80
92
  export async function computeHashlineDiff(
81
93
  input: { input: string },
82
94
  cwd: string,
95
+ snapshots: SnapshotStore,
83
96
  options: HashlineDiffOptions = {},
84
97
  ): Promise<{ diff: string; firstChangedLine: number | undefined } | { error: string }> {
85
98
  let patch: Patch;
@@ -91,5 +104,5 @@ export async function computeHashlineDiff(
91
104
  if (patch.sections.length !== 1) {
92
105
  return { error: "Streaming diff preview supports exactly one hashline section." };
93
106
  }
94
- return computeHashlineSectionDiff(patch.sections[0], cwd, options);
107
+ return computeHashlineSectionDiff(patch.sections[0], cwd, snapshots, options);
95
108
  }
@@ -37,14 +37,19 @@ export interface ExecuteHashlineSingleOptions {
37
37
  beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
38
38
  }
39
39
 
40
- function getHashlineApplyOptions(session: ToolSession): { autoDropPureInsertDuplicates: boolean } {
41
- return {
42
- autoDropPureInsertDuplicates: session.settings.get("edit.hashlineAutoDropPureInsertDuplicates"),
43
- };
44
- }
45
-
46
40
  function noChangeDiagnostic(path: string): string {
47
- return `Edits to ${path} resulted in no changes being made.`;
41
+ // The patch parsed and applied cleanly but produced no change the
42
+ // `|literal` body rows matched the file content at the targeted lines
43
+ // byte-for-byte. The model usually misreads this as "wrong anchor, try
44
+ // again with a bigger payload" and starts duplicating content; the
45
+ // message below names the cause directly so the next turn can re-read
46
+ // instead of expanding the patch.
47
+ return (
48
+ `Edits to ${path} parsed and applied cleanly, but produced no change: ` +
49
+ `your body row(s) are byte-identical to the file at the targeted lines. ` +
50
+ `The bug is somewhere else — re-read the file before issuing another edit. ` +
51
+ `Do NOT widen the payload or add lines; verify the anchor first.`
52
+ );
48
53
  }
49
54
 
50
55
  function assertUniqueCanonicalPaths(prepared: readonly PreparedSection[]): void {
@@ -128,8 +133,7 @@ export async function executeHashlineSingle(
128
133
  batchRequest: options.batchRequest,
129
134
  });
130
135
  const snapshots = getFileSnapshotStore(options.session);
131
- const applyOptions = getHashlineApplyOptions(options.session);
132
- const patcher = new Patcher({ fs, snapshots, applyOptions });
136
+ const patcher = new Patcher({ fs, snapshots });
133
137
 
134
138
  // Single-section fast path: prepare, commit, render.
135
139
  if (patch.sections.length === 1) {
@@ -235,24 +235,24 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, filePath?: string)
235
235
 
236
236
  function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, label = "streaming"): string {
237
237
  if (!diff) return "";
238
- // Hunk-aware truncation keeps the change rows themselves visible and
239
- // trims surrounding context proportionally so a multi-hunk diff doesn't
240
- // turn into just the tail of the last hunk while streaming.
238
+ // Hunk-aware truncation keeps the change rows themselves visible. Tail-mode
239
+ // pins the visible window to the bottom of the diff so newly streamed
240
+ // hunks stay on screen as more arrives, instead of leaving the user stuck
241
+ // staring at the head of the file while the tail scrolls offscreen.
241
242
  const {
242
243
  text: truncatedDiff,
243
244
  hiddenHunks,
244
245
  hiddenLines,
245
- } = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES);
246
+ } = truncateDiffByHunk(diff, PREVIEW_LIMITS.DIFF_COLLAPSED_HUNKS, EDIT_STREAMING_PREVIEW_LINES, { fromTail: true });
246
247
  let text = "\n\n";
247
- text += renderDiffColored(truncatedDiff, { filePath: rawPath });
248
248
  if (hiddenHunks > 0 || hiddenLines > 0) {
249
249
  const remainder: string[] = [];
250
250
  if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
251
251
  if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
252
- text += uiTheme.fg("dim", `\n… (${label} +${remainder.join(", ")})`);
253
- } else {
254
- text += uiTheme.fg("dim", `\n(${label})`);
252
+ text += `${uiTheme.fg("dim", `… (${remainder.join(", ")} above)`)}\n`;
255
253
  }
254
+ text += renderDiffColored(truncatedDiff, { filePath: rawPath });
255
+ text += uiTheme.fg("dim", `\n(${label})`);
256
256
  return text;
257
257
  }
258
258
 
@@ -312,7 +312,7 @@ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** E
312
312
 
313
313
  function normalizeHashlineInputPreviewPath(rawPath: string): string {
314
314
  const trimmed = rawPath.trim();
315
- const hashStart = /#[0-9a-f]{4}$/u.exec(trimmed)?.index;
315
+ const hashStart = /#[0-9a-fA-F]{3}$/u.exec(trimmed)?.index;
316
316
  const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
317
317
  if (withoutHash.length < 2) return withoutHash;
318
318
  const first = withoutHash[0];
@@ -20,6 +20,7 @@ import {
20
20
  END_PATCH_MARKER,
21
21
  type PatchSection as HashlineInputSection,
22
22
  Patch as HashlinePatch,
23
+ type SnapshotStore,
23
24
  } from "@oh-my-pi/hashline";
24
25
  import type { Theme } from "../modes/theme/theme";
25
26
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
@@ -39,9 +40,9 @@ export interface PerFileDiffPreview {
39
40
  export interface StreamingDiffContext {
40
41
  cwd: string;
41
42
  signal: AbortSignal;
43
+ snapshots: SnapshotStore;
42
44
  fuzzyThreshold?: number;
43
45
  allowFuzzy?: boolean;
44
- hashlineAutoDropPureInsertDuplicates?: boolean;
45
46
  /**
46
47
  * True while the tool's arguments are still streaming in. Strategies that
47
48
  * accept free-form text input (apply_patch, hashline) trim the trailing
@@ -325,9 +326,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
325
326
  // to parse; suppress until the next chunk arrives. Once args are
326
327
  // complete, surface the error so the model sees what went wrong.
327
328
  if (ctx.isStreaming) return null;
328
- const result = await computeHashlineDiff({ input }, ctx.cwd, {
329
- autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
330
- });
329
+ const result = await computeHashlineDiff({ input }, ctx.cwd, ctx.snapshots);
331
330
  ctx.signal.throwIfAborted();
332
331
  return [toPerFilePreview("", result)];
333
332
  }
@@ -346,8 +345,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
346
345
  for (let i = 0; i < sectionsToProcess.length; i++) {
347
346
  ctx.signal.throwIfAborted();
348
347
  const section = sectionsToProcess[i];
349
- const result = await computeHashlineSectionDiff(section, ctx.cwd, {
350
- autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
348
+ const result = await computeHashlineSectionDiff(section, ctx.cwd, ctx.snapshots, {
351
349
  streaming: ctx.isStreaming,
352
350
  });
353
351
  ctx.signal.throwIfAborted();
@@ -5,7 +5,7 @@ import { checkPythonKernelAvailability } from "./kernel";
5
5
 
6
6
  const PYTHON_SESSION_PREFIX = "python:";
7
7
 
8
- function namespaceSessionId(sessionId: string): string {
8
+ export function namespaceSessionId(sessionId: string): string {
9
9
  return sessionId.startsWith(PYTHON_SESSION_PREFIX) ? sessionId : `${PYTHON_SESSION_PREFIX}${sessionId}`;
10
10
  }
11
11
 
@@ -100,7 +100,7 @@ export type CustomToolSessionEvent =
100
100
  }
101
101
  | {
102
102
  reason: "auto_compaction_start";
103
- trigger: "threshold" | "overflow" | "idle";
103
+ trigger: "threshold" | "overflow" | "idle" | "incomplete";
104
104
  action: "context-full" | "handoff";
105
105
  }
106
106
  | {
@@ -203,7 +203,7 @@ export interface TurnEndEvent {
203
203
  /** Fired when auto-compaction starts */
204
204
  export interface AutoCompactionStartEvent {
205
205
  type: "auto_compaction_start";
206
- reason: "threshold" | "overflow" | "idle";
206
+ reason: "threshold" | "overflow" | "idle" | "incomplete";
207
207
  action: "context-full" | "handoff";
208
208
  }
209
209