@gajae-code/coding-agent 0.13.0 → 0.13.1

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 (113) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  35. package/dist/types/task/executor.d.ts +4 -7
  36. package/dist/types/tools/index.d.ts +1 -1
  37. package/dist/types/tools/read.d.ts +1 -1
  38. package/dist/types/tools/search.d.ts +1 -1
  39. package/dist/types/utils/edit-mode.d.ts +69 -0
  40. package/package.json +8 -8
  41. package/scripts/generate-hotkeys-docs.ts +8 -6
  42. package/src/commands/sdk.ts +23 -2
  43. package/src/config/file-lock.ts +25 -5
  44. package/src/config/keybindings.ts +4 -2
  45. package/src/config/model-registry.ts +20 -0
  46. package/src/config/models-config-schema.ts +1 -0
  47. package/src/config/settings-schema.ts +29 -22
  48. package/src/config/settings.ts +42 -10
  49. package/src/coordinator-mcp/server.ts +84 -106
  50. package/src/edit/index.ts +5 -19
  51. package/src/edit/renderer.ts +3 -2
  52. package/src/edit/streaming.ts +28 -15
  53. package/src/eval/py/executor.ts +108 -10
  54. package/src/eval/py/kernel.ts +86 -12
  55. package/src/eval/py/runtime.ts +200 -16
  56. package/src/exec/bash-executor.ts +36 -37
  57. package/src/gjc-runtime/gc-runtime.ts +25 -3
  58. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  59. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  60. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  61. package/src/internal-urls/docs-index.generated.ts +6 -6
  62. package/src/modes/acp/acp-agent.ts +10 -2
  63. package/src/modes/components/model-selector.ts +28 -8
  64. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  65. package/src/modes/controllers/selector-controller.ts +4 -2
  66. package/src/runtime/process-lifecycle.ts +126 -27
  67. package/src/runtime-mcp/transports/stdio.ts +11 -6
  68. package/src/sdk/acp/adapter.ts +18 -10
  69. package/src/sdk/broker/broker.ts +55 -4
  70. package/src/sdk/broker/ensure.ts +10 -7
  71. package/src/sdk/broker/identity.ts +10 -0
  72. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  73. package/src/sdk/broker/lifecycle.ts +53 -10
  74. package/src/sdk/broker/session-index.ts +28 -11
  75. package/src/sdk/broker/startup-failure.ts +101 -0
  76. package/src/sdk/broker/transport.ts +1 -0
  77. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  78. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  79. package/src/sdk/bus/conversation-store.ts +171 -50
  80. package/src/sdk/bus/index.ts +101 -47
  81. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  82. package/src/sdk/bus/notification-service.ts +3 -3
  83. package/src/sdk/bus/reconciliation-store.ts +16 -0
  84. package/src/sdk/bus/telegram-daemon-contract.ts +11 -2
  85. package/src/sdk/bus/telegram-daemon.ts +131 -29
  86. package/src/sdk/bus/topic-registry.ts +38 -9
  87. package/src/sdk/cli/session-cli.ts +20 -7
  88. package/src/sdk/client/discovery.ts +23 -2
  89. package/src/sdk/host/query/cursor.ts +4 -0
  90. package/src/sdk/host/query/handlers.ts +99 -100
  91. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  92. package/src/sdk/host/session-runtime.test.ts +98 -1
  93. package/src/sdk/host/session-runtime.ts +57 -15
  94. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  95. package/src/sdk/host/websocket-transport.ts +54 -30
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/mcp/server.ts +19 -6
  98. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  99. package/src/sdk/protocol/operation-registry.ts +8 -12
  100. package/src/sdk/session.ts +5 -14
  101. package/src/sdk/turn-result.ts +53 -0
  102. package/src/session/agent-session.ts +112 -69
  103. package/src/session/blob-store.ts +91 -21
  104. package/src/session/session-manager.ts +408 -81
  105. package/src/session/session-storage.ts +68 -10
  106. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  107. package/src/task/executor.ts +25 -8
  108. package/src/task/index.ts +0 -2
  109. package/src/tools/index.ts +1 -1
  110. package/src/tools/read.ts +12 -11
  111. package/src/tools/search.ts +7 -7
  112. package/src/utils/edit-mode.ts +180 -9
  113. package/src/web/search/providers/insane.ts +55 -1
@@ -748,14 +748,47 @@ function fsyncDirectorySync(pathname: string): void {
748
748
 
749
749
  /** Best-effort identity-checked removal of one of our own staged temp names. */
750
750
  function unlinkOwnedStagedSync(stagingPath: string, expected: { dev: bigint; ino: bigint }): void {
751
+ let fd: number | undefined;
751
752
  try {
752
753
  const named = fs.lstatSync(stagingPath, { bigint: true });
753
754
  if (!named.isFile() || named.isSymbolicLink() || named.dev !== expected.dev || named.ino !== expected.ino) return;
754
- const sha256 = createHash("sha256").update(fs.readFileSync(stagingPath)).digest("hex");
755
- exactRemoveSessionStorageLockPath(stagingPath, named, sha256, named.nlink > 1n);
755
+ fd = fs.openSync(stagingPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
756
+ const opened = fs.fstatSync(fd, { bigint: true });
757
+ if (
758
+ !opened.isFile() ||
759
+ opened.dev !== named.dev ||
760
+ opened.ino !== named.ino ||
761
+ opened.nlink !== named.nlink ||
762
+ opened.size !== named.size ||
763
+ opened.mtimeNs !== named.mtimeNs ||
764
+ opened.ctimeNs !== named.ctimeNs
765
+ )
766
+ return;
767
+ const hash = createHash("sha256");
768
+ const buffer = Buffer.allocUnsafe(64 * 1024);
769
+ let offset = 0;
770
+ while (offset < Number(opened.size)) {
771
+ const length = Math.min(buffer.byteLength, Number(opened.size) - offset);
772
+ const count = fs.readSync(fd, buffer, 0, length, offset);
773
+ if (count === 0) return;
774
+ hash.update(buffer.subarray(0, count));
775
+ offset += count;
776
+ }
777
+ const after = fs.fstatSync(fd, { bigint: true });
778
+ if (
779
+ after.dev !== opened.dev ||
780
+ after.ino !== opened.ino ||
781
+ after.nlink !== opened.nlink ||
782
+ after.size !== opened.size ||
783
+ after.mtimeNs !== opened.mtimeNs ||
784
+ after.ctimeNs !== opened.ctimeNs
785
+ )
786
+ return;
787
+ exactRemoveSessionStorageLockPath(stagingPath, after, hash.digest("hex"), after.nlink > 1n);
756
788
  } catch {
757
789
  // ENOENT means the name was already consumed or removed; cleanup is best-effort.
758
- return;
790
+ } finally {
791
+ if (fd !== undefined) fs.closeSync(fd);
759
792
  }
760
793
  }
761
794
 
@@ -1601,16 +1634,16 @@ function parseSessionStorageLockOwner(bytes: Buffer): SessionStorageLockOwner |
1601
1634
  }
1602
1635
  }
1603
1636
 
1604
- function exactRemoveSessionStorageLockPath(
1637
+ function exactRemoveSessionStorageLockPathResult(
1605
1638
  lockPath: string,
1606
1639
  expected: fs.BigIntStats,
1607
1640
  sha256: string,
1608
1641
  allowHardLink = false,
1609
- ): boolean {
1642
+ ): native.NativeExactUnlinkResult {
1610
1643
  try {
1611
1644
  const parent = fs.lstatSync(path.dirname(lockPath), { bigint: true });
1612
- if (!parent.isDirectory() || parent.isSymbolicLink()) return false;
1613
- const result = nativeSessionStorage().exactUnlink(lockPath, {
1645
+ if (!parent.isDirectory() || parent.isSymbolicLink()) return { ok: false, code: "parent_mismatch" };
1646
+ return nativeSessionStorage().exactUnlink(lockPath, {
1614
1647
  dev: expected.dev,
1615
1648
  ino: expected.ino,
1616
1649
  nlink: expected.nlink,
@@ -1622,12 +1655,27 @@ function exactRemoveSessionStorageLockPath(
1622
1655
  quarantineName: `${path.basename(lockPath)}.reap-${randomUUID()}`,
1623
1656
  allowHardLink,
1624
1657
  });
1625
- return result.ok || (result.code === "cleanup_pending" && typeof result.detachedPath === "string");
1626
1658
  } catch {
1627
- return false;
1659
+ return { ok: false, code: "native_failure" };
1628
1660
  }
1629
1661
  }
1630
1662
 
1663
+ function exactRemoveSessionStorageLockPath(
1664
+ lockPath: string,
1665
+ expected: fs.BigIntStats,
1666
+ sha256: string,
1667
+ allowHardLink = false,
1668
+ ): boolean {
1669
+ const result = exactRemoveSessionStorageLockPathResult(lockPath, expected, sha256, allowHardLink);
1670
+ return (
1671
+ result.ok ||
1672
+ (result.code === "cleanup_pending" &&
1673
+ result.payloadDurable === true &&
1674
+ !result.retainedSuccessorPath &&
1675
+ !result.retainedUnknownPath)
1676
+ );
1677
+ }
1678
+
1631
1679
  function exactRemoveOwnedSessionStorageLockPath(
1632
1680
  lockPath: string,
1633
1681
  expected: Pick<fs.BigIntStats, "dev" | "ino">,
@@ -1836,7 +1884,17 @@ export class FileSessionStorage implements SessionStorage {
1836
1884
  return {
1837
1885
  releaseSync: () => {
1838
1886
  if (released) return;
1839
- if (!exactRemoveSessionStorageLockPath(lockPath, expected, ownerDigest!)) {
1887
+ const removal = exactRemoveSessionStorageLockPathResult(lockPath, expected, ownerDigest!);
1888
+ if (!removal.ok) {
1889
+ if (
1890
+ removal.code === "cleanup_pending" &&
1891
+ removal.payloadDurable === true &&
1892
+ !removal.retainedSuccessorPath &&
1893
+ !removal.retainedUnknownPath
1894
+ ) {
1895
+ released = true;
1896
+ return;
1897
+ }
1840
1898
  try {
1841
1899
  const current = fs.lstatSync(lockPath, { bigint: true });
1842
1900
  if (current.dev === expected.dev && current.ino === expected.ino) {
@@ -1,4 +1,4 @@
1
- import type { Model } from "@gajae-code/ai/core";
1
+ import { type Model, modelSupportsServiceTier, modelsAreEqual } from "@gajae-code/ai/core";
2
2
 
3
3
  /**
4
4
  * A single line in the `/fast status` report: a labelled model and whether fast
@@ -55,7 +55,7 @@ export function formatFastStatusReport(args: FormatFastStatusReportArgs): string
55
55
  export interface FastStatusSessionLike {
56
56
  readonly model?: Model;
57
57
  /** Fast predicate against the main session tier (current model + `modelRoles`). */
58
- isFastForProvider(provider?: string): boolean;
58
+ isFastForProvider(provider?: string, supportsServiceTier?: boolean): boolean;
59
59
  /**
60
60
  * Current-model EFFECTIVE fast state (intent minus any provider auto-disable).
61
61
  * Used for the current-model row so it matches what the next request does.
@@ -63,8 +63,13 @@ export interface FastStatusSessionLike {
63
63
  */
64
64
  isFastModeActive?(): boolean;
65
65
  /** Fast predicate against the effective subagent tier (`task.agentModelOverrides` roles). */
66
- isFastForSubagentProvider(provider?: string): boolean;
66
+ isFastForSubagentProvider(provider?: string, supportsServiceTier?: boolean): boolean;
67
67
  resolveRoleModelWithThinking(role: string): { model?: Model };
68
+ /** Runtime fallback position for the configured DEFAULT chain. */
69
+ getDefaultFallbackRuntimeState?(): {
70
+ chain: { entries: readonly string[] };
71
+ controller: { activeIndex: number };
72
+ };
68
73
  }
69
74
 
70
75
  /** A role to enumerate in the report, with the tier source its subagent runs under. */
@@ -106,15 +111,26 @@ export function buildFastStatusReport(args: BuildFastStatusReportArgs): string {
106
111
  // on pure intent. Fall back to intent when a fake omits `isFastModeActive`.
107
112
  const currentFast = session.isFastModeActive
108
113
  ? session.isFastModeActive()
109
- : session.isFastForProvider(session.model?.provider);
114
+ : session.isFastForProvider(session.model?.provider, modelSupportsServiceTier(session.model));
110
115
  const rows: FastStatusRow[] = [{ label: "현재 모델", model: session.model, fast: currentFast }];
111
116
  for (const target of roleTargets) {
112
117
  const resolved = session.resolveRoleModelWithThinking(target.id);
113
- if (resolved.model) {
118
+ const fallbackState = target.id === "default" ? session.getDefaultFallbackRuntimeState?.() : undefined;
119
+ const activeDefaultFallback =
120
+ fallbackState !== undefined &&
121
+ fallbackState.chain.entries.length > 1 &&
122
+ fallbackState.controller.activeIndex > 0 &&
123
+ fallbackState.controller.activeIndex < fallbackState.chain.entries.length;
124
+ const rowModel = activeDefaultFallback && session.model ? session.model : resolved.model;
125
+ if (rowModel) {
126
+ const supportsServiceTier = modelSupportsServiceTier(rowModel);
127
+ const isCurrentModel = session.model !== undefined && modelsAreEqual(session.model, rowModel);
114
128
  const fast = target.isSubagentRole
115
- ? session.isFastForSubagentProvider(resolved.model.provider)
116
- : session.isFastForProvider(resolved.model.provider);
117
- rows.push({ label: target.label, model: resolved.model, fast });
129
+ ? session.isFastForSubagentProvider(rowModel.provider, supportsServiceTier)
130
+ : isCurrentModel
131
+ ? currentFast
132
+ : session.isFastForProvider(rowModel.provider, supportsServiceTier);
133
+ rows.push({ label: target.label, model: rowModel, fast });
118
134
  }
119
135
  }
120
136
  return formatFastStatusReport({ rows, iconFast, formatInactive });
@@ -15,7 +15,14 @@ import type {
15
15
  } from "@gajae-code/agent-core";
16
16
  import { recordHandoff, resolveTelemetry } from "@gajae-code/agent-core";
17
17
  import { estimateMessageTokensHeuristic } from "@gajae-code/agent-core/compaction";
18
- import type { AssistantMessage, Message, Model, ServiceTier } from "@gajae-code/ai/core";
18
+ import {
19
+ type AssistantMessage,
20
+ isFastModeEffectiveForProvider,
21
+ type Message,
22
+ type Model,
23
+ modelSupportsServiceTier,
24
+ type ServiceTier,
25
+ } from "@gajae-code/ai/core";
19
26
  import { type JsonSchemaValidationIssue, validateJsonSchemaValue } from "@gajae-code/ai/utils/schema";
20
27
  import { logger, prompt, untilAborted } from "@gajae-code/utils";
21
28
  import { AsyncJobManager } from "../async";
@@ -248,14 +255,11 @@ export interface ExecutorOptions {
248
255
  /** Parent session's registry; shared by child sessions for IRC routing and roster visibility. */
249
256
  agentRegistry?: AgentRegistry;
250
257
  /**
251
- * Live service-tier intent of the parent session (`AgentSession.serviceTier`),
252
- * used as the inherited tier when `task.serviceTier === "inherit"`. Passing the
253
- * live value (not the stale settings snapshot) lets a runtime `/fast on` reach
254
- * subagents, and a main-model fast-mode auto-disable does not clobber it.
258
+ * Parent service-tier intent captured when the child is spawned. Used when
259
+ * `task.serviceTier === "inherit"` so request serialization and reporting use
260
+ * the same immutable child settings snapshot.
255
261
  */
256
262
  inheritedServiceTier?: ServiceTier;
257
- /** Resolve whether the effective subagent tier grants fast mode for the selected provider. */
258
- isFastForSubagentProvider?: (provider?: string) => boolean;
259
263
  /** Override local:// protocol options so subagent shares parent's local:// root */
260
264
  localProtocolOptions?: LocalProtocolOptions;
261
265
  /**
@@ -865,6 +869,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
865
869
 
866
870
  const settings = options.settings ?? Settings.isolated();
867
871
  const subagentSettings = createSubagentSettings(settings, options.inheritedServiceTier);
872
+ const configuredSubagentServiceTier = subagentSettings.get("serviceTier");
873
+ const subagentServiceTier = configuredSubagentServiceTier === "none" ? undefined : configuredSubagentServiceTier;
874
+ const isFastForModel = (candidate: Model | undefined): boolean =>
875
+ candidate !== undefined &&
876
+ isFastModeEffectiveForProvider(subagentServiceTier, candidate.provider, modelSupportsServiceTier(candidate));
868
877
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
869
878
  const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
870
879
  const parentDepth = options.taskDepth ?? 0;
@@ -1533,7 +1542,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1533
1542
  resolvedModelString = formatModelString(model);
1534
1543
  activeProviderModelString = resolvedModelString;
1535
1544
  }
1536
- progress.fastMode = model ? (options.isFastForSubagentProvider?.(model.provider) ?? false) : false;
1545
+ progress.fastMode = isFastForModel(model);
1537
1546
  if (authFallbackUsed && model && requestedModel) {
1538
1547
  modelSubstitutionWarning = {
1539
1548
  requested: formatModelString(requestedModel),
@@ -1954,6 +1963,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1954
1963
  }
1955
1964
  if (event.type === "model_fallback_switched") {
1956
1965
  activeProviderModelString = event.to;
1966
+ progress.fastMode = isFastForModel(session.model);
1967
+ AsyncJobManager.instance()?.updateSubagentModel?.(options.subagentId ?? id, {
1968
+ requestedModel: modelSubstitutionWarning?.requested ?? resolvedModelString,
1969
+ effectiveModel: event.to,
1970
+ modelFellBack: true,
1971
+ fastMode: progress.fastMode,
1972
+ });
1973
+ scheduleProgress(true);
1957
1974
  forwardSubagentEvent(event);
1958
1975
  return;
1959
1976
  }
package/src/task/index.ts CHANGED
@@ -2027,7 +2027,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2027
2027
  agentRegistry: this.session.agentRegistry,
2028
2028
  settings: this.session.settings,
2029
2029
  inheritedServiceTier: this.session.serviceTier,
2030
- isFastForSubagentProvider: provider => this.session.isFastForSubagentProvider?.(provider) ?? false,
2031
2030
  contextFiles,
2032
2031
  skills: availableSkills,
2033
2032
  autoloadSkills: resolvedAutoloadSkills,
@@ -2105,7 +2104,6 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
2105
2104
  agentRegistry: this.session.agentRegistry,
2106
2105
  settings: this.session.settings,
2107
2106
  inheritedServiceTier: this.session.serviceTier,
2108
- isFastForSubagentProvider: provider => this.session.isFastForSubagentProvider?.(provider) ?? false,
2109
2107
  contextFiles,
2110
2108
  skills: availableSkills,
2111
2109
  autoloadSkills: resolvedAutoloadSkills,
@@ -282,7 +282,7 @@ export interface ToolSession {
282
282
  /** Live service-tier intent of the parent session, inherited by `inherit` subagents. */
283
283
  serviceTier?: ServiceTier;
284
284
  /** Whether the effective subagent tier grants fast mode for a resolved provider. */
285
- isFastForSubagentProvider?: (provider?: string) => boolean;
285
+ isFastForSubagentProvider?: (provider?: string, supportsServiceTier?: boolean) => boolean;
286
286
  /** Plan mode state (if active) */
287
287
  getPlanModeState?: () => PlanModeState | undefined;
288
288
  /** Goal mode state (if active or paused) */
package/src/tools/read.ts CHANGED
@@ -1447,7 +1447,18 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1447
1447
  readonly name = "read";
1448
1448
  readonly label = "Read";
1449
1449
  readonly loadMode = "essential";
1450
- readonly description: string;
1450
+ get description(): string {
1451
+ const displayMode = resolveFileDisplayMode(this.session);
1452
+ return prompt.render(readDescription, {
1453
+ DEFAULT_LIMIT: String(this.#defaultLimit),
1454
+ DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
1455
+ TRUNCATION_DEFAULT: String(this.session.settings.get("read.truncation") ?? "last"),
1456
+ RECEIPT_LINES: String(this.session.settings.get("read.receiptBudgetLines")),
1457
+ RECEIPT_KIB: String(this.session.settings.get("read.receiptBudgetBytes")),
1458
+ IS_HL_MODE: displayMode.hashLines,
1459
+ IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
1460
+ });
1461
+ }
1451
1462
  readonly parameters = readSchema;
1452
1463
  readonly nonAbortable = true;
1453
1464
  readonly strict = true;
@@ -1457,21 +1468,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1457
1468
  readonly #defaultLimit: number;
1458
1469
 
1459
1470
  constructor(private readonly session: ToolSession) {
1460
- const displayMode = resolveFileDisplayMode(session);
1461
1471
  this.#autoResizeImages = session.settings.get("images.autoResize");
1462
1472
  this.#defaultLimit = Math.max(
1463
1473
  1,
1464
1474
  Math.min(session.settings.get("read.defaultLimit") ?? DEFAULT_MAX_LINES, DEFAULT_MAX_LINES),
1465
1475
  );
1466
- this.description = prompt.render(readDescription, {
1467
- DEFAULT_LIMIT: String(this.#defaultLimit),
1468
- DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
1469
- TRUNCATION_DEFAULT: String(this.session.settings.get("read.truncation") ?? "last"),
1470
- RECEIPT_LINES: String(this.session.settings.get("read.receiptBudgetLines")),
1471
- RECEIPT_KIB: String(this.session.settings.get("read.receiptBudgetBytes")),
1472
- IS_HL_MODE: displayMode.hashLines,
1473
- IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
1474
- });
1475
1476
  }
1476
1477
 
1477
1478
  async #resolveArchiveReadPath(readPath: string, signal?: AbortSignal): Promise<ResolvedArchiveReadPath | null> {
@@ -237,17 +237,17 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
237
237
  readonly label = "Search";
238
238
  readonly loadMode = "discoverable";
239
239
  readonly summary = "Search file contents using ripgrep (fast text search)";
240
- readonly description: string;
241
- readonly parameters = searchSchema;
242
- readonly strict = true;
243
-
244
- constructor(private readonly session: ToolSession) {
245
- const displayMode = resolveFileDisplayMode(session);
246
- this.description = prompt.render(searchDescription, {
240
+ get description(): string {
241
+ const displayMode = resolveFileDisplayMode(this.session);
242
+ return prompt.render(searchDescription, {
247
243
  IS_HL_MODE: displayMode.hashLines,
248
244
  IS_LINE_NUMBER_MODE: !displayMode.hashLines && displayMode.lineNumbers,
249
245
  });
250
246
  }
247
+ readonly parameters = searchSchema;
248
+ readonly strict = true;
249
+
250
+ constructor(private readonly session: ToolSession) {}
251
251
 
252
252
  async execute(
253
253
  _toolCallId: string,
@@ -2,8 +2,15 @@ import { $pickenv } from "@gajae-code/utils/env";
2
2
 
3
3
  export type EditMode = "replace" | "patch" | "hashline" | "vim" | "apply_patch";
4
4
 
5
+ /** Setting value for `edit.mode`: an executable mode or `auto` (model-family routing). */
6
+ export type EditModeSetting = "auto" | EditMode;
7
+
8
+ /** Fallback executable mode used when automatic routing cannot identify the model. */
5
9
  export const DEFAULT_EDIT_MODE: EditMode = "hashline";
6
10
 
11
+ /** Default `edit.mode` setting value. */
12
+ export const DEFAULT_EDIT_MODE_SETTING: EditModeSetting = "auto";
13
+
7
14
  const EDIT_MODE_IDS = {
8
15
  apply_patch: "apply_patch",
9
16
  hashline: "hashline",
@@ -14,29 +21,193 @@ const EDIT_MODE_IDS = {
14
21
 
15
22
  export const EDIT_MODES = Object.keys(EDIT_MODE_IDS) as EditMode[];
16
23
 
24
+ export const EDIT_MODE_SETTINGS: EditModeSetting[] = ["auto", ...EDIT_MODES];
25
+
17
26
  export function normalizeEditMode(mode?: string | null): EditMode | undefined {
18
27
  if (!mode) return undefined;
19
- return EDIT_MODE_IDS[mode as keyof typeof EDIT_MODE_IDS];
28
+ return Object.hasOwn(EDIT_MODE_IDS, mode) ? EDIT_MODE_IDS[mode as keyof typeof EDIT_MODE_IDS] : undefined;
29
+ }
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Model-family detection (automatic routing)
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ export type ModelEditFamily = "gpt" | "codex" | "claude" | "deepseek" | "qwen" | "minimax" | "glm" | "kimi" | "unknown";
36
+
37
+ /**
38
+ * Built-in family → edit mode mapping used by `edit.mode: auto`.
39
+ *
40
+ * GPT/Codex models are trained on the OpenAI patch grammar; Claude, DeepSeek,
41
+ * and Qwen perform best with exact search/replace; MiniMax, GLM, and
42
+ * Kimi/Moonshot benchmark best with hash-anchored edits. Unknown models fall
43
+ * back to hashline, the safest fail-closed default.
44
+ */
45
+ const FAMILY_EDIT_MODES: Record<Exclude<ModelEditFamily, "unknown">, EditMode> = {
46
+ gpt: "apply_patch",
47
+ codex: "apply_patch",
48
+ claude: "replace",
49
+ deepseek: "replace",
50
+ qwen: "replace",
51
+ minimax: "hashline",
52
+ glm: "hashline",
53
+ kimi: "hashline",
54
+ };
55
+
56
+ /**
57
+ * Detect the model family from a model ID, ignoring the provider prefix so
58
+ * equivalent models served through different providers route identically
59
+ * (`openai/gpt-5.4`, `openrouter/openai/gpt-5.4`, `company-gateway/gpt-5.4`).
60
+ *
61
+ * Matching only examines the final path segment. A direct family prefix or a
62
+ * known provider-qualified family name is accepted, while arbitrary token
63
+ * substrings return `unknown` instead of guessing.
64
+ */
65
+ export function detectModelEditFamily(modelId: string | undefined): ModelEditFamily {
66
+ if (!modelId) return "unknown";
67
+ const normalized = modelId.trim().toLowerCase();
68
+ const segments = normalized.split("/").filter(Boolean);
69
+ const model = segments.at(-1) ?? normalized;
70
+
71
+ if (/^codex(?:$|[-_.\d])|^(?:gpt|(?:duo-chat|openai)-gpt)-\d+(?:[-_.][\w]+)*[-_.]codex(?:$|[-_.\d])/.test(model))
72
+ return "codex";
73
+ if (
74
+ /^(?:gpt-\d+(?:$|[-_.])|gpt-oss(?:$|[-_.\d])|chatgpt(?:$|[-_.]\d))|(?:^|\.)openai\.(?:gpt|chatgpt)(?:$|[-_.\d])/.test(
75
+ model,
76
+ )
77
+ )
78
+ return "gpt";
79
+ if (/^claude(?:$|[-_.\d])|(?:^|\.)anthropic\.claude(?:$|[-_.\d])/.test(model)) return "claude";
80
+ if (/^deepseek(?:$|[-_.\d])|(?:^|\.)deepseek(?:$|[-_.\d])/.test(model)) return "deepseek";
81
+ if (/^qwen(?:$|[-_.\d])|(?:^|\.)qwen\.qwen(?:$|[-_.\d])/.test(model)) return "qwen";
82
+ if (/^minimax(?:$|[-_.\d])/.test(model)) return "minimax";
83
+ if (/^glm(?:$|[-_.\d])/.test(model)) return "glm";
84
+ if (/^(?:kimi|moonshot)(?:$|[-_.\d])/.test(model)) return "kimi";
85
+
86
+ return "unknown";
20
87
  }
21
88
 
89
+ /** Built-in mode for a detected family; `undefined` for `unknown`. */
90
+ export function builtinEditModeForFamily(family: ModelEditFamily): EditMode | undefined {
91
+ if (family === "unknown") return undefined;
92
+ return FAMILY_EDIT_MODES[family];
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // Resolution
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+
99
+ export type EditModeSource = "environment" | "model-override" | "setting" | "catalog" | "builtin-family" | "fallback";
100
+
101
+ export type ResolvedEditModeDetails = {
102
+ mode: EditMode;
103
+ source: EditModeSource;
104
+ modelId?: string;
105
+ family?: ModelEditFamily;
106
+ matchedRule?: string;
107
+ };
108
+
109
+ /** Raw `edit.modelVariants` match: pattern plus the configured (unvalidated) value. */
110
+ export type EditVariantMatch = {
111
+ pattern: string;
112
+ value: string;
113
+ };
114
+
22
115
  export interface EditModeSettingsLike {
23
116
  get(key: "edit.mode"): unknown;
117
+ /** Legacy accessor: first *valid* matching `edit.modelVariants` value. */
24
118
  getEditVariantForModel?(model: string | undefined): EditMode | null;
119
+ /**
120
+ * Discriminated accessor: first matching `edit.modelVariants` rule with its
121
+ * raw value. Lets the resolver fail closed on invalid matched values
122
+ * instead of silently falling through to another mode.
123
+ */
124
+ matchEditVariantForModel?(model: string | undefined): EditVariantMatch | null;
25
125
  }
26
126
 
27
127
  export interface EditModeSessionLike {
28
128
  settings: EditModeSettingsLike;
29
129
  getActiveModelString?: () => string | undefined;
130
+ /**
131
+ * Optional model-catalog edit recommendation; beats the built-in family
132
+ * mapping when present.
133
+ *
134
+ * Extension point: production sessions do not wire this yet — the model
135
+ * catalog does not expose an edit-protocol recommendation (`applyPatchToolType`
136
+ * is the OpenAI wire representation of an already-selected `apply_patch`
137
+ * mode, not a mode selector). Catalog metadata generation is deliberately
138
+ * deferred until the family-based path is proven for arbitrary custom-provider
139
+ * IDs; when it lands, sessions can expose this hook without a resolver change.
140
+ */
141
+ getCatalogEditMode?: () => EditMode | undefined;
30
142
  }
31
143
 
32
- export function resolveEditMode(session: EditModeSessionLike): EditMode {
33
- const activeModel = session.getActiveModelString?.();
34
- const modelVariant = session.settings.getEditVariantForModel?.(activeModel);
35
- if (modelVariant) return modelVariant;
144
+ /**
145
+ * Read the forced edit mode from `GJC_EDIT_VARIANT`/`PI_EDIT_VARIANT`.
146
+ * The environment force is the emergency kill switch and beats every other
147
+ * source. Invalid values fail fast; empty and `auto` mean "not forced".
148
+ */
149
+ export function resolveForcedEnvEditMode(): EditMode | undefined {
150
+ const raw = $pickenv("GJC_EDIT_VARIANT", "PI_EDIT_VARIANT");
151
+ if (!raw || raw === "auto") return undefined;
152
+ const mode = normalizeEditMode(raw);
153
+ if (!mode) {
154
+ throw new Error(`Invalid GJC_EDIT_VARIANT: ${raw}`);
155
+ }
156
+ return mode;
157
+ }
158
+
159
+ /**
160
+ * Resolve the active edit mode with provenance.
161
+ *
162
+ * Precedence:
163
+ * 1. `GJC_EDIT_VARIANT`/`PI_EDIT_VARIANT` environment force (invalid → throw).
164
+ * 2. Matching user `edit.modelVariants` rule (matched invalid → throw).
165
+ * 3. Explicit `edit.mode` setting when it is not `auto`.
166
+ * 4. Model-catalog edit recommendation, when the session exposes one.
167
+ * 5. Built-in model-family mapping.
168
+ * 6. `hashline` fallback.
169
+ */
170
+ export function resolveEditModeDetails(session: EditModeSessionLike): ResolvedEditModeDetails {
171
+ const modelId = session.getActiveModelString?.();
172
+
173
+ const envMode = resolveForcedEnvEditMode();
174
+ if (envMode) return { mode: envMode, source: "environment", modelId };
175
+
176
+ const settings = session.settings;
177
+ if (settings.matchEditVariantForModel) {
178
+ const match = settings.matchEditVariantForModel(modelId);
179
+ if (match) {
180
+ const mode = normalizeEditMode(match.value);
181
+ if (!mode) {
182
+ throw new Error(
183
+ `Invalid edit.modelVariants value "${match.value}" for pattern "${match.pattern}" (expected one of: ${EDIT_MODES.join(", ")})`,
184
+ );
185
+ }
186
+ return { mode, source: "model-override", modelId, matchedRule: match.pattern };
187
+ }
188
+ } else {
189
+ const legacyVariant = settings.getEditVariantForModel?.(modelId);
190
+ if (legacyVariant) return { mode: legacyVariant, source: "model-override", modelId };
191
+ }
36
192
 
37
- const envMode = normalizeEditMode($pickenv("GJC_EDIT_VARIANT", "PI_EDIT_VARIANT"));
38
- if (envMode) return envMode;
193
+ const rawSetting = settings.get("edit.mode");
194
+ const settingValue = typeof rawSetting === "string" ? rawSetting : undefined;
195
+ if (settingValue && settingValue !== "auto") {
196
+ const settingsMode = normalizeEditMode(settingValue);
197
+ if (settingsMode) return { mode: settingsMode, source: "setting", modelId };
198
+ }
39
199
 
40
- const settingsMode = normalizeEditMode(String(session.settings.get("edit.mode") ?? ""));
41
- return settingsMode ?? DEFAULT_EDIT_MODE;
200
+ const family = detectModelEditFamily(modelId);
201
+
202
+ const catalogMode = session.getCatalogEditMode?.();
203
+ if (catalogMode) return { mode: catalogMode, source: "catalog", modelId, family };
204
+
205
+ const builtinMode = builtinEditModeForFamily(family);
206
+ if (builtinMode) return { mode: builtinMode, source: "builtin-family", modelId, family };
207
+
208
+ return { mode: DEFAULT_EDIT_MODE, source: "fallback", modelId, family };
209
+ }
210
+
211
+ export function resolveEditMode(session: EditModeSessionLike): EditMode {
212
+ return resolveEditModeDetails(session).mode;
42
213
  }
@@ -25,6 +25,7 @@ const DEFAULT_NUM_RESULTS = 10;
25
25
  const MAX_NUM_RESULTS = 20;
26
26
  const PUBLIC_ROUTE_TIMEOUT_MS = 15_000;
27
27
  const DISCOVERY_LIMIT = 8;
28
+ const MAX_PUBLIC_ROUTE_RESPONSE_BYTES = 1024 * 1024;
28
29
 
29
30
  const USER_AGENT = "Gajae-Code insane-search safe-public-routes/1.0 (+https://github.com/Yeachan-Heo/gajae-code)";
30
31
 
@@ -146,6 +147,59 @@ function attempt(
146
147
  return { platform, route, ok, status, bytes: new TextEncoder().encode(body).byteLength, note };
147
148
  }
148
149
 
150
+ class ResponseTooLargeError extends Error {
151
+ override readonly name = "response_too_large";
152
+ }
153
+
154
+ async function cancelBody(body: ReadableStream<Uint8Array> | null): Promise<void> {
155
+ if (!body) return;
156
+ try {
157
+ await body.cancel();
158
+ } catch {
159
+ // Best-effort teardown; preserve the stable size-limit diagnostic.
160
+ }
161
+ }
162
+
163
+ async function readResponseText(response: Response): Promise<string> {
164
+ const contentLength = response.headers.get("content-length");
165
+ if (contentLength !== null) {
166
+ const declaredBytes = Number.parseInt(contentLength, 10);
167
+ if (Number.isFinite(declaredBytes) && declaredBytes > MAX_PUBLIC_ROUTE_RESPONSE_BYTES) {
168
+ await cancelBody(response.body);
169
+ throw new ResponseTooLargeError();
170
+ }
171
+ }
172
+
173
+ const reader = response.body?.getReader();
174
+ if (!reader) return "";
175
+
176
+ const decoder = new TextDecoder();
177
+ let totalBytes = 0;
178
+ let text = "";
179
+ try {
180
+ while (true) {
181
+ const { done, value } = await reader.read();
182
+ if (done) break;
183
+ if (!value || value.byteLength === 0) continue;
184
+
185
+ totalBytes += value.byteLength;
186
+ if (totalBytes > MAX_PUBLIC_ROUTE_RESPONSE_BYTES) {
187
+ try {
188
+ await reader.cancel();
189
+ } catch {
190
+ // Best-effort teardown; preserve the stable size-limit diagnostic.
191
+ }
192
+ throw new ResponseTooLargeError();
193
+ }
194
+ text += decoder.decode(value, { stream: true });
195
+ }
196
+ text += decoder.decode();
197
+ return text;
198
+ } finally {
199
+ reader.releaseLock();
200
+ }
201
+ }
202
+
149
203
  async function fetchText(
150
204
  url: string,
151
205
  signal?: AbortSignal,
@@ -158,7 +212,7 @@ async function fetchText(
158
212
  redirect: "follow",
159
213
  signal: withHardTimeout(signal, PUBLIC_ROUTE_TIMEOUT_MS),
160
214
  });
161
- const text = await response.text();
215
+ const text = await readResponseText(response);
162
216
  return { status: response.status, text, contentType: response.headers.get("content-type") ?? "" };
163
217
  }
164
218