@gajae-code/coding-agent 0.3.1 → 0.4.0

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 (166) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +1 -1
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/commands/launch.d.ts +6 -0
  5. package/dist/types/config/model-profile-activation.d.ts +30 -0
  6. package/dist/types/config/model-profiles.d.ts +19 -0
  7. package/dist/types/config/model-registry.d.ts +25 -10
  8. package/dist/types/config/model-resolver.d.ts +1 -1
  9. package/dist/types/config/models-config-schema.d.ts +84 -0
  10. package/dist/types/config/settings-schema.d.ts +15 -0
  11. package/dist/types/edit/diff.d.ts +16 -0
  12. package/dist/types/edit/modes/replace.d.ts +7 -0
  13. package/dist/types/extensibility/gjc-plugins/activation.d.ts +14 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +31 -0
  16. package/dist/types/extensibility/gjc-plugins/loader.d.ts +3 -0
  17. package/dist/types/extensibility/gjc-plugins/paths.d.ts +8 -0
  18. package/dist/types/extensibility/gjc-plugins/schema.d.ts +3 -0
  19. package/dist/types/extensibility/gjc-plugins/state.d.ts +9 -0
  20. package/dist/types/extensibility/gjc-plugins/tools.d.ts +8 -0
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/validation.d.ts +4 -0
  23. package/dist/types/extensibility/skills.d.ts +9 -1
  24. package/dist/types/gjc-runtime/state-runtime.d.ts +22 -0
  25. package/dist/types/harness-control-plane/storage.d.ts +7 -0
  26. package/dist/types/lsp/client.d.ts +1 -0
  27. package/dist/types/main.d.ts +10 -1
  28. package/dist/types/modes/bridge/bridge-mode.d.ts +2 -0
  29. package/dist/types/modes/components/custom-provider-wizard.d.ts +10 -0
  30. package/dist/types/modes/components/model-selector.d.ts +6 -1
  31. package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
  32. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  33. package/dist/types/modes/prompt-action-autocomplete.d.ts +2 -2
  34. package/dist/types/modes/rpc/rpc-client.d.ts +9 -1
  35. package/dist/types/modes/rpc/rpc-types.d.ts +179 -2
  36. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +57 -0
  37. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +16 -1
  38. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +47 -0
  39. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +7 -0
  40. package/dist/types/modes/shared/agent-wire/handshake.d.ts +11 -1
  41. package/dist/types/modes/shared/agent-wire/protocol.d.ts +3 -1
  42. package/dist/types/modes/shared/agent-wire/responses.d.ts +1 -1
  43. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +27 -0
  44. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +68 -0
  45. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +161 -0
  46. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +61 -0
  47. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +114 -0
  48. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +39 -0
  49. package/dist/types/modes/theme/theme.d.ts +2 -1
  50. package/dist/types/modes/types.d.ts +1 -0
  51. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  52. package/dist/types/sdk.d.ts +8 -1
  53. package/dist/types/session/agent-session.d.ts +10 -0
  54. package/dist/types/session/blob-store.d.ts +17 -0
  55. package/dist/types/session/messages.d.ts +3 -0
  56. package/dist/types/session/session-storage.d.ts +6 -0
  57. package/dist/types/skill-state/active-state.d.ts +13 -0
  58. package/dist/types/task/executor.d.ts +1 -0
  59. package/dist/types/thinking.d.ts +3 -2
  60. package/dist/types/tools/hindsight-recall.d.ts +0 -2
  61. package/dist/types/tools/hindsight-reflect.d.ts +0 -2
  62. package/dist/types/tools/hindsight-retain.d.ts +0 -2
  63. package/dist/types/tools/index.d.ts +7 -4
  64. package/package.json +9 -7
  65. package/src/cli/args.ts +10 -0
  66. package/src/cli.ts +14 -0
  67. package/src/commands/harness.ts +192 -7
  68. package/src/commands/launch.ts +8 -0
  69. package/src/commands/ultragoal.ts +1 -21
  70. package/src/config/model-equivalence.ts +1 -1
  71. package/src/config/model-profile-activation.ts +157 -0
  72. package/src/config/model-profiles.ts +155 -0
  73. package/src/config/model-registry.ts +51 -5
  74. package/src/config/model-resolver.ts +3 -2
  75. package/src/config/models-config-schema.ts +42 -1
  76. package/src/config/settings-schema.ts +14 -1
  77. package/src/defaults/gjc/skills/ultragoal/SKILL.md +11 -1
  78. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +61 -0
  79. package/src/defaults/gjc-defaults.ts +7 -0
  80. package/src/discovery/claude-plugins.ts +25 -5
  81. package/src/edit/diff.ts +64 -1
  82. package/src/edit/modes/replace.ts +60 -2
  83. package/src/extensibility/gjc-plugins/activation.ts +87 -0
  84. package/src/extensibility/gjc-plugins/index.ts +9 -0
  85. package/src/extensibility/gjc-plugins/injection.ts +114 -0
  86. package/src/extensibility/gjc-plugins/loader.ts +131 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +66 -0
  88. package/src/extensibility/gjc-plugins/schema.ts +79 -0
  89. package/src/extensibility/gjc-plugins/state.ts +29 -0
  90. package/src/extensibility/gjc-plugins/tools.ts +47 -0
  91. package/src/extensibility/gjc-plugins/types.ts +97 -0
  92. package/src/extensibility/gjc-plugins/validation.ts +76 -0
  93. package/src/extensibility/skills.ts +39 -7
  94. package/src/gjc-runtime/state-runtime.ts +93 -2
  95. package/src/gjc-runtime/state-writer.ts +17 -1
  96. package/src/gjc-runtime/ultragoal-runtime.ts +62 -2
  97. package/src/gjc-runtime/workflow-manifest.generated.json +5 -0
  98. package/src/gjc-runtime/workflow-manifest.ts +2 -2
  99. package/src/harness-control-plane/storage.ts +144 -2
  100. package/src/hashline/hash.ts +23 -0
  101. package/src/hooks/skill-state.ts +2 -0
  102. package/src/internal-urls/docs-index.generated.ts +8 -11
  103. package/src/lsp/client.ts +7 -0
  104. package/src/main.ts +67 -1
  105. package/src/modes/acp/acp-agent.ts +25 -2
  106. package/src/modes/bridge/bridge-mode.ts +124 -2
  107. package/src/modes/components/custom-provider-wizard.ts +318 -0
  108. package/src/modes/components/model-selector.ts +108 -18
  109. package/src/modes/components/provider-onboarding-selector.ts +6 -1
  110. package/src/modes/controllers/input-controller.ts +14 -2
  111. package/src/modes/controllers/selector-controller.ts +57 -1
  112. package/src/modes/prompt-action-autocomplete.ts +49 -10
  113. package/src/modes/rpc/rpc-client.ts +57 -3
  114. package/src/modes/rpc/rpc-mode.ts +67 -0
  115. package/src/modes/rpc/rpc-types.ts +224 -2
  116. package/src/modes/shared/agent-wire/approval-gate.ts +151 -0
  117. package/src/modes/shared/agent-wire/command-dispatch.ts +97 -4
  118. package/src/modes/shared/agent-wire/command-validation.ts +25 -1
  119. package/src/modes/shared/agent-wire/deep-interview-gate.ts +222 -0
  120. package/src/modes/shared/agent-wire/event-envelope.ts +13 -0
  121. package/src/modes/shared/agent-wire/handshake.ts +43 -3
  122. package/src/modes/shared/agent-wire/protocol.ts +7 -0
  123. package/src/modes/shared/agent-wire/responses.ts +2 -2
  124. package/src/modes/shared/agent-wire/scopes.ts +2 -0
  125. package/src/modes/shared/agent-wire/unattended-action-policy.ts +341 -0
  126. package/src/modes/shared/agent-wire/unattended-audit.ts +175 -0
  127. package/src/modes/shared/agent-wire/unattended-run-controller.ts +406 -0
  128. package/src/modes/shared/agent-wire/unattended-session.ts +180 -0
  129. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +324 -0
  130. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +331 -0
  131. package/src/modes/theme/theme.ts +6 -0
  132. package/src/modes/types.ts +1 -0
  133. package/src/prompts/memories/consolidation.md +1 -1
  134. package/src/prompts/memories/read-path.md +6 -7
  135. package/src/prompts/memories/unavailable.md +2 -2
  136. package/src/prompts/tools/bash.md +1 -1
  137. package/src/prompts/tools/irc.md +1 -1
  138. package/src/prompts/tools/read.md +2 -2
  139. package/src/prompts/tools/recall.md +1 -0
  140. package/src/prompts/tools/reflect.md +1 -0
  141. package/src/prompts/tools/retain.md +1 -0
  142. package/src/runtime-mcp/client.ts +7 -4
  143. package/src/runtime-mcp/manager.ts +45 -13
  144. package/src/runtime-mcp/transports/http.ts +40 -14
  145. package/src/runtime-mcp/transports/stdio.ts +11 -10
  146. package/src/sdk.ts +48 -1
  147. package/src/session/agent-session.ts +211 -2
  148. package/src/session/blob-store.ts +84 -0
  149. package/src/session/messages.ts +3 -0
  150. package/src/session/session-manager.ts +390 -33
  151. package/src/session/session-storage.ts +26 -0
  152. package/src/setup/provider-onboarding.ts +2 -2
  153. package/src/skill-state/active-state.ts +89 -1
  154. package/src/slash-commands/builtin-registry.ts +1 -1
  155. package/src/task/discovery.ts +7 -1
  156. package/src/task/executor.ts +18 -2
  157. package/src/task/index.ts +2 -0
  158. package/src/thinking.ts +8 -2
  159. package/src/tools/ask.ts +39 -9
  160. package/src/tools/hindsight-recall.ts +0 -2
  161. package/src/tools/hindsight-reflect.ts +0 -2
  162. package/src/tools/hindsight-retain.ts +0 -2
  163. package/src/tools/index.ts +7 -18
  164. package/src/tools/read.ts +3 -3
  165. package/src/tools/skill.ts +15 -3
  166. package/src/utils/edit-mode.ts +1 -1
package/src/lsp/client.ts CHANGED
@@ -61,6 +61,10 @@ function stopIdleChecker(): void {
61
61
  }
62
62
  }
63
63
 
64
+ export function isIdleCheckerActiveForTests(): boolean {
65
+ return idleCheckInterval !== null;
66
+ }
67
+
64
68
  // =============================================================================
65
69
  // Client Capabilities
66
70
  // =============================================================================
@@ -919,6 +923,9 @@ export async function sendNotification(client: LspClient, method: string, params
919
923
  * Shutdown all LSP clients.
920
924
  */
921
925
  export async function shutdownAll(): Promise<void> {
926
+ stopIdleChecker();
927
+ clientLocks.clear();
928
+ fileOperationLocks.clear();
922
929
  const clientsToShutdown = Array.from(clients.values());
923
930
  clients.clear();
924
931
  await Promise.allSettled(clientsToShutdown.map(client => shutdownClientInstance(client)));
package/src/main.ts CHANGED
@@ -26,6 +26,7 @@ import { buildInitialMessage } from "./cli/initial-message";
26
26
  import { runListModelsCommand } from "./cli/list-models";
27
27
  import { selectSession } from "./cli/session-picker";
28
28
  import { findConfigFile } from "./config";
29
+ import { activateModelProfile } from "./config/model-profile-activation";
29
30
  import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
30
31
  import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
31
32
  import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
@@ -194,11 +195,59 @@ export interface AcpSessionFactoryOptions {
194
195
  sessionDir?: string;
195
196
  authStorage: AuthStorage;
196
197
  modelRegistry: ModelRegistry;
197
- parsedArgs: Pick<Args, "apiKey">;
198
+ parsedArgs: Pick<Args, "apiKey" | "default" | "model" | "mpreset" | "thinking">;
198
199
  rawArgs: string[];
199
200
  createSession: (options: CreateAgentSessionOptions) => Promise<CreateAgentSessionResult>;
200
201
  }
201
202
 
203
+ export async function applyStartupModelProfiles(args: {
204
+ session: AgentSession;
205
+ settings: Settings;
206
+ modelRegistry: ModelRegistry;
207
+ parsedArgs: Pick<Args, "default" | "model" | "mpreset" | "thinking">;
208
+ startupModel?: CreateAgentSessionOptions["model"];
209
+ startupThinkingLevel?: CreateAgentSessionOptions["thinkingLevel"];
210
+ }): Promise<void> {
211
+ const applyProfile = async (profileName: string, persistDefault: boolean): Promise<void> => {
212
+ await activateModelProfile(
213
+ { session: args.session, modelRegistry: args.modelRegistry, settings: args.settings, profileName },
214
+ { persistDefault },
215
+ );
216
+ };
217
+
218
+ // Capture the explicitly-selected startup model BEFORE profile activation can
219
+ // override it. startupModel covers the eager path; session.model covers the
220
+ // deferred `--model <pattern>` path resolved inside createAgentSession.
221
+ const explicitModel = args.parsedArgs.model ? (args.startupModel ?? args.session.model) : undefined;
222
+
223
+ const defaultProfile = args.settings.get("modelProfile.default");
224
+ if (defaultProfile) {
225
+ await applyProfile(defaultProfile, false);
226
+ }
227
+ if (args.parsedArgs.mpreset) {
228
+ await applyProfile(args.parsedArgs.mpreset, args.parsedArgs.default === true);
229
+ }
230
+
231
+ // Explicit CLI --model/--thinking must win over any activated profile.
232
+ if (explicitModel) {
233
+ await args.session.setModelTemporary(explicitModel, args.startupThinkingLevel ?? args.parsedArgs.thinking);
234
+ } else if (args.parsedArgs.thinking && args.session.model) {
235
+ await args.session.setModelTemporary(args.session.model, args.parsedArgs.thinking);
236
+ }
237
+ }
238
+
239
+ export async function applyStartupModelProfilesOrExit(
240
+ args: Parameters<typeof applyStartupModelProfiles>[0],
241
+ ): Promise<void> {
242
+ try {
243
+ await applyStartupModelProfiles(args);
244
+ } catch (error) {
245
+ const message = error instanceof Error ? error.message : String(error);
246
+ process.stderr.write(`${chalk.red(`Error: ${message}`)}\n`);
247
+ process.exit(1);
248
+ }
249
+ }
250
+
202
251
  /**
203
252
  * Build the per-`session/new` factory used by ACP mode.
204
253
  *
@@ -225,6 +274,14 @@ export function createAcpSessionFactory(args: AcpSessionFactoryOptions): AcpSess
225
274
  hasUI: false,
226
275
  enableMCP: false,
227
276
  });
277
+ await applyStartupModelProfilesOrExit({
278
+ session: nextSession,
279
+ settings: nextSettings,
280
+ modelRegistry: args.modelRegistry,
281
+ parsedArgs: args.parsedArgs,
282
+ startupModel: args.baseOptions.model,
283
+ startupThinkingLevel: args.baseOptions.thinkingLevel,
284
+ });
228
285
  if (args.parsedArgs.apiKey && !args.baseOptions.model && nextSession.model) {
229
286
  args.authStorage.setRuntimeApiKey(nextSession.model.provider, args.parsedArgs.apiKey);
230
287
  }
@@ -878,6 +935,15 @@ export async function runRootCommand(
878
935
  authStorage.setRuntimeApiKey(session.model.provider, parsedArgs.apiKey);
879
936
  }
880
937
 
938
+ await applyStartupModelProfilesOrExit({
939
+ session,
940
+ settings: settingsInstance,
941
+ modelRegistry,
942
+ parsedArgs,
943
+ startupModel: sessionOptions.model,
944
+ startupThinkingLevel: sessionOptions.thinkingLevel,
945
+ });
946
+
881
947
  if (modelFallbackMessage) {
882
948
  notifs.push({ kind: "warn", message: modelFallbackMessage });
883
949
  }
@@ -50,6 +50,7 @@ import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../
50
50
  import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
51
51
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
52
52
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
53
+ import { resolveSubskillActivationForSkillInvocation } from "../../extensibility/gjc-plugins";
53
54
  import {
54
55
  buildSkillPromptMessage,
55
56
  getSkillSlashCommandNames,
@@ -722,7 +723,18 @@ export class AcpAgent implements Agent {
722
723
  for (let index = 0; index < invocations.length; index += 1) {
723
724
  const invocation = invocations[index];
724
725
  if (!invocation) continue;
725
- const built = await buildSkillPromptMessage(invocation.skill, invocation.args);
726
+ const activationResult = await resolveSubskillActivationForSkillInvocation({
727
+ cwd: record.session.sessionManager.getCwd(),
728
+ sessionId: record.session.sessionId,
729
+ skillName: invocation.skill.name,
730
+ args: invocation.args,
731
+ });
732
+ const built = await buildSkillPromptMessage(invocation.skill, activationResult.cleanedArgs, {
733
+ subskillActivation: activationResult.activation,
734
+ subskillActivationSet: activationResult.activeSubskillsToPersist,
735
+ cwd: record.session.sessionManager.getCwd(),
736
+ sessionId: record.session.sessionId,
737
+ });
726
738
  if (index === invocations.length - 1) {
727
739
  await record.session.promptCustomMessage({
728
740
  customType: SKILL_PROMPT_MESSAGE_TYPE,
@@ -757,7 +769,18 @@ export class AcpAgent implements Agent {
757
769
  if (!skill || skill.hide === true) {
758
770
  return false;
759
771
  }
760
- const built = await buildSkillPromptMessage(skill, args);
772
+ const activationResult = await resolveSubskillActivationForSkillInvocation({
773
+ cwd: record.session.sessionManager.getCwd(),
774
+ sessionId: record.session.sessionId,
775
+ skillName: skill.name,
776
+ args,
777
+ });
778
+ const built = await buildSkillPromptMessage(skill, activationResult.cleanedArgs, {
779
+ subskillActivation: activationResult.activation,
780
+ subskillActivationSet: activationResult.activeSubskillsToPersist,
781
+ cwd: record.session.sessionManager.getCwd(),
782
+ sessionId: record.session.sessionId,
783
+ });
761
784
  await record.session.promptCustomMessage({
762
785
  customType: SKILL_PROMPT_MESSAGE_TYPE,
763
786
  content: built.message,
@@ -1,10 +1,15 @@
1
+ import * as path from "node:path";
1
2
  import type { ExtensionUIContext } from "../../extensibility/extensions";
2
3
  import type { AgentSession } from "../../session/agent-session";
3
4
  import type { ClientBridgePermissionOutcome } from "../../session/client-bridge";
4
- import type { RpcCommand, RpcResponse } from "../rpc/rpc-types";
5
+ import type { RpcCommand, RpcResponse, RpcWorkflowGateResponse } from "../rpc/rpc-types";
5
6
  import { dispatchRpcCommand } from "../shared/agent-wire/command-dispatch";
6
7
  import { isRpcCommand } from "../shared/agent-wire/command-validation";
7
- import { BridgeFrameSequencer, toBridgeEventFrame } from "../shared/agent-wire/event-envelope";
8
+ import {
9
+ BridgeFrameSequencer,
10
+ toBridgeEventFrame,
11
+ toBridgeWorkflowGateFrame,
12
+ } from "../shared/agent-wire/event-envelope";
8
13
  import type { BridgeCapability } from "../shared/agent-wire/handshake";
9
14
  import {
10
15
  type BridgeHandshakeRequest,
@@ -23,6 +28,9 @@ import {
23
28
  } from "../shared/agent-wire/scopes";
24
29
  import { UiRequestBroker } from "../shared/agent-wire/ui-request-broker";
25
30
  import type { BridgeUiResult } from "../shared/agent-wire/ui-result";
31
+ import { defaultAuditPath, UnattendedAuditLog } from "../shared/agent-wire/unattended-audit";
32
+ import { UnattendedSessionControlPlane } from "../shared/agent-wire/unattended-session";
33
+ import { FileGateStore } from "../shared/agent-wire/workflow-gate-broker";
26
34
  import { assertSafeBridgeBind, isBridgeTokenAuthorized } from "./auth";
27
35
  import { type BridgePermissionRequestPayload, createBridgeClientBridge } from "./bridge-client-bridge";
28
36
  import { BridgeExtensionUIContext, type BridgeUiRequestPayload } from "./bridge-ui-context";
@@ -39,6 +47,7 @@ const SERVER_CAPABILITIES: readonly BridgeCapability[] = [
39
47
  "ui.declarative",
40
48
  "host_tools",
41
49
  "host_uri",
50
+ "workflow_gate",
42
51
  ];
43
52
 
44
53
  const DEFAULT_BRIDGE_SCOPES: readonly BridgeCommandScope[] = ["prompt"];
@@ -71,6 +80,7 @@ const SERVER_FRAME_TYPES: readonly BridgeFrameType[] = [
71
80
  "host_tool_call",
72
81
  "host_uri_request",
73
82
  "reset",
83
+ "workflow_gate",
74
84
  "error",
75
85
  ];
76
86
 
@@ -86,6 +96,7 @@ interface BridgeFetchHandlerOptions {
86
96
  hostToolBridge?: RpcHostToolBridge;
87
97
  hostUriBridge?: RpcHostUriBridge;
88
98
  endpointMatrix?: Partial<BridgeEndpointMatrix>;
99
+ unattendedControlPlane?: UnattendedSessionControlPlane;
89
100
  }
90
101
 
91
102
  interface BridgeIdempotencyRecord {
@@ -174,6 +185,15 @@ function bridgeHelpResponse(matrix: BridgeEndpointMatrix): Response {
174
185
  });
175
186
  }
176
187
 
188
+ function auditOutcomeFor(event: string): "accepted" | "rejected" | "denied" | "exceeded" | "aborted" | "info" {
189
+ if (event.includes("denied")) return "denied";
190
+ if (event.includes("exceeded")) return "exceeded";
191
+ if (event.includes("abort")) return "aborted";
192
+ if (event.includes("rejected") || event.includes("conflict")) return "rejected";
193
+ if (event.includes("accepted") || event.includes("negotiated") || event.includes("emitted")) return "accepted";
194
+ return "info";
195
+ }
196
+
177
197
  function frameTypeForDispatchOutput(obj: RpcResponse | object): BridgeFrameType {
178
198
  const type = typeof obj === "object" && obj !== null && "type" in obj ? (obj as { type?: unknown }).type : undefined;
179
199
  if (type === "host_tool_call" || type === "host_tool_cancel") return "host_tool_call";
@@ -219,6 +239,24 @@ export function createBridgeFetchHandler(options: BridgeFetchHandlerOptions): (r
219
239
  if (!isBridgeHandshakeRequest(payload)) {
220
240
  return jsonResponse(400, { error: "invalid_request" });
221
241
  }
242
+ let acceptedUnattended = options.unattendedControlPlane?.isUnattended() ? payload.unattended : undefined;
243
+ if (
244
+ acceptedUnattended === undefined &&
245
+ payload.unattended !== undefined &&
246
+ endpointMatrix.events &&
247
+ options.unattendedControlPlane
248
+ ) {
249
+ try {
250
+ options.unattendedControlPlane.negotiate(payload.unattended);
251
+ acceptedUnattended = payload.unattended;
252
+ } catch (err) {
253
+ const error =
254
+ err instanceof Error && "code" in err
255
+ ? { code: (err as { code: unknown }).code, message: err.message }
256
+ : { error: err instanceof Error ? err.message : String(err) };
257
+ return jsonResponse(403, error);
258
+ }
259
+ }
222
260
  return jsonResponse(
223
261
  200,
224
262
  negotiateBridgeHandshake(payload, {
@@ -243,6 +281,7 @@ export function createBridgeFetchHandler(options: BridgeFetchHandlerOptions): (r
243
281
  : "",
244
282
  },
245
283
  frameTypes: endpointMatrix.events ? SERVER_FRAME_TYPES : [],
284
+ acceptedUnattended,
246
285
  }),
247
286
  );
248
287
  }
@@ -360,6 +399,36 @@ export function createBridgeFetchHandler(options: BridgeFetchHandlerOptions): (r
360
399
  } catch {
361
400
  return jsonResponse(400, { error: "invalid_json" });
362
401
  }
402
+ if (
403
+ payload !== null &&
404
+ typeof payload === "object" &&
405
+ "gate_id" in payload &&
406
+ "answer" in payload &&
407
+ (correlationId === (payload as RpcWorkflowGateResponse).gate_id || correlationId.startsWith("wg_"))
408
+ ) {
409
+ try {
410
+ const resolution = await options.unattendedControlPlane?.resolveGate({
411
+ gate_id: (payload as RpcWorkflowGateResponse).gate_id,
412
+ answer: (payload as RpcWorkflowGateResponse).answer,
413
+ idempotency_key: (payload as RpcWorkflowGateResponse).idempotency_key ?? idempotencyKey,
414
+ });
415
+ if (resolution) {
416
+ rememberIdempotencyResponse(options.idempotencyCache, idempotencyKey, {
417
+ route: url.pathname,
418
+ ownerToken,
419
+ body,
420
+ response: resolution,
421
+ });
422
+ return jsonResponse(200, resolution);
423
+ }
424
+ } catch (err) {
425
+ const error =
426
+ err instanceof Error && "code" in err
427
+ ? { code: (err as { code: unknown }).code, message: err.message }
428
+ : { error: err instanceof Error ? err.message : String(err) };
429
+ return jsonResponse(409, error);
430
+ }
431
+ }
363
432
  const permissionResult = options.permissionBroker?.respond(
364
433
  correlationId,
365
434
  ownerToken,
@@ -490,6 +559,57 @@ export async function runBridgeMode(
490
559
  const hostToolBridge = new RpcHostToolBridge(output);
491
560
  const hostUriBridge = new RpcHostUriBridge(output);
492
561
  const idempotencyCache: BridgeIdempotencyCache = new Map();
562
+ const auditLog = new UnattendedAuditLog(defaultAuditPath(session.sessionId, session.sessionManager.getCwd()), {
563
+ redactAnswers: true,
564
+ });
565
+ const recordAudit = (event: { event: string; [key: string]: unknown }) => {
566
+ const payload =
567
+ typeof event.payload === "object" && event.payload !== null
568
+ ? (event.payload as Record<string, unknown>)
569
+ : undefined;
570
+ const gateId =
571
+ typeof event.gate_id === "string"
572
+ ? event.gate_id
573
+ : typeof payload?.gate_id === "string"
574
+ ? payload.gate_id
575
+ : undefined;
576
+ auditLog.record({
577
+ run_id: session.sessionId,
578
+ session_id: session.sessionId,
579
+ actor: typeof event.actor === "string" ? event.actor : undefined,
580
+ event: event.event,
581
+ outcome: auditOutcomeFor(event.event),
582
+ dedupe_key: `${event.event}:${gateId ?? "run"}:${JSON.stringify(payload ?? event)}`,
583
+ gate_id: gateId,
584
+ stage: typeof event.stage === "string" ? (event.stage as never) : undefined,
585
+ kind: typeof event.kind === "string" ? (event.kind as never) : undefined,
586
+ scope: typeof payload?.scope === "string" ? payload.scope : undefined,
587
+ action: typeof payload?.action === "string" ? payload.action : undefined,
588
+ budget: event.event === "budget_exceeded" ? (payload as never) : undefined,
589
+ answer_hash: typeof event.answer_hash === "string" ? event.answer_hash : undefined,
590
+ error: payload && event.event.endsWith("denied") ? payload : undefined,
591
+ });
592
+ };
593
+ const gateStore = new FileGateStore(
594
+ path.join(session.sessionManager.getCwd(), ".gjc", "state", "workflow-gates", `${session.sessionId}.json`),
595
+ );
596
+ const unattendedControlPlane = new UnattendedSessionControlPlane({
597
+ runId: session.sessionId,
598
+ sessionId: session.sessionId,
599
+ emitFrame: gate => eventStream.publish(toBridgeWorkflowGateFrame(gate, sequencer)),
600
+ store: gateStore,
601
+ audit: recordAudit,
602
+ getUsageSnapshot: () => {
603
+ const stats = session.getSessionStats();
604
+ return { tokens: stats.tokens.total, costUsd: stats.cost };
605
+ },
606
+ });
607
+ session.setWorkflowGateEmitter(unattendedControlPlane);
608
+ unattendedControlPlane
609
+ .recover()
610
+ .catch(err =>
611
+ eventStream.publish(sequencer.next("error", { error: err instanceof Error ? err.message : String(err) })),
612
+ );
493
613
 
494
614
  Bun.serve({
495
615
  hostname,
@@ -505,6 +625,7 @@ export async function runBridgeMode(
505
625
  hostToolBridge,
506
626
  hostUriBridge,
507
627
  commandScopes,
628
+ unattendedControlPlane,
508
629
  commandDispatcher: command =>
509
630
  dispatchRpcCommand(command, {
510
631
  session,
@@ -512,6 +633,7 @@ export async function runBridgeMode(
512
633
  hostToolRegistry: hostToolBridge,
513
634
  hostUriRegistry: hostUriBridge,
514
635
  createUiContext: () => uiContext,
636
+ unattendedControlPlane,
515
637
  }),
516
638
  }),
517
639
  });
@@ -0,0 +1,318 @@
1
+ import { Container, Input, matchesKey, Spacer, Text, TruncatedText } from "@gajae-code/tui";
2
+ import type { ProviderCompatibility, ProviderSetupInput } from "../../setup/provider-onboarding";
3
+ import { theme } from "../theme/theme";
4
+ import { matchesAppInterrupt } from "../utils/keybinding-matchers";
5
+ import { DynamicBorder } from "./dynamic-border";
6
+
7
+ export type CustomProviderCredentialSource = "env" | "literal";
8
+
9
+ type WizardStep =
10
+ | "compatibility"
11
+ | "provider-id"
12
+ | "base-url"
13
+ | "credential-source"
14
+ | "credential"
15
+ | "models"
16
+ | "confirm"
17
+ | "force-confirm";
18
+
19
+ interface WizardState {
20
+ compatibility: ProviderCompatibility;
21
+ providerId: string;
22
+ baseUrl: string;
23
+ credentialSource: CustomProviderCredentialSource;
24
+ credential: string;
25
+ models: string;
26
+ }
27
+
28
+ export type CustomProviderWizardSubmit = ProviderSetupInput;
29
+
30
+ export class CustomProviderWizardComponent extends Container {
31
+ #contentContainer: Container;
32
+ #input: Input | null = null;
33
+ #step: WizardStep = "compatibility";
34
+ #selectedIndex = 0;
35
+ #lastSubmitError: string | null = null;
36
+ #state: WizardState = {
37
+ compatibility: "openai",
38
+ providerId: "",
39
+ baseUrl: "",
40
+ credentialSource: "env",
41
+ credential: "",
42
+ models: "",
43
+ };
44
+ #onSubmit: (input: CustomProviderWizardSubmit) => void;
45
+ #onCancel: () => void;
46
+ #onRender: () => void;
47
+
48
+ constructor(
49
+ onSubmit: (input: CustomProviderWizardSubmit) => void,
50
+ onCancel: () => void,
51
+ onRender: () => void = () => {},
52
+ ) {
53
+ super();
54
+ this.#onSubmit = onSubmit;
55
+ this.#onCancel = onCancel;
56
+ this.#onRender = onRender;
57
+
58
+ this.addChild(new DynamicBorder());
59
+ this.addChild(new Spacer(1));
60
+ this.addChild(new TruncatedText(theme.bold("Add custom provider")));
61
+ this.addChild(
62
+ new TruncatedText(theme.fg("muted", " Configure an OpenAI- or Anthropic-compatible API provider."), 0, 0),
63
+ );
64
+ this.addChild(new Spacer(1));
65
+ this.#contentContainer = new Container();
66
+ this.addChild(this.#contentContainer);
67
+ this.addChild(new Spacer(1));
68
+ this.addChild(new DynamicBorder());
69
+ this.#renderStep();
70
+ }
71
+
72
+ setSubmitError(error: string): void {
73
+ this.#lastSubmitError = error;
74
+ if (error.includes("already exists")) {
75
+ this.#step = "force-confirm";
76
+ this.#selectedIndex = 1;
77
+ }
78
+ this.#renderStep();
79
+ this.#onRender();
80
+ }
81
+
82
+ handleInput(keyData: string): void {
83
+ if (matchesAppInterrupt(keyData)) {
84
+ if (this.#step === "compatibility") {
85
+ this.#onCancel();
86
+ return;
87
+ }
88
+ this.#goBack();
89
+ return;
90
+ }
91
+
92
+ if (this.#input) {
93
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
94
+ this.#saveInputAndProceed();
95
+ return;
96
+ }
97
+ this.#input.handleInput(keyData);
98
+ return;
99
+ }
100
+
101
+ if (matchesKey(keyData, "up")) {
102
+ this.#moveSelection(-1);
103
+ return;
104
+ }
105
+ if (matchesKey(keyData, "down")) {
106
+ this.#moveSelection(1);
107
+ return;
108
+ }
109
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
110
+ this.#selectCurrentOption();
111
+ }
112
+ }
113
+
114
+ #renderStep(): void {
115
+ this.#contentContainer.clear();
116
+ this.#input = null;
117
+ switch (this.#step) {
118
+ case "compatibility":
119
+ this.#renderCompatibilityStep();
120
+ break;
121
+ case "provider-id":
122
+ this.#renderInputStep(
123
+ "Step 2: Provider id",
124
+ "Enter a provider id:",
125
+ this.#state.providerId,
126
+ "e.g. my-openai-proxy",
127
+ );
128
+ break;
129
+ case "base-url":
130
+ this.#renderInputStep(
131
+ "Step 3: Base URL",
132
+ "Enter the API base URL:",
133
+ this.#state.baseUrl,
134
+ "e.g. https://api.example.com/v1",
135
+ );
136
+ break;
137
+ case "credential-source":
138
+ this.#renderCredentialSourceStep();
139
+ break;
140
+ case "credential":
141
+ this.#renderInputStep(
142
+ "Step 5: Credential",
143
+ this.#state.credentialSource === "env"
144
+ ? "Enter the API key environment variable name:"
145
+ : "Paste the API key:",
146
+ this.#state.credential,
147
+ this.#state.credentialSource === "env"
148
+ ? "e.g. OPENAI_API_KEY"
149
+ : "The key will be stored in models.yml and redacted in output.",
150
+ );
151
+ break;
152
+ case "models":
153
+ this.#renderInputStep(
154
+ "Step 6: Model id(s)",
155
+ "Enter model ids, comma-separated:",
156
+ this.#state.models,
157
+ "e.g. gpt-5, claude-sonnet-4-5",
158
+ );
159
+ break;
160
+ case "confirm":
161
+ this.#renderConfirmStep(false);
162
+ break;
163
+ case "force-confirm":
164
+ this.#renderConfirmStep(true);
165
+ break;
166
+ }
167
+ }
168
+
169
+ #renderCompatibilityStep(): void {
170
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 1: Compatibility")));
171
+ this.#contentContainer.addChild(new Spacer(1));
172
+ const options: Array<{ value: ProviderCompatibility; label: string }> = [
173
+ { value: "openai", label: "OpenAI-compatible" },
174
+ { value: "anthropic", label: "Anthropic-compatible" },
175
+ ];
176
+ for (let i = 0; i < options.length; i++) this.#addOption(i, options[i]?.label ?? "");
177
+ this.#addHelp("[↑↓ to navigate, Enter to select, Esc to cancel]");
178
+ }
179
+
180
+ #renderCredentialSourceStep(): void {
181
+ this.#contentContainer.addChild(new Text(theme.fg("accent", "Step 4: Credential source")));
182
+ this.#contentContainer.addChild(new Spacer(1));
183
+ this.#addOption(0, "Environment variable");
184
+ this.#addOption(1, "Paste API key");
185
+ this.#addHelp("[↑↓ to navigate, Enter to select, Esc to go back]");
186
+ }
187
+
188
+ #renderInputStep(title: string, prompt: string, value: string, hint: string): void {
189
+ this.#contentContainer.addChild(new Text(theme.fg("accent", title)));
190
+ this.#contentContainer.addChild(new Spacer(1));
191
+ this.#contentContainer.addChild(new Text(prompt, 0, 0));
192
+ this.#contentContainer.addChild(new Spacer(1));
193
+ this.#input = new Input();
194
+ this.#input.setValue(value);
195
+ this.#contentContainer.addChild(this.#input);
196
+ this.#contentContainer.addChild(new Spacer(1));
197
+ this.#addHelp(hint);
198
+ this.#addHelp("[Enter to continue, Esc to go back]");
199
+ }
200
+
201
+ #renderConfirmStep(force: boolean): void {
202
+ this.#contentContainer.addChild(
203
+ new Text(theme.fg("accent", force ? "Provider exists — replace it?" : "Confirm custom provider")),
204
+ );
205
+ this.#contentContainer.addChild(new Spacer(1));
206
+ if (this.#lastSubmitError) {
207
+ this.#contentContainer.addChild(new Text(theme.fg(force ? "warning" : "error", this.#lastSubmitError), 0, 0));
208
+ this.#contentContainer.addChild(new Spacer(1));
209
+ }
210
+ this.#contentContainer.addChild(new Text(`Compatibility: ${this.#state.compatibility}`, 0, 0));
211
+ this.#contentContainer.addChild(new Text(`Provider: ${this.#state.providerId}`, 0, 0));
212
+ this.#contentContainer.addChild(new Text(`Base URL: ${this.#state.baseUrl}`, 0, 0));
213
+ this.#contentContainer.addChild(
214
+ new Text(
215
+ `Credential: ${this.#state.credentialSource === "env" ? this.#state.credential : "pasted API key"}`,
216
+ 0,
217
+ 0,
218
+ ),
219
+ );
220
+ this.#contentContainer.addChild(new Text(`Models: ${this.#state.models}`, 0, 0));
221
+ this.#contentContainer.addChild(new Spacer(1));
222
+ this.#addOption(0, force ? "Replace existing provider" : "Add provider");
223
+ this.#addOption(1, "Go back");
224
+ this.#addHelp("[↑↓ to navigate, Enter to select, Esc to go back]");
225
+ }
226
+
227
+ #addOption(index: number, label: string): void {
228
+ const selected = index === this.#selectedIndex;
229
+ const prefix = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
230
+ this.#contentContainer.addChild(new Text(`${prefix}${selected ? theme.fg("accent", label) : label}`, 0, 0));
231
+ }
232
+
233
+ #addHelp(text: string): void {
234
+ this.#contentContainer.addChild(new Text(theme.fg("muted", text), 0, 0));
235
+ }
236
+
237
+ #saveInputAndProceed(): void {
238
+ const value = this.#input?.getValue().trim() ?? "";
239
+ if (!value) return;
240
+ if (this.#step === "provider-id") {
241
+ this.#state.providerId = value;
242
+ this.#step = "base-url";
243
+ } else if (this.#step === "base-url") {
244
+ this.#state.baseUrl = value;
245
+ this.#step = "credential-source";
246
+ this.#selectedIndex = 0;
247
+ } else if (this.#step === "credential") {
248
+ this.#state.credential = value;
249
+ this.#step = "models";
250
+ } else if (this.#step === "models") {
251
+ this.#state.models = value;
252
+ this.#step = "confirm";
253
+ this.#selectedIndex = 0;
254
+ this.#lastSubmitError = null;
255
+ }
256
+ this.#renderStep();
257
+ this.#onRender();
258
+ }
259
+
260
+ #selectCurrentOption(): void {
261
+ if (this.#step === "compatibility") {
262
+ this.#state.compatibility = this.#selectedIndex === 0 ? "openai" : "anthropic";
263
+ this.#step = "provider-id";
264
+ } else if (this.#step === "credential-source") {
265
+ this.#state.credentialSource = this.#selectedIndex === 0 ? "env" : "literal";
266
+ this.#state.credential = "";
267
+ this.#step = "credential";
268
+ } else if (this.#step === "confirm" || this.#step === "force-confirm") {
269
+ if (this.#selectedIndex === 0) {
270
+ this.#onSubmit(this.#buildInput(this.#step === "force-confirm"));
271
+ return;
272
+ }
273
+ this.#step = "models";
274
+ }
275
+ this.#renderStep();
276
+ this.#onRender();
277
+ }
278
+
279
+ #buildInput(force: boolean): CustomProviderWizardSubmit {
280
+ return {
281
+ compatibility: this.#state.compatibility,
282
+ providerId: this.#state.providerId,
283
+ baseUrl: this.#state.baseUrl,
284
+ apiKeyEnv: this.#state.credentialSource === "env" ? this.#state.credential : undefined,
285
+ apiKey: this.#state.credentialSource === "literal" ? this.#state.credential : undefined,
286
+ models: this.#state.models
287
+ .split(",")
288
+ .map(model => model.trim())
289
+ .filter(Boolean),
290
+ force,
291
+ };
292
+ }
293
+
294
+ #moveSelection(delta: number): void {
295
+ const maxIndex =
296
+ this.#step === "confirm" ||
297
+ this.#step === "force-confirm" ||
298
+ this.#step === "compatibility" ||
299
+ this.#step === "credential-source"
300
+ ? 1
301
+ : 0;
302
+ this.#selectedIndex = (this.#selectedIndex + delta + maxIndex + 1) % (maxIndex + 1);
303
+ this.#renderStep();
304
+ this.#onRender();
305
+ }
306
+
307
+ #goBack(): void {
308
+ if (this.#step === "provider-id") this.#step = "compatibility";
309
+ else if (this.#step === "base-url") this.#step = "provider-id";
310
+ else if (this.#step === "credential-source") this.#step = "base-url";
311
+ else if (this.#step === "credential") this.#step = "credential-source";
312
+ else if (this.#step === "models") this.#step = "credential";
313
+ else if (this.#step === "confirm" || this.#step === "force-confirm") this.#step = "models";
314
+ this.#selectedIndex = 0;
315
+ this.#renderStep();
316
+ this.#onRender();
317
+ }
318
+ }