@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
@@ -75,8 +75,10 @@ export function defaultMessageQueueKeysForPlatform(platform: NodeJS.Platform = p
75
75
  return platform === "win32" || platform === "darwin" ? "alt+q" : "alt+enter";
76
76
  }
77
77
 
78
- export function defaultClipboardPasteImageKeysForPlatform(platform: NodeJS.Platform = process.platform): KeyId {
79
- return platform === "win32" ? "alt+v" : "ctrl+v";
78
+ export function defaultClipboardPasteImageKeysForPlatform(platform: NodeJS.Platform = process.platform): KeyId[] {
79
+ if (platform === "darwin") return ["ctrl+v", "super+v"];
80
+ if (platform === "win32") return ["ctrl+v", "alt+v"];
81
+ return ["ctrl+v"];
80
82
  }
81
83
 
82
84
  /**
@@ -1223,6 +1223,7 @@ interface ModelManagerDiscoveryOptions {
1223
1223
  */
1224
1224
  export class ModelRegistry {
1225
1225
  #models: Model<Api>[] = [];
1226
+ #catalogChangeListeners: Set<() => void> = new Set();
1226
1227
  #canonicalIndex: CanonicalModelIndex = {
1227
1228
  records: [],
1228
1229
  byId: new Map(),
@@ -1301,6 +1302,13 @@ export class ModelRegistry {
1301
1302
  this.#loadModels();
1302
1303
  }
1303
1304
 
1305
+ onCatalogChanged(listener: () => void): () => void {
1306
+ this.#catalogChangeListeners.add(listener);
1307
+ return () => {
1308
+ this.#catalogChangeListeners.delete(listener);
1309
+ };
1310
+ }
1311
+
1304
1312
  /**
1305
1313
  * Reload models from disk (built-in + custom from models.json).
1306
1314
  */
@@ -3250,6 +3258,7 @@ export class ModelRegistry {
3250
3258
  }
3251
3259
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
3252
3260
  this.#invalidateAvailableModels();
3261
+ this.#notifyCatalogChanged();
3253
3262
  this.#rebuildPending = false;
3254
3263
  }
3255
3264
 
@@ -3259,6 +3268,16 @@ export class ModelRegistry {
3259
3268
  this.#availableModelsEnvFingerprint = undefined;
3260
3269
  }
3261
3270
 
3271
+ #notifyCatalogChanged(): void {
3272
+ for (const listener of [...this.#catalogChangeListeners]) {
3273
+ try {
3274
+ listener();
3275
+ } catch (error) {
3276
+ logger.debug("ModelRegistry catalog listener failed", { error: String(error) });
3277
+ }
3278
+ }
3279
+ }
3280
+
3262
3281
  #suspendRebuild(): void {
3263
3282
  this.#rebuildSuspended += 1;
3264
3283
  }
@@ -3271,6 +3290,7 @@ export class ModelRegistry {
3271
3290
  this.#rebuildPending = false;
3272
3291
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
3273
3292
  this.#invalidateAvailableModels();
3293
+ this.#notifyCatalogChanged();
3274
3294
  }
3275
3295
  }
3276
3296
 
@@ -25,6 +25,7 @@ export const ModelCompatSchema = z.object({
25
25
  supportsDeveloperRole: z.boolean().optional(),
26
26
  sendSessionHeaders: z.boolean().optional(),
27
27
  supportsResponsesSessionAffinity: z.boolean().optional(),
28
+ supportsServiceTier: z.boolean().optional(),
28
29
  supportsMultipleSystemMessages: z.boolean().optional(),
29
30
  supportsReasoningEffort: z.boolean().optional(),
30
31
  reasoningEffortMap: ReasoningEffortMapSchema.optional(),
@@ -2,7 +2,7 @@ import type { Effort } from "@gajae-code/ai/model-thinking";
2
2
  import { PET_MODE_IDS, PET_SKIN_IDS, PET_SKINS } from "@gajae-code/tui/components/gajae-pet";
3
3
  import { TASK_SIMPLE_MODES } from "../task/simple-mode";
4
4
  import { getThinkingLevelMetadata } from "../thinking-metadata";
5
- import { EDIT_MODES } from "../utils/edit-mode";
5
+ import { DEFAULT_EDIT_MODE_SETTING, EDIT_MODE_SETTINGS, EDIT_MODES, type EditMode } from "../utils/edit-mode";
6
6
  import { CONFIGURABLE_SEARCH_PROVIDER_IDS } from "../web/search/types";
7
7
  import type { ModelSelectorValue } from "./model-selector-value";
8
8
  import { UPDATE_CHANNELS } from "./update-channel";
@@ -181,9 +181,7 @@ interface ArrayDef<T> {
181
181
  ui?: UiBase;
182
182
  }
183
183
 
184
- interface RecordValueDef {
185
- type: "model-selector-value";
186
- }
184
+ type RecordValueDef = { type: "model-selector-value" } | { type: "string-enum"; values: readonly string[] };
187
185
 
188
186
  interface RecordDef<T> {
189
187
  type: "record";
@@ -218,6 +216,7 @@ export interface ModelTagsSettings {
218
216
  const EMPTY_STRING_ARRAY: string[] = [];
219
217
  const EMPTY_MODEL_SELECTOR_RECORD: Record<string, ModelSelectorValue> = {};
220
218
  const MODEL_SELECTOR_VALUE_SCHEMA = { type: "model-selector-value" } as const;
219
+ const EDIT_MODE_VALUE_SCHEMA = { type: "string-enum", values: EDIT_MODES } as const;
221
220
  const DEFAULT_CYCLE_ORDER: string[] = ["default"];
222
221
  const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
223
222
  const HINDSIGHT_RECALL_TYPES_DEFAULT: string[] = ["world", "experience"];
@@ -2141,14 +2140,20 @@ export const SETTINGS_SCHEMA = {
2141
2140
  // Edit tool
2142
2141
  "edit.mode": {
2143
2142
  type: "enum",
2144
- values: EDIT_MODES,
2145
- default: "hashline",
2143
+ values: EDIT_MODE_SETTINGS,
2144
+ default: DEFAULT_EDIT_MODE_SETTING,
2146
2145
  ui: {
2147
2146
  tab: "editing",
2148
2147
  label: "Edit Mode",
2149
- description: "Select the edit tool variant (replace, patch, hashline, vim, or apply_patch)",
2148
+ description:
2149
+ "Select the edit tool variant (auto routes by model family; replace, patch, hashline, vim, or apply_patch)",
2150
2150
  },
2151
2151
  },
2152
+ "edit.modelVariants": {
2153
+ type: "record",
2154
+ default: {} as Record<string, EditMode>,
2155
+ valueSchema: EDIT_MODE_VALUE_SCHEMA,
2156
+ },
2152
2157
 
2153
2158
  "edit.fuzzyMatch": {
2154
2159
  type: "boolean",
@@ -3857,6 +3862,13 @@ function validArraySettingValue(value: unknown, allowedValues: readonly string[]
3857
3862
  return !allowedValues || value.every(item => typeof item === "string" && allowedValues.includes(item));
3858
3863
  }
3859
3864
 
3865
+ function validRecordValue(valueSchema: RecordValueDef, value: unknown): boolean {
3866
+ if (valueSchema.type === "model-selector-value") {
3867
+ return typeof value === "string" || (Array.isArray(value) && value.every(item => typeof item === "string"));
3868
+ }
3869
+ return typeof value === "string" && valueSchema.values.includes(value);
3870
+ }
3871
+
3860
3872
  function validSettingValue(definition: (typeof SETTINGS_SCHEMA)[SettingPath], value: unknown): boolean {
3861
3873
  return (
3862
3874
  (definition.type === "boolean" && typeof value === "boolean") ||
@@ -3897,11 +3909,8 @@ export function validateSettingPatch(patch: Record<string, unknown>): Array<{ pa
3897
3909
  issues.push({ path, detail: "Setting is not a valid record sub-path." });
3898
3910
  continue;
3899
3911
  }
3900
- if (
3901
- parentDef.valueSchema.type === "model-selector-value" &&
3902
- !(typeof value === "string" || (Array.isArray(value) && value.every(item => typeof item === "string")))
3903
- ) {
3904
- issues.push({ path, detail: "Expected model-selector-value." });
3912
+ if (!validRecordValue(parentDef.valueSchema, value)) {
3913
+ issues.push({ path, detail: `Expected ${parentDef.valueSchema.type}.` });
3905
3914
  }
3906
3915
  continue;
3907
3916
  }
@@ -3923,11 +3932,8 @@ export function validateSettingPatch(patch: Record<string, unknown>): Array<{ pa
3923
3932
  }
3924
3933
  if (definition.type === "record" && "valueSchema" in definition && definition.valueSchema) {
3925
3934
  for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
3926
- if (
3927
- definition.valueSchema.type === "model-selector-value" &&
3928
- !(typeof entry === "string" || (Array.isArray(entry) && entry.every(item => typeof item === "string")))
3929
- ) {
3930
- issues.push({ path: `${path}.${key}`, detail: "Expected model-selector-value." });
3935
+ if (!validRecordValue(definition.valueSchema, entry)) {
3936
+ issues.push({ path: `${path}.${key}`, detail: `Expected ${definition.valueSchema.type}.` });
3931
3937
  }
3932
3938
  }
3933
3939
  }
@@ -3984,11 +3990,12 @@ export function reconcileSettingsSchema(raw: Record<string, unknown>): {
3984
3990
  validSettingValue(definition, next)
3985
3991
  ) {
3986
3992
  for (const [key, entry] of Object.entries(next as Record<string, unknown>)) {
3987
- if (
3988
- definition.valueSchema.type === "model-selector-value" &&
3989
- !(typeof entry === "string" || (Array.isArray(entry) && entry.every(item => typeof item === "string")))
3990
- ) {
3991
- issues.push({ path: `${path}.${key}`, kind: "invalid", detail: "Expected model-selector-value." });
3993
+ if (!validRecordValue(definition.valueSchema, entry)) {
3994
+ issues.push({
3995
+ path: `${path}.${key}`,
3996
+ kind: "invalid",
3997
+ detail: `Expected ${definition.valueSchema.type}.`,
3998
+ });
3992
3999
  }
3993
4000
  }
3994
4001
  }
@@ -37,7 +37,7 @@ import {
37
37
  parseNotificationSettingsSnapshot,
38
38
  } from "../sdk/bus/config";
39
39
  import { AgentStorage } from "../session/agent-storage";
40
- import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
40
+ import { type EditMode, type EditVariantMatch, normalizeEditMode } from "../utils/edit-mode";
41
41
  import {
42
42
  type AtomicYamlPatch,
43
43
  applyAtomicYamlPatches,
@@ -77,6 +77,8 @@ export interface RawSettings {
77
77
  [key: string]: unknown;
78
78
  }
79
79
 
80
+ const UNSAFE_EDIT_VARIANT_PATTERNS = new Set(["__proto__", "constructor", "prototype"]);
81
+
80
82
  type SettingsPatch = {
81
83
  readonly path: string;
82
84
  readonly value: unknown | undefined;
@@ -964,14 +966,17 @@ export class Settings implements NotificationSettingsReader {
964
966
  /**
965
967
  * Get the edit variant for a specific model.
966
968
  * Returns "patch", "replace", "hashline", "vim", "apply_patch", or null (use global default).
969
+ * Skips invalid values; prefer `matchEditVariantForModel` when invalid
970
+ * matches must fail closed instead of falling through.
967
971
  */
968
972
  getEditVariantForModel(model: string | undefined): EditMode | null {
969
973
  if (!model) return null;
970
- const variants = (this.#merged.edit as { modelVariants?: Record<string, string> })?.modelVariants;
974
+ const variants = this.#editModelVariants();
971
975
  if (!variants) return null;
972
- for (const pattern in variants) {
976
+ for (const [pattern, rawValue] of Object.entries(variants)) {
977
+ if (UNSAFE_EDIT_VARIANT_PATTERNS.has(pattern)) continue;
973
978
  if (model.includes(pattern)) {
974
- const value = normalizeEditMode(variants[pattern]);
979
+ const value = normalizeEditMode(rawValue);
975
980
  if (value) {
976
981
  return value;
977
982
  }
@@ -980,6 +985,35 @@ export class Settings implements NotificationSettingsReader {
980
985
  return null;
981
986
  }
982
987
 
988
+ /**
989
+ * First matching `edit.modelVariants` rule for a model, with its raw
990
+ * (unvalidated) value. The edit-mode resolver uses this discriminated
991
+ * result so a matched-but-invalid value fails closed with a diagnostic
992
+ * rather than silently resolving to another mode.
993
+ */
994
+ matchEditVariantForModel(model: string | undefined): EditVariantMatch | null {
995
+ if (!model) return null;
996
+ const variants = this.#editModelVariants();
997
+ if (!variants) return null;
998
+ for (const [pattern, value] of Object.entries(variants)) {
999
+ if (UNSAFE_EDIT_VARIANT_PATTERNS.has(pattern)) continue;
1000
+ if (model.includes(pattern)) {
1001
+ return { pattern, value: String(value) };
1002
+ }
1003
+ }
1004
+ return null;
1005
+ }
1006
+
1007
+ #editModelVariants(): Record<string, string> | undefined {
1008
+ const variants = (this.#merged.edit as { modelVariants?: unknown })?.modelVariants;
1009
+ return typeof variants === "object" &&
1010
+ variants !== null &&
1011
+ !Array.isArray(variants) &&
1012
+ (Object.getPrototypeOf(variants) === Object.prototype || Object.getPrototypeOf(variants) === null)
1013
+ ? (variants as Record<string, string>)
1014
+ : undefined;
1015
+ }
1016
+
983
1017
  /**
984
1018
  * Get bash interceptor rules (typed accessor for complex array config).
985
1019
  */
@@ -2075,18 +2109,16 @@ export class Settings implements NotificationSettingsReader {
2075
2109
 
2076
2110
  if (override === undefined) continue;
2077
2111
 
2078
- if (
2112
+ const value =
2079
2113
  typeof override === "object" &&
2080
2114
  override !== null &&
2081
2115
  !Array.isArray(override) &&
2082
2116
  typeof baseVal === "object" &&
2083
2117
  baseVal !== null &&
2084
2118
  !Array.isArray(baseVal)
2085
- ) {
2086
- result[key] = this.#deepMerge(baseVal as RawSettings, override as RawSettings);
2087
- } else {
2088
- result[key] = override;
2089
- }
2119
+ ? this.#deepMerge(baseVal as RawSettings, override as RawSettings)
2120
+ : override;
2121
+ Object.defineProperty(result, key, { configurable: true, enumerable: true, value, writable: true });
2090
2122
  }
2091
2123
  return result;
2092
2124
  }
@@ -5,20 +5,20 @@ import * as path from "node:path";
5
5
  import { getAgentDir, isKnownSinkPeerClosedError } from "@gajae-code/utils";
6
6
  import { normalizePathForComparison, VERSION } from "@gajae-code/utils/dirs";
7
7
 
8
+ import { withFileLock } from "../config/file-lock";
8
9
  import {
9
10
  COORDINATOR_MCP_PROTOCOL_VERSION,
10
11
  COORDINATOR_MCP_SERVER_NAME,
11
12
  COORDINATOR_MCP_TOOL_NAMES,
12
13
  type CoordinatorToolName,
13
14
  } from "../coordinator/contract";
14
- import { readLinuxProcStartTimeSync } from "../gjc-runtime/linux-proc";
15
15
  import { listMcpDelegateHostContexts } from "../hooks/mcp-delegate-host-context";
16
16
  import type { WorkflowGate, WorkflowGateQueryRecord } from "../modes/shared/agent-wire/workflow-gate-types";
17
17
  import type { BrokerDiscovery } from "../sdk/broker/discovery";
18
18
  import { type EnsureBrokerSettings, ensureBroker } from "../sdk/broker/ensure";
19
19
  import { lifecycleRequestTimeoutMs } from "../sdk/broker/startup-budget";
20
20
  import { UnsupportedStateVersionError } from "../sdk/broker/state-version";
21
- import { SdkClient, SdkClientError } from "../sdk/client/client";
21
+ import { SdkClient, SdkClientError, type SdkSentRecord } from "../sdk/client/client";
22
22
  import { readSdkBrokerDiscovery } from "../sdk/client/discovery";
23
23
  import {
24
24
  type ActivatedPreparedSession,
@@ -111,6 +111,16 @@ interface JsonRpcResponse {
111
111
  error?: { code: number; message: string; data?: unknown };
112
112
  }
113
113
 
114
+ function isLifecycleOperation(operation: string): boolean {
115
+ return (
116
+ operation === "session.create" ||
117
+ operation === "session.fork" ||
118
+ operation === "session.resume" ||
119
+ operation === "session.close" ||
120
+ operation === "session.delete"
121
+ );
122
+ }
123
+
114
124
  function sinkErrorCode(error: unknown): string | undefined {
115
125
  if (error === null || (typeof error !== "object" && typeof error !== "function")) return undefined;
116
126
  try {
@@ -270,6 +280,10 @@ interface CoordinatorSessionState {
270
280
  source: "coordinator" | "agent_session_event";
271
281
  live: boolean | null;
272
282
  reason: string | null;
283
+ last_activity_at?: string;
284
+ activity?: unknown;
285
+ active_tools?: unknown;
286
+ active_tool_calls?: unknown;
273
287
  }
274
288
 
275
289
  type CoordinatorEventKind =
@@ -1227,7 +1241,7 @@ function publicLifecycleReceipt(result: Record<string, unknown>, sessionId: stri
1227
1241
 
1228
1242
  function publicCoordinatorSessionState(state: CoordinatorSessionState | null): Record<string, unknown> | null {
1229
1243
  if (!state) return null;
1230
- return {
1244
+ const result: Record<string, unknown> = {
1231
1245
  session_id: state.session_id,
1232
1246
  state: state.state,
1233
1247
  ready_for_input: state.ready_for_input,
@@ -1236,6 +1250,55 @@ function publicCoordinatorSessionState(state: CoordinatorSessionState | null): R
1236
1250
  updated_at: state.updated_at,
1237
1251
  ...(typeof state.live === "boolean" ? { live: state.live } : {}),
1238
1252
  };
1253
+ const activity = asRecord(state.activity);
1254
+ if (
1255
+ typeof state.last_activity_at === "string" &&
1256
+ Number.isFinite(Date.parse(state.last_activity_at)) &&
1257
+ activity &&
1258
+ typeof activity.sequence === "number" &&
1259
+ Number.isSafeInteger(activity.sequence) &&
1260
+ activity.sequence > 0 &&
1261
+ activity.kind === "tool_execution" &&
1262
+ (activity.phase === "started" || activity.phase === "finished") &&
1263
+ typeof activity.tool_name === "string" &&
1264
+ /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(activity.tool_name) &&
1265
+ typeof activity.observed_at === "string" &&
1266
+ Number.isFinite(Date.parse(activity.observed_at)) &&
1267
+ typeof activity.active_tool_count === "number" &&
1268
+ Number.isSafeInteger(activity.active_tool_count) &&
1269
+ activity.active_tool_count >= 0
1270
+ ) {
1271
+ result.last_activity_at = state.last_activity_at;
1272
+ result.activity = {
1273
+ sequence: activity.sequence,
1274
+ kind: activity.kind,
1275
+ phase: activity.phase,
1276
+ tool_name: activity.tool_name,
1277
+ observed_at: activity.observed_at,
1278
+ ...(typeof activity.started_at === "string" && Number.isFinite(Date.parse(activity.started_at))
1279
+ ? { started_at: activity.started_at }
1280
+ : {}),
1281
+ ...(typeof activity.elapsed_ms === "number" &&
1282
+ Number.isSafeInteger(activity.elapsed_ms) &&
1283
+ activity.elapsed_ms >= 0
1284
+ ? { elapsed_ms: activity.elapsed_ms }
1285
+ : {}),
1286
+ ...(activity.outcome === "succeeded" || activity.outcome === "failed" ? { outcome: activity.outcome } : {}),
1287
+ active_tool_count: activity.active_tool_count,
1288
+ };
1289
+ if (Array.isArray(state.active_tools))
1290
+ result.active_tools = state.active_tools.slice(0, 8).flatMap(value => {
1291
+ const tool = asRecord(value);
1292
+ return tool &&
1293
+ typeof tool.tool_name === "string" &&
1294
+ /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.tool_name) &&
1295
+ typeof tool.started_at === "string" &&
1296
+ Number.isFinite(Date.parse(tool.started_at))
1297
+ ? [{ tool_name: tool.tool_name, started_at: tool.started_at }]
1298
+ : [];
1299
+ });
1300
+ }
1301
+ return result;
1239
1302
  }
1240
1303
 
1241
1304
  function eventTimestamp(record: Record<string, unknown>): string | null {
@@ -1828,7 +1891,8 @@ async function writeSessionStateUnlocked(
1828
1891
  overwrite?: boolean;
1829
1892
  } = {},
1830
1893
  ): Promise<CoordinatorSessionState> {
1831
- const previous = options.overwrite ? null : await readSessionState(namespaceDir, sessionId);
1894
+ const existing = await readSessionState(namespaceDir, sessionId);
1895
+ const previous = options.overwrite ? null : existing;
1832
1896
  const hasCurrentTurn = Object.hasOwn(options, "currentTurnId");
1833
1897
  const hasLastTurn = Object.hasOwn(options, "lastTurnId");
1834
1898
  const hasLive = Object.hasOwn(options, "live");
@@ -1847,6 +1911,12 @@ async function writeSessionStateUnlocked(
1847
1911
  source: options.source ?? "coordinator",
1848
1912
  live: hasLive ? (options.live ?? null) : (previous?.live ?? null),
1849
1913
  reason: options.reason ?? null,
1914
+ ...(typeof existing?.last_activity_at === "string" ? { last_activity_at: existing.last_activity_at } : {}),
1915
+ ...(existing?.activity && typeof existing.activity === "object" ? { activity: existing.activity } : {}),
1916
+ ...(Array.isArray(existing?.active_tools) ? { active_tools: existing.active_tools } : {}),
1917
+ ...(existing?.active_tool_calls && typeof existing.active_tool_calls === "object"
1918
+ ? { active_tool_calls: existing.active_tool_calls }
1919
+ : {}),
1850
1920
  };
1851
1921
  await writeJsonFile(sessionStateFile(namespaceDir, sessionId), payload);
1852
1922
  if (
@@ -1874,108 +1944,8 @@ async function writeSessionStateUnlocked(
1874
1944
  return payload;
1875
1945
  }
1876
1946
 
1877
- interface SessionStateLockOwner {
1878
- pid: number;
1879
- start_time: string;
1880
- token: string;
1881
- }
1882
-
1883
- function processStartTime(pid: number): string | null {
1884
- return readLinuxProcStartTimeSync(pid);
1885
- }
1886
-
1887
- function validLockOwner(value: unknown): value is SessionStateLockOwner {
1888
- if (!value || typeof value !== "object") return false;
1889
- const owner = value as Partial<SessionStateLockOwner>;
1890
- return (
1891
- typeof owner.pid === "number" &&
1892
- Number.isSafeInteger(owner.pid) &&
1893
- owner.pid > 0 &&
1894
- typeof owner.start_time === "string" &&
1895
- typeof owner.token === "string" &&
1896
- owner.token.length > 0
1897
- );
1898
- }
1899
-
1900
- function lockOwnerIsAlive(value: unknown): boolean {
1901
- if (!validLockOwner(value)) return false;
1902
- const owner = value;
1903
- try {
1904
- process.kill(owner.pid, 0);
1905
- } catch (error) {
1906
- if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
1907
- return true;
1908
- }
1909
- const currentStartTime = processStartTime(owner.pid);
1910
- return currentStartTime === null || currentStartTime === owner.start_time;
1911
- }
1912
-
1913
- async function reclaimStaleSessionStateLock(lockFile: string): Promise<void> {
1914
- let raw: string;
1915
- try {
1916
- raw = await fs.readFile(lockFile, "utf8");
1917
- } catch (error) {
1918
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1919
- throw error;
1920
- }
1921
- let owner: unknown;
1922
- try {
1923
- owner = JSON.parse(raw);
1924
- } catch {
1925
- owner = null;
1926
- }
1927
- if (!validLockOwner(owner)) {
1928
- const stat = await fs.stat(lockFile);
1929
- if (Date.now() - stat.mtimeMs < 30_000) return;
1930
- } else if (lockOwnerIsAlive(owner)) return;
1931
- try {
1932
- if ((await fs.readFile(lockFile, "utf8")) === raw) await fs.rm(lockFile);
1933
- } catch (error) {
1934
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1935
- }
1936
- }
1937
-
1938
1947
  async function withSessionStateLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1939
- const lockFile = `${stateFile}.lock`;
1940
- const owner: SessionStateLockOwner = {
1941
- pid: process.pid,
1942
- start_time: processStartTime(process.pid) ?? "unknown",
1943
- token: randomUUID(),
1944
- };
1945
- await ensureDir(path.dirname(stateFile));
1946
- for (let attempt = 0; attempt < 12_000; attempt++) {
1947
- let handle: fs.FileHandle | undefined;
1948
- try {
1949
- handle = await fs.open(lockFile, "wx");
1950
- try {
1951
- await handle.writeFile(JSON.stringify(owner));
1952
- } catch (error) {
1953
- await handle.close().catch(() => undefined);
1954
- handle = undefined;
1955
- await fs.rm(lockFile, { force: true }).catch(() => undefined);
1956
- throw error;
1957
- }
1958
- const outcome = await operation().then(
1959
- value => ({ ok: true as const, value }),
1960
- error => ({ ok: false as const, error }),
1961
- );
1962
- await handle.close();
1963
- try {
1964
- if ((await fs.readFile(lockFile, "utf8")) === JSON.stringify(owner)) await fs.rm(lockFile);
1965
- } catch (error) {
1966
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1967
- }
1968
- if (!outcome.ok) throw outcome.error;
1969
- return outcome.value;
1970
- } catch (error) {
1971
- if (handle) throw error;
1972
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new Error("coordinator_state_unreadable");
1973
-
1974
- await reclaimStaleSessionStateLock(lockFile);
1975
- await Bun.sleep(5);
1976
- }
1977
- }
1978
- throw new Error("coordinator_state_unreadable");
1948
+ return await withFileLock(stateFile, operation, { staleMs: 30_000, retries: 12_000, retryDelayMs: 5 });
1979
1949
  }
1980
1950
 
1981
1951
  async function writeSessionState(
@@ -3191,7 +3161,15 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
3191
3161
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
3192
3162
  });
3193
3163
  } catch (error) {
3194
- requestError = toCoordinatorBrokerError("request", error);
3164
+ const mapped = toCoordinatorBrokerError("request", error);
3165
+ if (
3166
+ isLifecycleOperation(operation) &&
3167
+ mapped.code === "uncertain_after_send" &&
3168
+ mapped.details &&
3169
+ typeof mapped.details === "object"
3170
+ )
3171
+ result = await client.lookupLifecycle(mapped.details as SdkSentRecord);
3172
+ else requestError = mapped;
3195
3173
  }
3196
3174
  try {
3197
3175
  await client.close();
package/src/edit/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@gajae-code/agent-core";
2
- import { $pickenv, prompt } from "@gajae-code/utils";
2
+ import { prompt } from "@gajae-code/utils";
3
3
  import type * as z from "zod/v4";
4
4
  import {
5
5
  executeHashlineSingle,
@@ -22,7 +22,7 @@ import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
22
22
  import replaceDescription from "../prompts/tools/replace.md" with { type: "text" };
23
23
  import type { ToolSession } from "../tools";
24
24
  import { VimTool, vimSchema } from "../tools/vim";
25
- import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode";
25
+ import { type EditMode, resolveEditMode, resolveForcedEnvEditMode } from "../utils/edit-mode";
26
26
  import type { VimToolDetails } from "../vim/types";
27
27
  import { type ApplyPatchParams, applyPatchSchema, expandApplyPatchToEntries } from "./modes/apply-patch";
28
28
  import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
@@ -132,19 +132,6 @@ type EditModeDefinition = {
132
132
  ) => Promise<AgentToolResult<EditToolResultDetails, TInput>>;
133
133
  };
134
134
 
135
- function resolveConfiguredEditMode(rawEditMode: string): EditMode | undefined {
136
- if (!rawEditMode || rawEditMode === "auto") {
137
- return undefined;
138
- }
139
-
140
- const editMode = normalizeEditMode(rawEditMode);
141
- if (!editMode) {
142
- throw new Error(`Invalid PI_EDIT_VARIANT: ${rawEditMode}`);
143
- }
144
-
145
- return editMode;
146
- }
147
-
148
135
  function resolveAllowFuzzy(session: ToolSession, rawValue: string): boolean {
149
136
  switch (rawValue) {
150
137
  case "true":
@@ -347,15 +334,15 @@ export class EditTool implements AgentTool<TInput> {
347
334
  readonly #allowFuzzy: boolean;
348
335
  readonly #fuzzyThreshold: number;
349
336
  readonly #writethrough: WritethroughCallback;
350
- readonly #editMode?: EditMode;
351
337
  readonly #vimTool: VimTool;
352
338
  readonly #pendingDeferredFetches = new Map<string, AbortController>();
353
339
 
354
340
  constructor(private readonly session: ToolSession) {
355
341
  const { PI_EDIT_FUZZY: editFuzzy = "auto", PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto" } = Bun.env;
356
- const envEditVariant = $pickenv("GJC_EDIT_VARIANT", "PI_EDIT_VARIANT") ?? "auto";
357
342
 
358
- this.#editMode = resolveConfiguredEditMode(envEditVariant);
343
+ // Fail fast on an invalid forced env variant; the shared resolver owns
344
+ // env precedence at resolution time (no separate cache here).
345
+ resolveForcedEnvEditMode();
359
346
  this.#allowFuzzy = resolveAllowFuzzy(session, editFuzzy);
360
347
  this.#fuzzyThreshold = resolveFuzzyThreshold(session, editFuzzyThreshold);
361
348
  this.#writethrough = createEditWritethrough(session);
@@ -363,7 +350,6 @@ export class EditTool implements AgentTool<TInput> {
363
350
  }
364
351
 
365
352
  get mode(): EditMode {
366
- if (this.#editMode) return this.#editMode;
367
353
  return resolveEditMode(this.session);
368
354
  }
369
355
 
@@ -356,8 +356,8 @@ export const editToolRenderer = {
356
356
  });
357
357
  const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
358
358
  const rawPath = inventory.paths[0] ?? "";
359
- const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move;
360
- const op = editArgs.op || firstEdit?.op || inventory.firstOp;
359
+ const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || inventory.rename;
360
+ const op = editArgs.op || firstEdit?.op || inventory.op;
361
361
  const { description } = formatEditDescription(rawPath, uiTheme, options.expanded ? { rename } : undefined);
362
362
  const spinner =
363
363
  options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
@@ -416,6 +416,7 @@ function resolveCompletedEditIdentity(
416
416
  const firstEdit = args?.edits?.[0];
417
417
  const inventory = getEditRequestTargetInventory(args, editMode, { isPartial });
418
418
  const detailsPath = details && "path" in details ? details.path : undefined;
419
+
419
420
  return {
420
421
  path: detailsPath ?? args?.file_path ?? args?.path ?? firstEdit?.path ?? inventory.paths[0] ?? "",
421
422
  op: details?.op ?? args?.op ?? firstEdit?.op,