@gajae-code/coding-agent 0.15.4 → 0.15.6

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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +27 -5
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +99 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -8385,13 +8385,13 @@ export class SessionManager {
8385
8385
  this.#usageStatistics = commit.usageStatistics;
8386
8386
  this.#flushed = true;
8387
8387
  this.#ensuredOnDisk = true;
8388
- this.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(terminalDescriptor);
8388
+ this.#managedPersistExpectedIdentity =
8389
+ this.destination.kind === "managed" ? this.#captureManagedPersistIdentity(sessionFile) : undefined;
8389
8390
  this.#lazyReopenSucceeded = true;
8390
8391
  this.#lazyReopenFallbackReason = undefined;
8391
8392
  initialized = true;
8392
8393
  return true;
8393
- } catch (error) {
8394
- SessionManagerTestHooks.lastSidecarInitError = toError(error).message;
8394
+ } catch {
8395
8395
  return false;
8396
8396
  } finally {
8397
8397
  if (!initialized) {
@@ -8572,7 +8572,8 @@ export class SessionManager {
8572
8572
  this.#lazyReopenFallbackReason = "bounded_first_open_descriptor_changed";
8573
8573
  return false;
8574
8574
  }
8575
- this.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(finalDescriptor);
8575
+ this.#managedPersistExpectedIdentity =
8576
+ this.destination.kind === "managed" ? this.#captureManagedPersistIdentity(sessionFile) : undefined;
8576
8577
  this.#sessionId = discovery.header.id;
8577
8578
  this.#sessionName = discovery.header.title;
8578
8579
  this.#titleSource = discovery.header.titleSource;
@@ -14945,9 +14946,27 @@ export class SessionManager {
14945
14946
  this.#managedPersistExpectedIdentity = undefined;
14946
14947
  return;
14947
14948
  }
14948
- const descriptor = this.#managedTranscriptStore(sessionFile).descriptorExpected(path.basename(sessionFile));
14949
- if (!descriptor) throw new Error("managed_persist_identity_unavailable");
14950
- this.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(descriptor);
14949
+ this.#managedPersistExpectedIdentity = this.#captureManagedPersistIdentity(sessionFile);
14950
+ }
14951
+
14952
+ /** Capture one descriptor-bound digest for a future metadata-drift comparison. */
14953
+ #captureManagedPersistIdentity(sessionFile: string): ManagedFileIdentity {
14954
+ const store = this.#managedTranscriptStore(sessionFile);
14955
+ const relativePath = path.basename(sessionFile);
14956
+ const bounded = store.captureBoundedAppendExpectation(relativePath);
14957
+ const descriptor = store.descriptorExpected(relativePath);
14958
+ if (!bounded || !descriptor) throw new Error("managed_persist_identity_unavailable");
14959
+ const identity = managedIdentityFromDescriptor(descriptor);
14960
+ if (
14961
+ bounded.dev !== identity.dev.toString() ||
14962
+ bounded.ino !== identity.ino.toString() ||
14963
+ bounded.nlink !== identity.nlink.toString() ||
14964
+ bounded.size !== String(identity.size) ||
14965
+ bounded.mtimeNs !== identity.mtimeNs.toString() ||
14966
+ bounded.ctimeNs !== identity.ctimeNs.toString()
14967
+ )
14968
+ throw new Error("managed_persist_identity_unavailable");
14969
+ return { ...identity, sha256: bounded.sha256 };
14951
14970
  }
14952
14971
  /** Current transcript descriptor, or null when unavailable. */
14953
14972
  #managedDescriptorSnapshotOrNull(): DescriptorSnapshot | null {
@@ -14997,7 +15016,7 @@ export class SessionManager {
14997
15016
  } else store.replaceSync(relativePath, bytes);
14998
15017
  const descriptor = store.descriptorExpected(relativePath);
14999
15018
  if (!descriptor) throw new Error("managed_replace_identity_unavailable");
15000
- this.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(descriptor);
15019
+ this.#managedPersistExpectedIdentity = this.#captureManagedPersistIdentity(sessionFile);
15001
15020
  this.#publishCommitMarkerFromCurrentTranscriptSync();
15002
15021
  return;
15003
15022
  }
@@ -19961,7 +19980,7 @@ export class SessionManager {
19961
19980
  const returnManagedDescriptor = managedInspectionStore?.descriptorExpected(path.basename(filePath));
19962
19981
  if (!returnManagedDescriptor || !sameDescriptor(managedBoundedDescriptor, returnManagedDescriptor))
19963
19982
  throw new Error("Could not open session: unstable");
19964
- manager.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(returnManagedDescriptor);
19983
+ manager.#managedPersistExpectedIdentity = manager.#captureManagedPersistIdentity(filePath);
19965
19984
  const header = manager.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
19966
19985
  if (header?.cwd) manager.cwd = header.cwd;
19967
19986
  manager.buildSessionContext();
@@ -20079,7 +20098,7 @@ export class SessionManager {
20079
20098
  const finalDescriptor = store.descriptorExpected(path.basename(resolved));
20080
20099
  if (!finalDescriptor || !sameDescriptor(capturedDescriptor, finalDescriptor))
20081
20100
  throw new Error("source_changed");
20082
- boundedManager.#managedPersistExpectedIdentity = managedIdentityFromDescriptor(finalDescriptor);
20101
+ boundedManager.#managedPersistExpectedIdentity = boundedManager.#captureManagedPersistIdentity(resolved);
20083
20102
  boundedManager.buildSessionContext();
20084
20103
  return boundedManager;
20085
20104
  }
@@ -4,7 +4,7 @@
4
4
  {"id":"glm","aliases":["zai","z-ai","bigmodel"],"name":"GLM / zAI","description":"OpenAI-compatible GLM endpoint from zAI/BigModel","compatibility":"openai","api":"openai-completions","providerId":"glm-proxy","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"ZAI_API_KEY","models":["glm-4.6"],"compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"zai","reasoningContentField":"reasoning_content"}},
5
5
  {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","qwen3.8-max","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","qwen3.8-max":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}},
6
6
  {"id":"cline-pass","aliases":["clinepass","cline"],"name":"ClinePass","description":"ClinePass subscription models discovered from Cline's upstream models.dev catalog","compatibility":"openai","api":"openai-completions","providerId":"cline-pass","baseUrl":"https://api.cline.bot/api/v1","apiKeyEnv":"CLINE_API_KEY","discovery":{"type":"models-dev","modelsDevProvider":"cline-pass"},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning"}},
7
- {"id":"commandcode-goat","aliases":["commandcode","command-code","goat"],"name":"Command Code GOAT","description":"Command Code GOAT coding-plan models discovered from its OpenAI-compatible Provider API","compatibility":"openai","api":"openai-completions","providerId":"commandcode-goat","baseUrl":"https://api.commandcode.ai/provider/v1","apiKeyEnv":"CMD_API_KEY","discovery":{"type":"openai-models-list","apiByModelPrefix":{"claude-":"anthropic-messages"}},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
7
+ {"id":"commandcode-goat","aliases":["commandcode","command-code","goat"],"name":"Command Code GOAT","description":"Command Code GOAT coding-plan models discovered from its OpenAI-compatible Provider API","compatibility":"openai","api":"openai-completions","providerId":"commandcode-goat","baseUrl":"https://api.commandcode.ai/provider/v1","apiKeyEnv":"CMD_API_KEY","discovery":{"type":"openai-models-list","apiByModelPrefix":{"claude":"anthropic-messages","anthropic/claude":"anthropic-messages"}},"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","reasoningContentField":"reasoning_content"}},
8
8
  {"id":"litellm","aliases":["litellm-proxy"],"name":"LiteLLM Proxy","description":"OpenAI-compatible LiteLLM proxy endpoint (user-supplied base URL) with live model discovery","compatibility":"openai","api":"openai-completions","providerId":"litellm","parameterized":true,"apiKeyEnv":"LITELLM_API_KEY","discovery":{"type":"openai-models-list"}},
9
9
  {"id":"openai-compatible-proxy","aliases":["openai-proxy","compatible-proxy","custom-proxy"],"name":"OpenAI-Compatible Proxy","description":"Generic OpenAI-compatible gateway (OpenRouter, OpenCode Go, custom) with live model discovery","compatibility":"openai","api":"openai-completions","providerId":"openai-compatible-proxy","parameterized":true,"apiKeyEnv":"OPENAI_COMPATIBLE_API_KEY","discovery":{"type":"openai-models-list"}}
10
10
  ]
@@ -131,7 +131,12 @@ export async function switchSessionCredentialCommand(runtime: SlashCommandRuntim
131
131
  }
132
132
 
133
133
  try {
134
- authStorage.switchSessionCredential(provider, runtime.session.credentialSessionId, parsed.selector);
134
+ authStorage.switchSessionCredential(
135
+ provider,
136
+ runtime.session.credentialSessionId,
137
+ parsed.selector,
138
+ runtime.session.modelRegistry.getAuthStorageOwner?.(),
139
+ );
135
140
  } catch (error) {
136
141
  return error instanceof Error ? error.message : String(error);
137
142
  }
@@ -1121,6 +1121,8 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise<Singl
1121
1121
  const abortController = new AbortController();
1122
1122
  const abortSignal = abortController.signal;
1123
1123
  let activeSession: AgentSession | null = null;
1124
+ let ownedModelRegistry: ModelRegistry | undefined;
1125
+ let ownedAuthStorage: AuthStorage | undefined;
1124
1126
  let unsubscribe: (() => void) | null = null;
1125
1127
  let yieldCalled = false;
1126
1128
  let pauseRequested = false;
@@ -1703,9 +1705,19 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise<Singl
1703
1705
  checkAbort();
1704
1706
  // Pin authStorage to modelRegistry.authStorage — mirrors the createAgentSession invariant.
1705
1707
  const registryFromParent = options.modelRegistry !== undefined;
1706
- const modelRegistry =
1707
- options.modelRegistry ??
1708
- new ModelRegistry(options.authStorage ?? (await awaitAbortable(discoverAuthStorage())));
1708
+ const registryAuthStorage =
1709
+ options.authStorage ??
1710
+ options.modelRegistry?.authStorage ??
1711
+ (await awaitAbortable(discoverAuthStorage(settings.getAgentDir())));
1712
+ if (options.modelRegistry === undefined && options.authStorage === undefined)
1713
+ ownedAuthStorage = registryAuthStorage;
1714
+ ownedModelRegistry = options.modelRegistry
1715
+ ? undefined
1716
+ : new ModelRegistry(registryAuthStorage, path.join(settings.getAgentDir(), "models.yml"), settings, {
1717
+ agentDir: settings.getAgentDir(),
1718
+ automaticRefresh: false,
1719
+ });
1720
+ const modelRegistry = options.modelRegistry ?? ownedModelRegistry!;
1709
1721
  const authStorage = modelRegistry.authStorage;
1710
1722
  if (options.authStorage && options.authStorage !== authStorage) {
1711
1723
  throw new Error(
@@ -2500,6 +2512,10 @@ export async function runSubprocessOnce(options: ExecutorOptions): Promise<Singl
2500
2512
  // Ignore cleanup errors
2501
2513
  }
2502
2514
  }
2515
+ ownedModelRegistry?.dispose();
2516
+ ownedModelRegistry = undefined;
2517
+ ownedAuthStorage?.close();
2518
+ ownedAuthStorage = undefined;
2503
2519
  }
2504
2520
 
2505
2521
  return {
@@ -0,0 +1,26 @@
1
+ /** Process-wide emergency stop; it cannot be enabled by project dotenv files. */
2
+ export const TELEMETRY_KILL_SWITCH_ENV = "GJC_DISABLE_TELEMETRY" as const;
3
+
4
+ function isTruthy(value: string | undefined): boolean {
5
+ return value === "1" || value === "true" || value === "yes" || value === "on";
6
+ }
7
+
8
+ export interface TelemetryStatus {
9
+ enabled: boolean;
10
+ disabledByKillSwitch: boolean;
11
+ reason: "enabled" | "setting_disabled" | "kill_switch";
12
+ }
13
+
14
+ export interface TelemetrySettingsReader {
15
+ get(path: "telemetry.enabled"): boolean;
16
+ }
17
+
18
+ export function getTelemetryStatus(settings: TelemetrySettingsReader): TelemetryStatus {
19
+ if (isTruthy(process.env[TELEMETRY_KILL_SWITCH_ENV])) {
20
+ return { enabled: false, disabledByKillSwitch: true, reason: "kill_switch" };
21
+ }
22
+ if (settings.get("telemetry.enabled") !== true) {
23
+ return { enabled: false, disabledByKillSwitch: false, reason: "setting_disabled" };
24
+ }
25
+ return { enabled: true, disabledByKillSwitch: false, reason: "enabled" };
26
+ }
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { getTrustedAgentFile } from "@gajae-code/utils";
5
+
6
+ export const TELEMETRY_SCHEMA_VERSION = 1 as const;
7
+ export const TELEMETRY_INSTALL_ID_FILE = "telemetry-install-id" as const;
8
+
9
+ export const TELEMETRY_EVENT_NAMES = [
10
+ "update_check_started",
11
+ "update_check_completed",
12
+ "update_install_started",
13
+ "update_install_completed",
14
+ "update_install_failed",
15
+ ] as const;
16
+
17
+ export type TelemetryEventName = (typeof TELEMETRY_EVENT_NAMES)[number];
18
+
19
+ export interface TelemetryEvent {
20
+ schemaVersion: typeof TELEMETRY_SCHEMA_VERSION;
21
+ event: TelemetryEventName;
22
+ installId: string;
23
+ occurredAt: string;
24
+ channel?: "stable" | "nightly";
25
+ result?: "available" | "up_to_date" | "installed" | "failed" | "skipped";
26
+ installMethod?: "bun" | "npm" | "binary" | "migrate";
27
+ }
28
+
29
+ type EventInput = {
30
+ event?: unknown;
31
+ installId?: unknown;
32
+ occurredAt?: unknown;
33
+ channel?: unknown;
34
+ result?: unknown;
35
+ installMethod?: unknown;
36
+ [key: string]: unknown;
37
+ };
38
+
39
+ const EVENT_NAMES = new Set<string>(TELEMETRY_EVENT_NAMES);
40
+ const CHANNELS = new Set(["stable", "nightly"]);
41
+ const RESULTS = new Set(["available", "up_to_date", "installed", "failed", "skipped"]);
42
+ const INSTALL_METHODS = new Set(["bun", "npm", "binary", "migrate"]);
43
+ const FORBIDDEN_KEY = /(?:prompt|argv|path|env|secret|account|model|provider|repo|error|hostname|username|machine|ip)/i;
44
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
45
+
46
+ function hasForbiddenKey(value: unknown, seen = new Set<object>()): boolean {
47
+ if (value === null || typeof value !== "object") return false;
48
+ if (seen.has(value)) return false;
49
+ seen.add(value);
50
+ for (const [key, child] of Object.entries(value)) {
51
+ if (FORBIDDEN_KEY.test(key) || hasForbiddenKey(child, seen)) return true;
52
+ }
53
+ return false;
54
+ }
55
+
56
+ function requireString(value: unknown, field: string): string {
57
+ if (typeof value !== "string" || value.length === 0 || value.length > 128) {
58
+ throw new Error(`invalid telemetry ${field}`);
59
+ }
60
+ return value;
61
+ }
62
+
63
+ /**
64
+ * Serialize only the versioned telemetry allowlist. Unknown fields are never
65
+ * emitted; forbidden fields anywhere in the input fail closed.
66
+ */
67
+ export function serializeTelemetryEvent(input: unknown): string {
68
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
69
+ throw new Error("telemetry event must be an object");
70
+ }
71
+ if (hasForbiddenKey(input)) throw new Error("telemetry event contains forbidden data");
72
+ const value = input as EventInput;
73
+ const event = requireString(value.event, "event");
74
+ if (!EVENT_NAMES.has(event)) throw new Error("invalid telemetry event");
75
+ const installId = requireString(value.installId, "installId");
76
+ if (!UUID_V4.test(installId)) throw new Error("invalid telemetry installId");
77
+ const occurredAt = requireString(value.occurredAt, "occurredAt");
78
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(occurredAt) || Number.isNaN(Date.parse(occurredAt))) {
79
+ throw new Error("invalid telemetry occurredAt");
80
+ }
81
+
82
+ const output: TelemetryEvent = {
83
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
84
+ event: event as TelemetryEventName,
85
+ installId,
86
+ occurredAt,
87
+ };
88
+ if (value.channel !== undefined) {
89
+ if (typeof value.channel !== "string" || !CHANNELS.has(value.channel))
90
+ throw new Error("invalid telemetry channel");
91
+ output.channel = value.channel as TelemetryEvent["channel"];
92
+ }
93
+ if (value.result !== undefined) {
94
+ if (typeof value.result !== "string" || !RESULTS.has(value.result)) throw new Error("invalid telemetry result");
95
+ output.result = value.result as TelemetryEvent["result"];
96
+ }
97
+ if (value.installMethod !== undefined) {
98
+ if (typeof value.installMethod !== "string" || !INSTALL_METHODS.has(value.installMethod)) {
99
+ throw new Error("invalid telemetry installMethod");
100
+ }
101
+ output.installMethod = value.installMethod as TelemetryEvent["installMethod"];
102
+ }
103
+ return `${JSON.stringify(output)}\n`;
104
+ }
105
+
106
+ async function writeNewInstallId(filePath: string, installId: string): Promise<void> {
107
+ const handle = await fs.open(filePath, "wx", 0o600);
108
+ try {
109
+ await handle.writeFile(`${installId}\n`, "utf8");
110
+ } finally {
111
+ await handle.close();
112
+ }
113
+ }
114
+
115
+ /** Load or create a random UUIDv4 that is not derived from machine data. */
116
+ export async function getTelemetryInstallId(
117
+ filePath = getTrustedAgentFile(TELEMETRY_INSTALL_ID_FILE),
118
+ ): Promise<string> {
119
+ try {
120
+ const existing = (await Bun.file(filePath).text()).trim();
121
+ if (UUID_V4.test(existing)) {
122
+ await fs.chmod(filePath, 0o600);
123
+ return existing;
124
+ }
125
+ throw new Error("telemetry install ID is malformed");
126
+ } catch (error) {
127
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
128
+ }
129
+
130
+ await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
131
+ const generated = randomUUID();
132
+ try {
133
+ await writeNewInstallId(filePath, generated);
134
+ return generated;
135
+ } catch (error) {
136
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
137
+ const existing = (await Bun.file(filePath).text()).trim();
138
+ if (!UUID_V4.test(existing)) throw new Error("telemetry install ID is malformed");
139
+ await fs.chmod(filePath, 0o600);
140
+ return existing;
141
+ }
142
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./control";
2
+ export * from "./events";
3
+ export * from "./transport";
@@ -0,0 +1,87 @@
1
+ import { Settings } from "../config/settings";
2
+ import { getTelemetryStatus } from "./control";
3
+ import { getTelemetryInstallId, serializeTelemetryEvent, type TelemetryEvent, type TelemetryEventName } from "./events";
4
+
5
+ export const TELEMETRY_ENDPOINT = "https://telemetry.gajae.dev/v1/events" as const;
6
+ export const TELEMETRY_MAX_IN_FLIGHT = 2 as const;
7
+ export const TELEMETRY_TIMEOUT_MS = 1500 as const;
8
+ export const TELEMETRY_FLUSH_TIMEOUT_MS = 2000 as const;
9
+
10
+ interface TelemetryTransportDependencies {
11
+ fetchImpl?: (input: string | URL, init?: RequestInit) => Promise<Response>;
12
+ endpoint?: string;
13
+ }
14
+
15
+ export type TelemetryDetails = Pick<TelemetryEvent, "channel" | "result" | "installMethod">;
16
+
17
+ let inFlight = 0;
18
+ const pendingTelemetry = new Set<Promise<void>>();
19
+
20
+ /**
21
+ * Best-effort transport. The caller only schedules work; disabled telemetry,
22
+ * queue saturation, network errors, and non-2xx responses are all ignored.
23
+ */
24
+ export function sendTelemetryEvent(
25
+ settings: Parameters<typeof getTelemetryStatus>[0],
26
+ event: TelemetryEvent,
27
+ deps: TelemetryTransportDependencies = {},
28
+ ): Promise<void> {
29
+ if (!getTelemetryStatus(settings).enabled || inFlight >= TELEMETRY_MAX_IN_FLIGHT) return Promise.resolve();
30
+ let body: string;
31
+ try {
32
+ body = serializeTelemetryEvent(event);
33
+ } catch {
34
+ return Promise.resolve();
35
+ }
36
+ inFlight++;
37
+ const fetchImpl = deps.fetchImpl ?? fetch;
38
+ const controller = new AbortController();
39
+ const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
40
+ timeout.unref?.();
41
+ const delivery = fetchImpl(deps.endpoint ?? TELEMETRY_ENDPOINT, {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body,
45
+ signal: controller.signal,
46
+ redirect: "error",
47
+ })
48
+ .catch(() => undefined)
49
+ .then(() => undefined)
50
+ .finally(() => {
51
+ clearTimeout(timeout);
52
+ inFlight--;
53
+ });
54
+ pendingTelemetry.add(delivery);
55
+ void delivery.finally(() => pendingTelemetry.delete(delivery));
56
+ return delivery;
57
+ }
58
+
59
+ export async function resetTelemetryTransportForTest(): Promise<void> {
60
+ await Promise.allSettled([...pendingTelemetry]);
61
+ }
62
+
63
+ /** Schedule an event without making telemetry part of the caller's lifecycle. */
64
+ export function recordTelemetryEvent(event: TelemetryEventName, details: TelemetryDetails): Promise<void> {
65
+ const pending = (async () => {
66
+ const settings = await Settings.init();
67
+ if (!getTelemetryStatus(settings).enabled) return;
68
+ const installId = await getTelemetryInstallId();
69
+ await sendTelemetryEvent(settings, {
70
+ schemaVersion: 1,
71
+ event,
72
+ installId,
73
+ occurredAt: new Date().toISOString(),
74
+ ...details,
75
+ });
76
+ })().catch(() => undefined);
77
+ pendingTelemetry.add(pending);
78
+ void pending.finally(() => pendingTelemetry.delete(pending));
79
+ return pending;
80
+ }
81
+
82
+ /** Wait briefly for accepted events so explicit process exits do not drop them. */
83
+ export async function flushTelemetryEvents(timeoutMs = TELEMETRY_FLUSH_TIMEOUT_MS): Promise<void> {
84
+ const pending = [...pendingTelemetry];
85
+ if (pending.length === 0) return;
86
+ await Promise.race([Promise.allSettled(pending), Bun.sleep(timeoutMs)]);
87
+ }