@gajae-code/coding-agent 0.11.4 → 0.11.5

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 (146) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/cli/notify-cli.d.ts +5 -1
  3. package/dist/types/config/settings-schema.d.ts +20 -0
  4. package/dist/types/export/html/template.generated.d.ts +1 -1
  5. package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
  6. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
  7. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
  8. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
  9. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
  10. package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  12. package/dist/types/harness-control-plane/owner.d.ts +3 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -0
  14. package/dist/types/main.d.ts +2 -2
  15. package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
  16. package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
  17. package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
  20. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
  21. package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
  23. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
  24. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
  25. package/dist/types/plan-mode/approved-plan.d.ts +1 -0
  26. package/dist/types/runtime-mcp/manager.d.ts +2 -0
  27. package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
  28. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
  29. package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
  30. package/dist/types/sdk/bus/config-commands.d.ts +8 -0
  31. package/dist/types/sdk/bus/config.d.ts +22 -0
  32. package/dist/types/sdk/bus/index.d.ts +162 -2
  33. package/dist/types/sdk/bus/notification-service.d.ts +71 -8
  34. package/dist/types/sdk/bus/session-control.d.ts +11 -8
  35. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
  36. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
  37. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
  38. package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
  39. package/dist/types/sdk/session.d.ts +16 -1
  40. package/dist/types/secrets/index.d.ts +4 -2
  41. package/dist/types/secrets/obfuscator.d.ts +1 -1
  42. package/dist/types/session/agent-session.d.ts +31 -2
  43. package/dist/types/session/artifacts.d.ts +30 -8
  44. package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
  45. package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
  46. package/dist/types/session/session-manager.d.ts +88 -18
  47. package/dist/types/session/session-storage.d.ts +27 -0
  48. package/dist/types/session/streaming-output.d.ts +4 -0
  49. package/dist/types/task/executor.d.ts +9 -0
  50. package/dist/types/tool-discovery/tool-index.d.ts +2 -0
  51. package/dist/types/tools/ask.d.ts +48 -0
  52. package/dist/types/tools/bash-skill-urls.d.ts +2 -2
  53. package/dist/types/tools/index.d.ts +4 -0
  54. package/package.json +9 -9
  55. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  56. package/src/cli/notify-cli.ts +40 -18
  57. package/src/commands/sdk.ts +7 -2
  58. package/src/config/settings-schema.ts +17 -0
  59. package/src/daemon/runtime.ts +2 -2
  60. package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
  61. package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
  62. package/src/defaults/gjc/skills/team/SKILL.md +7 -9
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
  64. package/src/eval/py/index.ts +2 -2
  65. package/src/export/html/template.generated.ts +1 -1
  66. package/src/extensibility/plugins/marketplace/manager.ts +94 -10
  67. package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
  68. package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
  69. package/src/gjc-runtime/deep-interview-state.ts +127 -3
  70. package/src/gjc-runtime/launch-tmux.ts +61 -6
  71. package/src/gjc-runtime/managed-owner-admission.ts +352 -0
  72. package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
  73. package/src/gjc-runtime/state-runtime.ts +38 -12
  74. package/src/gjc-runtime/team-runtime.ts +1 -1
  75. package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
  76. package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
  77. package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
  78. package/src/harness-control-plane/owner.ts +59 -3
  79. package/src/hindsight/client.ts +65 -2
  80. package/src/internal-urls/agent-protocol.ts +76 -5
  81. package/src/internal-urls/docs-index.generated.ts +7 -6
  82. package/src/internal-urls/local-protocol.ts +416 -18
  83. package/src/lsp/index.ts +4 -88
  84. package/src/main.ts +37 -13
  85. package/src/modes/acp/acp-agent.ts +8 -0
  86. package/src/modes/components/ansi-display-validator.ts +54 -0
  87. package/src/modes/components/notifications-settings-editor.ts +26 -2
  88. package/src/modes/components/session-observer-overlay.ts +40 -24
  89. package/src/modes/components/settings-selector.ts +3 -1
  90. package/src/modes/components/tool-transcript-format.ts +321 -0
  91. package/src/modes/components/transcript-viewer-overlay.ts +39 -17
  92. package/src/modes/controllers/goal-mode-controller.ts +20 -1
  93. package/src/modes/controllers/plan-mode-controller.ts +2 -0
  94. package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
  95. package/src/modes/controllers/selector-controller.ts +70 -8
  96. package/src/modes/interactive-mode.ts +1 -0
  97. package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
  98. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
  99. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
  100. package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
  101. package/src/plan-mode/approved-plan.ts +3 -1
  102. package/src/prompts/agents/architect.md +1 -0
  103. package/src/prompts/agents/critic.md +1 -0
  104. package/src/prompts/system/custom-system-prompt.md +1 -1
  105. package/src/prompts/system/system-prompt.md +2 -1
  106. package/src/prompts/tools/lsp.md +3 -3
  107. package/src/runtime-mcp/manager.ts +16 -1
  108. package/src/runtime-mcp/oauth-discovery.ts +116 -26
  109. package/src/sdk/broker/session-index.ts +122 -64
  110. package/src/sdk/bus/chat-daemon-cli.ts +72 -10
  111. package/src/sdk/bus/chat-daemon-control.ts +526 -130
  112. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  113. package/src/sdk/bus/config-commands.ts +22 -0
  114. package/src/sdk/bus/config.ts +37 -0
  115. package/src/sdk/bus/index.ts +384 -92
  116. package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
  117. package/src/sdk/bus/notification-service.ts +284 -62
  118. package/src/sdk/bus/recent-activity.ts +14 -5
  119. package/src/sdk/bus/session-control.ts +150 -44
  120. package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
  121. package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
  122. package/src/sdk/bus/telegram-daemon-control.ts +294 -76
  123. package/src/sdk/bus/telegram-daemon.ts +1788 -267
  124. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  125. package/src/sdk/session.ts +155 -27
  126. package/src/secrets/index.ts +53 -10
  127. package/src/secrets/obfuscator.ts +21 -18
  128. package/src/secrets/regex.ts +2 -0
  129. package/src/session/agent-session.ts +495 -253
  130. package/src/session/artifacts.ts +188 -23
  131. package/src/session/context-estimation.ts +7 -2
  132. package/src/session/internal/managed-session-scope.ts +200 -38
  133. package/src/session/internal/managed-session-storage.ts +1152 -46
  134. package/src/session/session-manager.ts +1055 -148
  135. package/src/session/session-storage.ts +109 -11
  136. package/src/session/streaming-output.ts +6 -0
  137. package/src/task/executor.ts +71 -26
  138. package/src/task/index.ts +63 -20
  139. package/src/tool-discovery/tool-index.ts +18 -0
  140. package/src/tools/ask.ts +129 -32
  141. package/src/tools/bash-skill-urls.ts +14 -3
  142. package/src/tools/bash.ts +2 -2
  143. package/src/tools/index.ts +4 -0
  144. package/src/tools/plan-mode-guard.ts +1 -0
  145. package/src/tools/sqlite-reader.ts +3 -2
  146. package/src/tools/subagent.ts +2 -0
@@ -2809,6 +2809,17 @@
2809
2809
  "testIds": "not_applicable"
2810
2810
  }
2811
2811
  },
2812
+ {
2813
+ "sourceId": "agent_session:promoteRecoveryHydrationAfterOwnershipReadyFence",
2814
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
2815
+ "sourceKind": "agent_session",
2816
+ "decision": "exclude",
2817
+ "rationale": "internal owner-recovery authority transition after a durable writer fence, never a user-facing SDK operation",
2818
+ "exclusionMetadata": {
2819
+ "adapterMappings": "not_applicable",
2820
+ "testIds": "not_applicable"
2821
+ }
2822
+ },
2812
2823
  {
2813
2824
  "sourceId": "agent_session:continuePersistedHistory",
2814
2825
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -10,6 +10,7 @@ import {
10
10
  ThinkingLevel,
11
11
  } from "@gajae-code/agent-core";
12
12
  import {
13
+ type AssistantMessage,
13
14
  type AuthCredentialSelector,
14
15
  type CredentialDisabledEvent,
15
16
  type Message,
@@ -17,6 +18,7 @@ import {
17
18
  type ProviderSessionState,
18
19
  type SimpleStreamOptions,
19
20
  streamSimple,
21
+ type ToolResultMessage,
20
22
  } from "@gajae-code/ai";
21
23
  import {
22
24
  getOpenAICodexTransportDetails,
@@ -88,7 +90,7 @@ import { loadActiveSubskillTools } from "../extensibility/gjc-plugins/tools";
88
90
  import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
89
91
  import type { FileSlashCommand } from "../extensibility/slash-commands";
90
92
  import type { HindsightSessionState } from "../hindsight/state";
91
- import { LocalProtocolHandler, type LocalProtocolOptions } from "../internal-urls";
93
+ import { initializeLocalRoot, LocalProtocolHandler, type LocalProtocolOptions } from "../internal-urls";
92
94
  import { resolveMemoryBackend } from "../memory-backend";
93
95
  import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" };
94
96
  import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
@@ -105,10 +107,11 @@ import { NotificationSessionController } from "../sdk/bus/session-control";
105
107
  import { shouldHostSdk } from "../sdk/host";
106
108
  import {
107
109
  collectEnvSecrets,
110
+ createSecretObfuscator,
108
111
  deobfuscateSessionContext,
109
112
  loadSecrets,
110
113
  obfuscateMessages,
111
- SecretObfuscator,
114
+ type SecretObfuscator,
112
115
  } from "../secrets";
113
116
  import { AgentSession, type ForkContextSeed } from "../session/agent-session";
114
117
  import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
@@ -126,7 +129,7 @@ import {
126
129
  } from "../system-prompt";
127
130
  import { AgentOutputManager } from "../task/output-manager";
128
131
  import { parseThinkingLevel, resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
129
- import { isMCPBridgeTool } from "../tool-discovery/tool-index";
132
+ import { isMCPBridgeTool, selectRestorableDiscoveredBuiltinToolNames } from "../tool-discovery/tool-index";
130
133
  import {
131
134
  applyConfiguredSearchTimeout,
132
135
  BashTool,
@@ -221,6 +224,57 @@ function buildAsyncResultBatchMessage(entries: AsyncResultEntry[]): CustomMessag
221
224
  };
222
225
  }
223
226
 
227
+ /**
228
+ * Reconcile a resumed transcript that ends on an unpaired tool call.
229
+ *
230
+ * When a subagent finishes by calling `yield` (or any turn is torn down right
231
+ * after a tool executes), the terminating abort can land before the tool
232
+ * result is persisted, leaving the saved session ending on an assistant
233
+ * `toolCall` with no matching `toolResult`. Replaying that history verbatim on
234
+ * resume produces an invalid provider request (a tool_use not followed by a
235
+ * tool_result) and the resumed turn fails immediately. Synthesize a placeholder
236
+ * result for any such trailing unpaired tool call so a resumed session always
237
+ * starts from a valid, paired history. No-op for well-formed transcripts.
238
+ */
239
+ export function reconcileTrailingToolCalls(messages: AgentMessage[]): AgentMessage[] {
240
+ let lastAssistantIdx = -1;
241
+ for (let i = messages.length - 1; i >= 0; i--) {
242
+ if (messages[i].role === "assistant") {
243
+ lastAssistantIdx = i;
244
+ break;
245
+ }
246
+ }
247
+ if (lastAssistantIdx === -1) return messages;
248
+ const lastAssistant = messages[lastAssistantIdx] as AssistantMessage;
249
+ const toolCalls = lastAssistant.content.filter(
250
+ (part): part is Extract<AssistantMessage["content"][number], { type: "toolCall" }> => part.type === "toolCall",
251
+ );
252
+ if (toolCalls.length === 0) return messages;
253
+ const satisfied = new Set<string>();
254
+ for (let i = lastAssistantIdx + 1; i < messages.length; i++) {
255
+ const message = messages[i];
256
+ if (message.role === "toolResult") satisfied.add(message.toolCallId);
257
+ }
258
+ const missing = toolCalls.filter(toolCall => !satisfied.has(toolCall.id));
259
+ if (missing.length === 0) return messages;
260
+ const now = Date.now();
261
+ const synthesized: ToolResultMessage[] = missing.map(toolCall => ({
262
+ role: "toolResult",
263
+ toolCallId: toolCall.id,
264
+ toolName: toolCall.name,
265
+ content: [
266
+ {
267
+ type: "text",
268
+ text: "Tool result was not persisted before the previous turn ended; synthesized on resume to keep tool_use/tool_result pairing valid.",
269
+ },
270
+ ],
271
+ details: {},
272
+ isError: false,
273
+ timestamp: now,
274
+ }));
275
+ return [...messages, ...synthesized];
276
+ }
277
+
224
278
  function buildMcpNotificationBatchMessage(entries: McpNotificationEntry[]): AgentMessage | null {
225
279
  const resources: McpNotificationEntry[] = [];
226
280
  const seen = new Set<string>();
@@ -392,6 +446,8 @@ export interface CreateAgentSessionOptions {
392
446
  notificationHostModeSupported?: boolean;
393
447
  /** Whether this host mode can own the root SDK endpoint. Default: true. */
394
448
  sdkHostModeSupported?: boolean;
449
+ /** Override configured Discord/Slack daemon readiness, primarily for embedded hosts and deterministic tests. */
450
+ ensureNotificationProviderDaemon?: (provider: "discord" | "slack", settings: Settings) => Promise<unknown>;
395
451
 
396
452
  /**
397
453
  * Opt-in OpenTelemetry instrumentation forwarded to the underlying Agent.
@@ -926,6 +982,7 @@ const MCP_TOOLS_ONLY_MANAGER_SUBSESSION_ERROR = "tools-only MCP managers cannot
926
982
  const MCP_CONFIG_PATH_SUBSESSION_ERROR = "mcpConfigPath cannot be used in sub-sessions";
927
983
  const MAX_EXACT_MCP_TOOL_COLLISION_NAMES = 10;
928
984
  const MAX_EXACT_MCP_TOOL_NAME_LENGTH = 100;
985
+ const pluginMcpManagerServers = new WeakMap<MCPManager, ReadonlySet<string>>();
929
986
 
930
987
  class ExactMcpToolNameCollisionError extends Error {
931
988
  constructor(toolNames: Iterable<string>) {
@@ -1114,8 +1171,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1114
1171
  const sessionManager =
1115
1172
  options.sessionManager ??
1116
1173
  (await logger.time("sessionManager", async () => {
1117
- const sessionDir = SessionManager.getDefaultSessionDir(cwd, agentDir);
1118
- return SessionManager.create(cwd, sessionDir);
1174
+ return SessionManager.create(cwd, SessionManager.managedDestination(cwd, agentDir));
1119
1175
  }));
1120
1176
  const logicalSessionId = sessionManager.getSessionId();
1121
1177
  const providerSessionId = options.providerSessionId ?? options.forkContextSeed?.cacheIdentity ?? logicalSessionId;
@@ -1166,7 +1222,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1166
1222
  const envEntries = collectEnvSecrets();
1167
1223
  const allEntries = [...envEntries, ...fileEntries];
1168
1224
  if (allEntries.length > 0) {
1169
- obfuscator = new SecretObfuscator(allEntries);
1225
+ obfuscator = createSecretObfuscator(allEntries);
1170
1226
  }
1171
1227
  }
1172
1228
  const secretsEnabled = obfuscator?.hasSecrets() === true;
@@ -1427,6 +1483,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1427
1483
  return sessionManager.getCwd();
1428
1484
  },
1429
1485
  hasUI: options.hasUI ?? false,
1486
+ workflowGateEligible: true,
1430
1487
  enableLsp,
1431
1488
  get hasEditTool() {
1432
1489
  const requestedToolNames = options.toolNames
@@ -1449,6 +1506,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1449
1506
  trackEvalExecution: (execution, abortController) =>
1450
1507
  session ? session.trackEvalExecution(execution, abortController) : execution,
1451
1508
  getSessionId: () => sessionManager.getSessionId?.() ?? null,
1509
+ isManagedSessionDestination: () => sessionManager.isManagedDestination(),
1452
1510
  getActiveSkillState: () => session?.getActiveSkillState(),
1453
1511
  getActiveSkillPhase: () => session?.getActiveSkillPhase(),
1454
1512
  getHindsightSessionState: () => session?.getHindsightSessionState(),
@@ -1538,6 +1596,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1538
1596
  if (asyncJobManager) AsyncJobManager.setInstance(asyncJobManager);
1539
1597
  }
1540
1598
  if (options.localProtocolOptions) {
1599
+ await initializeLocalRoot(options.localProtocolOptions);
1541
1600
  disposeLocalProtocolOverride = LocalProtocolHandler.installOverride(options.localProtocolOptions);
1542
1601
  }
1543
1602
  toolSession.getArtifactsDir = getArtifactsDir;
@@ -1559,6 +1618,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1559
1618
  const explicitMcpConfigPath = !isCanonicalSubSession && !options.mcpManager ? options.mcpConfigPath : undefined;
1560
1619
  const customTools: CustomTool[] = [];
1561
1620
  const exactMcpToolNames: string[] = [];
1621
+ const pluginMcpToolNames: string[] = [];
1562
1622
 
1563
1623
  // Add image tools when the active model or configured image providers can generate images.
1564
1624
  const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
@@ -1670,6 +1730,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1670
1730
  mcpManager = owned;
1671
1731
  ownsMcpManager = true;
1672
1732
  customTools.push(...(result.tools as CustomTool[]));
1733
+ pluginMcpManagerServers.set(owned, new Set(result.connectedServers));
1734
+ owned.sealConnectionSet();
1735
+ pluginMcpToolNames.push(...result.tools.map(tool => tool.name));
1673
1736
  } else {
1674
1737
  await owned.disconnectAll().catch(() => {});
1675
1738
  }
@@ -1694,8 +1757,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1694
1757
  try {
1695
1758
  const inheritedTools = inherited.getTools();
1696
1759
  if (inheritedTools.length > 0) customTools.push(...(inheritedTools as CustomTool[]));
1760
+ const pluginServers = pluginMcpManagerServers.get(inherited);
1761
+ if (pluginServers) {
1762
+ pluginMcpToolNames.push(
1763
+ ...inheritedTools
1764
+ .filter(tool => tool.mcpServerName !== undefined && pluginServers.has(tool.mcpServerName))
1765
+ .map(tool => tool.name),
1766
+ );
1767
+ }
1697
1768
  } catch (error) {
1698
- logger.warn("Failed to inherit plugin MCP tools in subagent", { error });
1769
+ logger.warn("Failed to inherit MCP tools in subagent", { error });
1699
1770
  }
1700
1771
  }
1701
1772
  }
@@ -1777,6 +1848,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1777
1848
  controller: notificationSessionController,
1778
1849
  spawnedByGjc,
1779
1850
  sdkHostModeSupported: options.sdkHostModeSupported,
1851
+ ensureProviderDaemon: options.ensureNotificationProviderDaemon,
1780
1852
  runEphemeralTurn: async (promptText, signal) => {
1781
1853
  if (!session) throw new Error("Ephemeral turns are unavailable.");
1782
1854
  const { replyText } = await session.runEphemeralTurn({ promptText, signal });
@@ -2121,15 +2193,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2121
2193
  };
2122
2194
 
2123
2195
  const toolNamesFromRegistry = Array.from(toolRegistry.keys());
2124
- const requestedToolNames = options.toolNames
2196
+ const hasExplicitToolNames = options.toolNames !== undefined;
2197
+ const requestedToolNames = hasExplicitToolNames
2125
2198
  ? [
2126
2199
  ...new Set([
2127
- ...options.toolNames.map(name => name.toLowerCase()),
2200
+ ...options.toolNames!.map(name => name.toLowerCase()),
2128
2201
  ...(settings.get("goal.enabled") ? ["goal"] : []),
2129
2202
  ]),
2130
2203
  ]
2131
2204
  : toolNamesFromRegistry;
2132
2205
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
2206
+ const explicitRequestedToolNames = hasExplicitToolNames ? normalizedRequested : [];
2133
2207
  const requestedToolNameSet = new Set(normalizedRequested);
2134
2208
  // Normalize the user-facing mcp.discoveryMode alias once at session construction.
2135
2209
  const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
@@ -2152,12 +2226,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2152
2226
  const discoveryDefaultServerToolNames: string[] = [];
2153
2227
  let initialSelectedMCPToolNames: string[] = [];
2154
2228
  let defaultSelectedMCPToolNames: string[] = [];
2229
+ let initialBaselineDiscoveredBuiltinToolNames: string[] = [];
2230
+ let initialSelectedDiscoveredBuiltinToolNames: string[] = [];
2231
+ let hasExplicitMCPToolSelection = false;
2232
+ let hasExplicitDiscoveredBuiltinToolSelection = false;
2233
+ let explicitlyRequestedDiscoveredBuiltinToolNames: string[] = [];
2155
2234
  if (mcpDiscoveryEnabled) {
2156
2235
  const defaultServerNames = new Set(settings.get("mcp.discoveryDefaultServers") ?? []);
2157
2236
  for (const tool of toolRegistry.values()) {
2158
2237
  if (!isMCPBridgeTool(tool)) continue;
2159
2238
  discoverableMCPToolNames.add(tool.name);
2160
- if (initialRequestedActiveToolNames.includes(tool.name)) {
2239
+ if (explicitRequestedToolNames.includes(tool.name)) {
2161
2240
  explicitlyRequestedMCPToolNames.push(tool.name);
2162
2241
  }
2163
2242
  const serverName = (tool as AgentTool & { mcpServerName?: string }).mcpServerName;
@@ -2166,21 +2245,34 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2166
2245
  }
2167
2246
  }
2168
2247
  }
2248
+ const mandatoryMCPToolNameSet = new Set(pluginMcpToolNames);
2249
+ const selectableExplicitMCPToolNames = explicitlyRequestedMCPToolNames.filter(
2250
+ name => !mandatoryMCPToolNameSet.has(name),
2251
+ );
2169
2252
  let initialToolNames = [...initialRequestedActiveToolNames];
2170
2253
  if (mcpDiscoveryEnabled) {
2171
- const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(name =>
2172
- toolRegistry.has(name),
2254
+ const restoredSelectedMCPToolNames = existingSession.selectedMCPToolNames.filter(
2255
+ name => toolRegistry.has(name) && !mandatoryMCPToolNameSet.has(name),
2173
2256
  );
2257
+ if (
2258
+ existingSession.hasPersistedMCPToolSelection &&
2259
+ restoredSelectedMCPToolNames.length !== existingSession.selectedMCPToolNames.length
2260
+ ) {
2261
+ sessionManager.appendMCPToolSelection(restoredSelectedMCPToolNames);
2262
+ }
2174
2263
  defaultSelectedMCPToolNames = [
2175
2264
  ...new Set([
2176
2265
  ...discoveryDefaultServerToolNames,
2177
- ...explicitlyRequestedMCPToolNames,
2178
2266
  ...(explicitMcpConfigPath !== undefined ? exactMcpToolNames : []),
2179
2267
  ]),
2180
2268
  ];
2269
+ hasExplicitMCPToolSelection =
2270
+ hasExplicitToolNames && (options.toolNames!.length === 0 || selectableExplicitMCPToolNames.length > 0);
2181
2271
  initialSelectedMCPToolNames = existingSession.hasPersistedMCPToolSelection
2182
2272
  ? restoredSelectedMCPToolNames
2183
- : [...new Set([...restoredSelectedMCPToolNames, ...defaultSelectedMCPToolNames])];
2273
+ : hasExplicitMCPToolSelection
2274
+ ? selectableExplicitMCPToolNames
2275
+ : defaultSelectedMCPToolNames;
2184
2276
  initialToolNames = [
2185
2277
  ...new Set([
2186
2278
  ...initialRequestedActiveToolNames.filter(name => !discoverableMCPToolNames.has(name)),
@@ -2189,13 +2281,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2189
2281
  ];
2190
2282
  }
2191
2283
 
2192
- // Custom tools and extension-registered tools are always included regardless of toolNames filter
2284
+ // Custom, extension-registered, and plugin-bundle MCP tools are always
2285
+ // included regardless of the caller's built-in tool filter. Plugin MCPs
2286
+ // remain always-on even when generic MCP discovery is disabled.
2193
2287
  const alwaysInclude: string[] = [
2194
2288
  ...(options.customTools?.map(t => (isCustomTool(t) ? t.name : t.name)) ?? []),
2195
2289
  ...registeredTools.filter(t => !t.definition.defaultInactive).map(t => t.definition.name),
2290
+ ...pluginMcpToolNames,
2196
2291
  ];
2292
+ const pluginMcpToolNameSet = new Set(pluginMcpToolNames);
2197
2293
  for (const name of alwaysInclude) {
2198
- if (mcpDiscoveryEnabled && discoverableMCPToolNames.has(name)) {
2294
+ if (mcpDiscoveryEnabled && discoverableMCPToolNames.has(name) && !pluginMcpToolNameSet.has(name)) {
2199
2295
  continue;
2200
2296
  }
2201
2297
  if (toolRegistry.has(name) && !initialToolNames.includes(name)) {
@@ -2208,19 +2304,39 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2208
2304
  // The model finds them via search_tool_bm25 and activates them on demand.
2209
2305
  if (effectiveDiscoveryMode === "all") {
2210
2306
  const essentialBuiltinNames = new Set(computeEssentialBuiltinNames(settings));
2211
- const explicitlyRequestedToolNames = new Set(options.toolNames?.map(name => name.toLowerCase()) ?? []);
2212
- // Back-compat: persisted activations live under selectedMCPToolNames today (built-in
2213
- // activation persistence is a follow-up). MCP names won't collide with built-in names.
2214
- const restoredDiscoveredNames = new Set(existingSession.selectedMCPToolNames);
2215
- initialToolNames = initialToolNames.filter(name => {
2307
+ const allowedDiscoveredBuiltinNames = options.discoverableToolAllowedNames
2308
+ ? new Set(options.discoverableToolAllowedNames.map(name => name.toLowerCase()))
2309
+ : undefined;
2310
+ const baselineInitialToolNames = initialToolNames.filter(name => {
2216
2311
  const tool = toolRegistry.get(name);
2217
2312
  if (!tool?.loadMode) return true; // not a built-in — leave MCP/custom/extension to existing logic
2218
2313
  if (tool.loadMode === "essential") return true;
2219
- if (essentialBuiltinNames.has(name)) return true;
2220
- if (explicitlyRequestedToolNames.has(name)) return true;
2221
- if (restoredDiscoveredNames.has(name)) return true;
2222
- return false;
2314
+ return essentialBuiltinNames.has(name);
2223
2315
  });
2316
+ explicitlyRequestedDiscoveredBuiltinToolNames = selectRestorableDiscoveredBuiltinToolNames(
2317
+ explicitRequestedToolNames,
2318
+ toolRegistry,
2319
+ allowedDiscoveredBuiltinNames,
2320
+ ).filter(name => !essentialBuiltinNames.has(name));
2321
+ const requestedDiscoveredBuiltinToolNameSet = new Set(explicitlyRequestedDiscoveredBuiltinToolNames);
2322
+ initialBaselineDiscoveredBuiltinToolNames = selectRestorableDiscoveredBuiltinToolNames(
2323
+ baselineInitialToolNames.filter(name => !requestedDiscoveredBuiltinToolNameSet.has(name)),
2324
+ toolRegistry,
2325
+ allowedDiscoveredBuiltinNames,
2326
+ );
2327
+ const restoredDiscoveredNames = selectRestorableDiscoveredBuiltinToolNames(
2328
+ existingSession.selectedDiscoveredBuiltinToolNames ?? [],
2329
+ toolRegistry,
2330
+ allowedDiscoveredBuiltinNames,
2331
+ essentialBuiltinNames,
2332
+ );
2333
+ initialSelectedDiscoveredBuiltinToolNames = existingSession.hasPersistedDiscoveredBuiltinToolSelection
2334
+ ? restoredDiscoveredNames
2335
+ : explicitlyRequestedDiscoveredBuiltinToolNames;
2336
+ initialToolNames = [...new Set([...baselineInitialToolNames, ...initialSelectedDiscoveredBuiltinToolNames])];
2337
+ hasExplicitDiscoveredBuiltinToolSelection =
2338
+ hasExplicitToolNames &&
2339
+ (options.toolNames!.length === 0 || explicitlyRequestedDiscoveredBuiltinToolNames.length > 0);
2224
2340
  }
2225
2341
 
2226
2342
  // Pre-register in the global agent registry BEFORE building the system prompt,
@@ -2456,7 +2572,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2456
2572
 
2457
2573
  // Restore messages if session has existing data
2458
2574
  if (hasExistingSession) {
2459
- agent.replaceMessages(existingSession.messages);
2575
+ agent.replaceMessages(reconcileTrailingToolCalls(existingSession.messages));
2460
2576
  } else {
2461
2577
  // Save initial model, thinking level, and service tier for new sessions so they can be restored on resume.
2462
2578
  if (model) {
@@ -2521,8 +2637,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2521
2637
  mcpDiscoveryEnabled,
2522
2638
  discoveryMode: effectiveDiscoveryMode,
2523
2639
  initialSelectedMCPToolNames,
2640
+ initialMCPToolSelectionIsExplicit: hasExplicitMCPToolSelection,
2641
+ initialDiscoveredBuiltinToolSelectionIsExplicit: hasExplicitDiscoveredBuiltinToolSelection,
2642
+ initialSelectedDiscoveredBuiltinToolNames,
2643
+ initialBaselineDiscoveredBuiltinToolNames,
2524
2644
  defaultSelectedMCPToolNames,
2525
- persistInitialMCPToolSelection: !hasExistingSession,
2645
+ mandatoryMCPToolNames: pluginMcpToolNames,
2646
+ persistInitialMCPToolSelection: !hasExistingSession && hasExplicitMCPToolSelection,
2647
+ persistInitialDiscoveredBuiltinToolSelection: !hasExistingSession && hasExplicitDiscoveredBuiltinToolSelection,
2648
+ initialPersistedMCPToolNames: selectableExplicitMCPToolNames,
2649
+ initialPersistedDiscoveredBuiltinToolNames: explicitlyRequestedDiscoveredBuiltinToolNames,
2526
2650
  defaultSelectedMCPServerNames: settings.get("mcp.discoveryDefaultServers") ?? [],
2527
2651
  ttsrManager,
2528
2652
  obfuscator,
@@ -2664,6 +2788,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2664
2788
  });
2665
2789
  }
2666
2790
 
2791
+ // Constructor-time workflow-gate tool restoration is deferred by one
2792
+ // microtask (the ToolSession closure needs `session` assigned). Await it
2793
+ // so a resumed canonical workflow session returns with `ask` resident.
2794
+ await session.workflowGateToolRestoration;
2667
2795
  return {
2668
2796
  session,
2669
2797
  extensionsResult,
@@ -1,31 +1,60 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
1
3
  import * as path from "node:path";
2
- import { isEnoent, logger } from "@gajae-code/utils";
4
+ import { isEnoent, logger, pathIsWithin } from "@gajae-code/utils";
3
5
  import { YAML } from "bun";
4
- import type { SecretEntry } from "./obfuscator";
6
+ import { type SecretEntry, SecretObfuscator } from "./obfuscator";
5
7
  import { compileSecretRegex } from "./regex";
6
8
 
9
+ const PROCESS_SECRET_OBFUSCATION_KEY = randomBytes(32);
10
+
11
+ /** Create an obfuscator using the process-local authenticated-placeholder key. */
12
+ export function createSecretObfuscator(entries: SecretEntry[]): SecretObfuscator {
13
+ return new SecretObfuscator(entries, PROCESS_SECRET_OBFUSCATION_KEY);
14
+ }
7
15
  export { deobfuscateSessionContext, obfuscateMessages, type SecretEntry, SecretObfuscator } from "./obfuscator";
8
16
 
17
+ type SecretsFileScope = "global" | "project";
18
+
9
19
  /**
10
20
  * Load secrets from project-local and global secrets.yml files.
11
- * Project-local entries override global entries with matching content.
21
+ * Project-local plain entries override global entries with matching content.
12
22
  */
13
23
  export async function loadSecrets(cwd: string, agentDir: string): Promise<SecretEntry[]> {
14
24
  const projectPath = path.join(cwd, ".gjc", "secrets.yml");
15
25
  const globalPath = path.join(agentDir, "secrets.yml");
26
+ const agentScope = await classifySecretsFileScope(cwd, agentDir);
16
27
 
17
- const globalEntries = await loadSecretsFile(globalPath);
18
- const projectEntries = await loadSecretsFile(projectPath);
28
+ const globalEntries = await loadSecretsFile(globalPath, agentScope);
29
+ const projectEntries = await loadSecretsFile(projectPath, "project");
19
30
 
20
31
  if (globalEntries.length === 0) return projectEntries;
21
32
  if (projectEntries.length === 0) return globalEntries;
22
33
 
23
- // Merge: project overrides global by content match
24
- const projectContents = new Set(projectEntries.map(e => e.content));
25
- const merged = [...globalEntries.filter(e => !projectContents.has(e.content)), ...projectEntries];
34
+ // Project plain entries override matching global plain entries, never global regex entries.
35
+ const projectPlainContents = new Set(projectEntries.filter(e => e.type === "plain").map(e => e.content));
36
+ const merged = [
37
+ ...globalEntries.filter(e => e.type === "regex" || !projectPlainContents.has(e.content)),
38
+ ...projectEntries,
39
+ ];
26
40
  return merged;
27
41
  }
28
42
 
43
+ function pathIsLexicallyWithin(root: string, candidate: string): boolean {
44
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
45
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
46
+ }
47
+
48
+ async function classifySecretsFileScope(cwd: string, agentDir: string): Promise<SecretsFileScope> {
49
+ if (pathIsLexicallyWithin(cwd, agentDir)) return "project";
50
+ try {
51
+ const [canonicalCwd, canonicalAgentDir] = await Promise.all([fs.realpath(cwd), fs.realpath(agentDir)]);
52
+ return pathIsWithin(canonicalCwd, canonicalAgentDir) ? "project" : "global";
53
+ } catch {
54
+ return "project";
55
+ }
56
+ }
57
+
29
58
  /** Minimum env var value length to consider as a secret. */
30
59
  const MIN_ENV_VALUE_LENGTH = 8;
31
60
 
@@ -46,7 +75,7 @@ export function collectEnvSecrets(): SecretEntry[] {
46
75
  return entries;
47
76
  }
48
77
 
49
- async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
78
+ async function loadSecretsFile(filePath: string, scope: SecretsFileScope): Promise<SecretEntry[]> {
50
79
  try {
51
80
  const text = await Bun.file(filePath).text();
52
81
  const raw = YAML.parse(text);
@@ -55,8 +84,13 @@ async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
55
84
  return [];
56
85
  }
57
86
  const entries: SecretEntry[] = [];
87
+ let skippedProjectRegexEntries = 0;
58
88
  for (let i = 0; i < raw.length; i++) {
59
89
  const entry = raw[i];
90
+ if (scope === "project" && isRegexEntry(entry)) {
91
+ skippedProjectRegexEntries++;
92
+ continue;
93
+ }
60
94
  if (!validateEntry(entry, filePath, i)) continue;
61
95
  entries.push({
62
96
  type: entry.type,
@@ -66,6 +100,12 @@ async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
66
100
  flags: entry.flags,
67
101
  });
68
102
  }
103
+ if (skippedProjectRegexEntries > 0) {
104
+ logger.warn("Project secrets.yml regex entries are ignored; regex entries are global-only", {
105
+ path: filePath,
106
+ skippedEntries: skippedProjectRegexEntries,
107
+ });
108
+ }
69
109
  return entries;
70
110
  } catch (err) {
71
111
  if (isEnoent(err)) return [];
@@ -74,6 +114,10 @@ async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
74
114
  }
75
115
  }
76
116
 
117
+ function isRegexEntry(entry: unknown): boolean {
118
+ return entry !== null && typeof entry === "object" && (entry as Record<string, unknown>).type === "regex";
119
+ }
120
+
77
121
  function validateEntry(entry: unknown, filePath: string, index: number): entry is SecretEntry {
78
122
  if (entry === null || typeof entry !== "object") {
79
123
  logger.warn(`secrets.yml[${index}]: entry must be an object`, { path: filePath });
@@ -106,7 +150,6 @@ function validateEntry(entry: unknown, filePath: string, index: number): entry i
106
150
  } catch (error) {
107
151
  logger.warn(`secrets.yml[${index}]: invalid regex pattern`, {
108
152
  path: filePath,
109
- pattern: e.content,
110
153
  error: String(error),
111
154
  });
112
155
  return false;
@@ -1,3 +1,4 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
1
2
  import type { Message, TextContent } from "@gajae-code/ai";
2
3
  import { type SessionContext, transferSessionMessageIdentity } from "../session/session-manager";
3
4
  import { compileSecretRegex } from "./regex";
@@ -39,28 +40,28 @@ function generateDeterministicReplacement(secret: string): string {
39
40
  // Placeholder format
40
41
  // ═══════════════════════════════════════════════════════════════════════════
41
42
 
42
- const HASH_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
43
- const HASH_LEN = 4;
44
-
45
- /** Build an obfuscation placeholder for secret index N. Deterministic `#HASH#` token. */
46
- function buildPlaceholder(index: number): string {
47
- let v = Bun.hash.xxHash32(String(index), 0x5345_4352);
48
- let tag = "#";
49
- for (let i = 0; i < HASH_LEN; i++) {
50
- tag += HASH_CHARS[v % HASH_CHARS.length];
51
- v = Math.floor(v / HASH_CHARS.length);
52
- }
53
- return `${tag}#`;
43
+ const PLACEHOLDER_DOMAIN = "gjc.secret-obfuscation.placeholder.v1\0";
44
+ const PLACEHOLDER_RE = /#GJC1_[A-Za-z0-9_-]{22}#/g;
45
+
46
+ /** Build a versioned, authenticated placeholder whose identity depends only on the key and secret. */
47
+ function buildPlaceholder(secret: string, key: Uint8Array): string {
48
+ const tag = createHmac("sha256", key)
49
+ .update(PLACEHOLDER_DOMAIN)
50
+ .update(secret, "utf8")
51
+ .digest()
52
+ .subarray(0, 16)
53
+ .toString("base64url");
54
+ return `#GJC1_${tag}#`;
54
55
  }
55
56
 
56
- /** Regex to match obfuscation placeholders: #HASH# */
57
- const PLACEHOLDER_RE = /#[A-Z0-9]{4}#/g;
58
-
59
57
  // ═══════════════════════════════════════════════════════════════════════════
60
58
  // SecretObfuscator
61
59
  // ═══════════════════════════════════════════════════════════════════════════
62
60
 
63
61
  export class SecretObfuscator {
62
+ /** Key used to authenticate reversible placeholders. */
63
+ #placeholderKey: Uint8Array;
64
+
64
65
  /** Plain secrets: secret → index (known at construction) */
65
66
  #plainMappings = new Map<string, number>();
66
67
 
@@ -97,14 +98,16 @@ export class SecretObfuscator {
97
98
  /** Whether any secrets were configured */
98
99
  #hasAny: boolean;
99
100
 
100
- constructor(entries: SecretEntry[]) {
101
+ constructor(entries: SecretEntry[], key: Uint8Array = randomBytes(32)) {
102
+ if (key.byteLength !== 32) throw new Error("Secret obfuscation key must be 32 bytes");
103
+ this.#placeholderKey = Uint8Array.from(key);
101
104
  let index = 0;
102
105
  for (const entry of entries) {
103
106
  const mode = entry.mode ?? "obfuscate";
104
107
 
105
108
  if (entry.type === "plain") {
106
109
  if (mode === "obfuscate") {
107
- const placeholder = buildPlaceholder(index);
110
+ const placeholder = buildPlaceholder(entry.content, this.#placeholderKey);
108
111
  this.#plainMappings.set(entry.content, index);
109
112
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
110
113
  this.#deobfuscateMap.set(placeholder, entry.content);
@@ -172,7 +175,7 @@ export class SecretObfuscator {
172
175
  let index = this.#findObfuscateIndex(matchValue);
173
176
  if (index === undefined) {
174
177
  index = this.#nextIndex++;
175
- const placeholder = buildPlaceholder(index);
178
+ const placeholder = buildPlaceholder(matchValue, this.#placeholderKey);
176
179
  this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
177
180
  this.#deobfuscateMap.set(placeholder, matchValue);
178
181
  this.#obfuscateIndexBySecret.set(matchValue, index);
@@ -16,6 +16,8 @@ export function compileSecretRegex(pattern: string, flags?: string): RegExp {
16
16
  const combined = new Set([...resolvedFlags, ...literalMatch[2]]);
17
17
  resolvedFlags = [...combined].join("");
18
18
  }
19
+ if (resolvedFlags.includes("y"))
20
+ throw new SyntaxError('The sticky "y" flag is incompatible with globally scanned secret regexes');
19
21
 
20
22
  return new RegExp(resolvedPattern, enforceGlobalFlag(resolvedFlags));
21
23
  }