@posthog/agent 2.3.647 → 2.3.655

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 (37) hide show
  1. package/dist/adapters/claude/permissions/permission-options.js +700 -0
  2. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  3. package/dist/adapters/claude/tools.js +700 -0
  4. package/dist/adapters/claude/tools.js.map +1 -1
  5. package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
  6. package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
  7. package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
  8. package/dist/agent.js +1488 -219
  9. package/dist/agent.js.map +1 -1
  10. package/dist/execution-mode.js +700 -0
  11. package/dist/execution-mode.js.map +1 -1
  12. package/dist/handoff-checkpoint.js.map +1 -1
  13. package/dist/posthog-api.js +1 -1
  14. package/dist/posthog-api.js.map +1 -1
  15. package/dist/server/agent-server.js +1604 -339
  16. package/dist/server/agent-server.js.map +1 -1
  17. package/dist/server/bin.cjs +1520 -258
  18. package/dist/server/bin.cjs.map +1 -1
  19. package/package.json +3 -3
  20. package/src/adapters/claude/claude-agent.ts +32 -2
  21. package/src/adapters/claude/hooks.test.ts +54 -0
  22. package/src/adapters/claude/hooks.ts +86 -0
  23. package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
  24. package/src/adapters/claude/mcp/local-tools.ts +40 -0
  25. package/src/adapters/claude/session/options.ts +14 -9
  26. package/src/adapters/claude/types.ts +1 -0
  27. package/src/adapters/codex/codex-agent.ts +117 -22
  28. package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
  29. package/src/adapters/local-tools/index.ts +22 -0
  30. package/src/adapters/local-tools/registry.test.ts +57 -0
  31. package/src/adapters/local-tools/registry.ts +81 -0
  32. package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
  33. package/src/adapters/session-meta.ts +16 -0
  34. package/src/adapters/signed-commit-shared.ts +82 -0
  35. package/src/server/agent-server.test.ts +2 -4
  36. package/src/server/agent-server.ts +27 -30
  37. package/src/utils/common.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.647",
3
+ "version": "2.3.655",
4
4
  "repository": "https://github.com/PostHog/code",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -107,8 +107,8 @@
107
107
  "typescript": "^5.5.0",
108
108
  "vitest": "^2.1.8",
109
109
  "@posthog/shared": "1.0.0",
110
- "@posthog/git": "1.0.0",
111
- "@posthog/enricher": "1.0.0"
110
+ "@posthog/enricher": "1.0.0",
111
+ "@posthog/git": "1.0.0"
112
112
  },
113
113
  "dependencies": {
114
114
  "@agentclientprotocol/sdk": "0.19.0",
@@ -57,10 +57,17 @@ import {
57
57
  type FileEnrichmentDeps,
58
58
  } from "../../enrichment/file-enricher";
59
59
  import type { PostHogAPIConfig } from "../../types";
60
- import { unreachable, withTimeout } from "../../utils/common";
60
+ import {
61
+ isCloudRun,
62
+ resolveGithubToken,
63
+ unreachable,
64
+ withTimeout,
65
+ } from "../../utils/common";
61
66
  import { Logger } from "../../utils/logger";
62
67
  import { Pushable } from "../../utils/streams";
63
68
  import { BaseAcpAgent } from "../base-acp-agent";
69
+ import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
70
+ import { resolveTaskId } from "../session-meta";
64
71
  import { promptToClaude } from "./conversion/acp-to-sdk";
65
72
  import {
66
73
  handleResultMessage,
@@ -69,6 +76,7 @@ import {
69
76
  handleUserAssistantMessage,
70
77
  } from "./conversion/sdk-to-acp";
71
78
  import type { EnrichedReadCache } from "./hooks";
79
+ import { createLocalToolsMcpServer } from "./mcp/local-tools";
72
80
  import {
73
81
  fetchMcpToolMetadata,
74
82
  getConnectedMcpServerNames,
@@ -1091,7 +1099,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1091
1099
  const isResume = !!resume;
1092
1100
 
1093
1101
  const meta = params._meta as NewSessionMeta | undefined;
1094
- const taskId = meta?.persistence?.taskId;
1102
+ const taskId = resolveTaskId(meta);
1103
+ // Gate signed-commit wiring on cloud-run detection so the desktop (which
1104
+ // signs via CommitSaga) is untouched.
1105
+ const cloudRun = isCloudRun(meta);
1095
1106
  const effort = meta?.claudeCode?.options?.effort as EffortLevel | undefined;
1096
1107
 
1097
1108
  // We want to create a new session id unless it is resume,
@@ -1115,6 +1126,24 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1115
1126
  const mcpServers = supportsMcpInjection(earlyModelId)
1116
1127
  ? parseMcpServers(params)
1117
1128
  : {};
1129
+
1130
+ // Register the in-process general local-tools MCP server. Tools self-gate
1131
+ // via the registry (e.g. signed-commit is cloud-only and needs a GH token),
1132
+ // so adding a tool needs no change here. In cloud runs `git commit`/`git
1133
+ // push` are blocked by the PreToolUse guard (and the sandbox git shim), so
1134
+ // the agent commits via the signed-commit tool instead.
1135
+ const localToolsServer = createLocalToolsMcpServer(
1136
+ { cwd, token: resolveGithubToken(), taskId },
1137
+ meta,
1138
+ );
1139
+ if (localToolsServer) {
1140
+ mcpServers[LOCAL_TOOLS_MCP_NAME] = localToolsServer;
1141
+ } else if (cloudRun) {
1142
+ this.logger.warn(
1143
+ "Cloud run registered no local tools — missing GH_TOKEN/GITHUB_TOKEN? signed commits unavailable",
1144
+ );
1145
+ }
1146
+
1118
1147
  const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
1119
1148
 
1120
1149
  if (meta?.mcpToolApprovals) {
@@ -1164,6 +1193,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
1164
1193
  effort,
1165
1194
  enrichmentDeps: this.enrichment?.deps,
1166
1195
  enrichedReadCache: this.enrichedReadCache,
1196
+ cloudMode: cloudRun,
1167
1197
  });
1168
1198
 
1169
1199
  // Use the same abort controller that buildSessionOptions gave to the query
@@ -11,6 +11,7 @@ import { Logger } from "../../utils/logger";
11
11
  import {
12
12
  createPreToolUseHook,
13
13
  createReadEnrichmentHook,
14
+ createSignedCommitGuardHook,
14
15
  type EnrichedReadCache,
15
16
  } from "./hooks";
16
17
  import type {
@@ -311,3 +312,56 @@ describe("createPreToolUseHook", () => {
311
312
  });
312
313
  });
313
314
  });
315
+
316
+ describe("createSignedCommitGuardHook", () => {
317
+ const logger = new Logger();
318
+
319
+ function bashInput(command: string): HookInput {
320
+ return {
321
+ session_id: "s",
322
+ transcript_path: "/tmp/t",
323
+ cwd: "/tmp",
324
+ hook_event_name: "PreToolUse",
325
+ tool_name: "Bash",
326
+ tool_use_id: "toolu_1",
327
+ tool_input: { command },
328
+ } as HookInput;
329
+ }
330
+
331
+ const guard = createSignedCommitGuardHook(logger);
332
+ const opts = { signal: new AbortController().signal };
333
+
334
+ test.each([
335
+ "git commit -m x",
336
+ "git push origin main",
337
+ "git add . && git commit -m 'y'",
338
+ "git -C /repo commit",
339
+ "git --no-pager push",
340
+ ])("denies %s", async (command) => {
341
+ const result = await guard(bashInput(command), undefined, opts);
342
+ expect(result).toMatchObject({
343
+ hookSpecificOutput: { permissionDecision: "deny" },
344
+ });
345
+ });
346
+
347
+ test.each([
348
+ "git status",
349
+ "git add .",
350
+ "git fetch origin",
351
+ "git log --grep=commit",
352
+ "git stash push",
353
+ "git ls-remote --heads origin x",
354
+ ])("allows %s", async (command) => {
355
+ const result = await guard(bashInput(command), undefined, opts);
356
+ expect(result).toEqual({ continue: true });
357
+ });
358
+
359
+ test("ignores non-Bash tools", async () => {
360
+ const result = await guard(
361
+ { ...bashInput("git commit"), tool_name: "Read" } as HookInput,
362
+ undefined,
363
+ opts,
364
+ );
365
+ expect(result).toEqual({ continue: true });
366
+ });
367
+ });
@@ -4,6 +4,7 @@ import {
4
4
  type FileEnrichmentDeps,
5
5
  } from "../../enrichment/file-enricher";
6
6
  import type { Logger } from "../../utils/logger";
7
+ import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../signed-commit-shared";
7
8
  import { stripCatLineNumbers } from "./conversion/sdk-to-acp";
8
9
  import {
9
10
  extractPostHogSubTool,
@@ -222,6 +223,91 @@ export const createSubagentRewriteHook =
222
223
  };
223
224
  };
224
225
 
226
+ // git global options that consume the following token as their value, so the
227
+ // subcommand detector must skip both (mirrors the sandbox `git` PATH shim).
228
+ const GIT_VALUE_FLAGS = new Set([
229
+ "-C",
230
+ "-c",
231
+ "--git-dir",
232
+ "--work-tree",
233
+ "--namespace",
234
+ "--exec-path",
235
+ ]);
236
+
237
+ function gitSubcommand(segment: string): string | null {
238
+ const tokens = segment.trim().split(/\s+/).filter(Boolean);
239
+ if (tokens.length === 0) return null;
240
+ // Strip a leading path so `/usr/bin/git` is still recognised as git.
241
+ const head = tokens[0].split("/").pop();
242
+ if (head !== "git") return null;
243
+
244
+ let skipNext = false;
245
+ for (const tok of tokens.slice(1)) {
246
+ if (skipNext) {
247
+ skipNext = false;
248
+ continue;
249
+ }
250
+ if (GIT_VALUE_FLAGS.has(tok)) {
251
+ skipNext = true;
252
+ continue;
253
+ }
254
+ if (tok.startsWith("-")) continue;
255
+ return tok;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * True when any top-level shell segment of `command` is a direct `git commit` /
262
+ * `git push` invocation (allowing `git`-level global flags like `-C path` or
263
+ * `--no-pager`). Does not match subcommands such as `git stash push` or
264
+ * `git log --grep=commit`. Git reached via command substitution (`$(git push)`)
265
+ * is not caught here — the sandbox `git` PATH shim is the authoritative backstop;
266
+ * this hook is a fast in-band deny with a helpful message.
267
+ */
268
+ function blocksUnsignedGit(command: string): boolean {
269
+ // Cheap reject for the overwhelmingly common non-git Bash call before splitting.
270
+ if (!command.includes("git")) return false;
271
+ return command.split(/&&|\|\||[;\n|]/).some((segment) => {
272
+ const sub = gitSubcommand(segment);
273
+ return sub === "commit" || sub === "push";
274
+ });
275
+ }
276
+
277
+ /**
278
+ * Cloud-only guard: blocks raw `git commit` / `git push` so unsigned commits
279
+ * cannot leave the sandbox. The agent must use the `git_signed_commit` tool,
280
+ * which creates GitHub-signed (Verified) commits via the API.
281
+ */
282
+ export const createSignedCommitGuardHook =
283
+ (logger: Logger): HookCallback =>
284
+ async (input: HookInput, _toolUseID: string | undefined) => {
285
+ if (input.hook_event_name !== "PreToolUse") return { continue: true };
286
+ if (input.tool_name !== "Bash") return { continue: true };
287
+
288
+ const command = (input.tool_input as { command?: string } | undefined)
289
+ ?.command;
290
+ if (!command || !blocksUnsignedGit(command)) {
291
+ return { continue: true };
292
+ }
293
+
294
+ logger.info(
295
+ `[SignedCommitGuard] Blocking unsigned git command: ${command}`,
296
+ );
297
+ return {
298
+ continue: true,
299
+ hookSpecificOutput: {
300
+ hookEventName: "PreToolUse" as const,
301
+ permissionDecision: "deny" as const,
302
+ permissionDecisionReason:
303
+ "Commits must be signed: `git commit` and `git push` are disabled here. " +
304
+ "Stage changes with `git add`, then call the `git_signed_commit` tool " +
305
+ `(${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}) with a \`message\` to create a signed ` +
306
+ "commit on the branch.",
307
+ },
308
+ };
309
+ };
310
+
225
311
  export const createPreToolUseHook =
226
312
  (settingsManager: SettingsManager, logger: Logger): HookCallback =>
227
313
  async (input: HookInput, _toolUseID: string | undefined) => {
@@ -0,0 +1,50 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { createLocalToolsMcpServer } from "./local-tools";
5
+
6
+ describe("createLocalToolsMcpServer", () => {
7
+ const savedSandbox = process.env.IS_SANDBOX;
8
+
9
+ beforeEach(() => {
10
+ // isCloudRun also keys off IS_SANDBOX; clear it so the meta arg is the only
11
+ // cloud signal under test.
12
+ delete process.env.IS_SANDBOX;
13
+ });
14
+
15
+ afterEach(() => {
16
+ if (savedSandbox === undefined) {
17
+ delete process.env.IS_SANDBOX;
18
+ } else {
19
+ process.env.IS_SANDBOX = savedSandbox;
20
+ }
21
+ });
22
+
23
+ it("returns undefined when no tool's gate passes (desktop run)", () => {
24
+ expect(
25
+ createLocalToolsMcpServer({ cwd: "/repo", token: "ghs_x" }, undefined),
26
+ ).toBeUndefined();
27
+ });
28
+
29
+ it("exposes git_signed_commit over MCP in a cloud run with a token", async () => {
30
+ const server = createLocalToolsMcpServer(
31
+ { cwd: "/repo", token: "ghs_x" },
32
+ { taskRunId: "run-1" },
33
+ );
34
+ if (!server) {
35
+ throw new Error("expected the local-tools server to be registered");
36
+ }
37
+ expect(server.name).toBe("posthog-local");
38
+
39
+ const [clientTransport, serverTransport] =
40
+ InMemoryTransport.createLinkedPair();
41
+ await server.instance.connect(serverTransport);
42
+ const client = new Client({ name: "test", version: "1.0.0" });
43
+ await client.connect(clientTransport);
44
+
45
+ const { tools } = await client.listTools();
46
+ expect(tools.map((t) => t.name)).toContain("git_signed_commit");
47
+
48
+ await client.close();
49
+ });
50
+ });
@@ -0,0 +1,40 @@
1
+ import {
2
+ createSdkMcpServer,
3
+ type McpSdkServerConfigWithInstance,
4
+ tool,
5
+ } from "@anthropic-ai/claude-agent-sdk";
6
+ import {
7
+ enabledLocalTools,
8
+ LOCAL_TOOLS_MCP_NAME,
9
+ type LocalToolCtx,
10
+ type LocalToolGateMeta,
11
+ } from "../../local-tools";
12
+
13
+ /**
14
+ * In-process SDK MCP server exposing the enabled local tools to the Claude
15
+ * adapter (see `../../local-tools` for the registry). Returns `undefined` when
16
+ * no tool's gate passes, so the caller can skip registering an empty server.
17
+ * Registered per session in `claude-agent.ts`.
18
+ */
19
+ export function createLocalToolsMcpServer(
20
+ ctx: LocalToolCtx,
21
+ meta: LocalToolGateMeta | undefined,
22
+ ): McpSdkServerConfigWithInstance | undefined {
23
+ const tools = enabledLocalTools(ctx, meta);
24
+ if (tools.length === 0) {
25
+ return undefined;
26
+ }
27
+ return createSdkMcpServer({
28
+ name: LOCAL_TOOLS_MCP_NAME,
29
+ version: "1.0.0",
30
+ tools: tools.map((t) =>
31
+ tool(
32
+ t.name,
33
+ t.description,
34
+ t.schema,
35
+ async (args) => t.handler(ctx, args),
36
+ { alwaysLoad: t.alwaysLoad ?? false },
37
+ ),
38
+ ),
39
+ });
40
+ }
@@ -17,6 +17,7 @@ import {
17
17
  createPostToolUseHook,
18
18
  createPreToolUseHook,
19
19
  createReadEnrichmentHook,
20
+ createSignedCommitGuardHook,
20
21
  createSubagentRewriteHook,
21
22
  type EnrichedReadCache,
22
23
  type OnModeChange,
@@ -55,6 +56,8 @@ export interface BuildOptionsParams {
55
56
  effort?: EffortLevel;
56
57
  enrichmentDeps?: FileEnrichmentDeps;
57
58
  enrichedReadCache?: EnrichedReadCache;
59
+ /** Cloud task session — enables the signed-commit guard. */
60
+ cloudMode?: boolean;
58
61
  }
59
62
 
60
63
  export function buildSystemPrompt(
@@ -129,6 +132,7 @@ function buildHooks(
129
132
  enrichmentDeps: FileEnrichmentDeps | undefined,
130
133
  enrichedReadCache: EnrichedReadCache | undefined,
131
134
  registeredAgents: ReadonlySet<string>,
135
+ cloudMode: boolean,
132
136
  ): Options["hooks"] {
133
137
  const postToolUseHooks = [createPostToolUseHook({ onModeChange })];
134
138
  if (enrichmentDeps && enrichedReadCache) {
@@ -137,21 +141,21 @@ function buildHooks(
137
141
  );
138
142
  }
139
143
 
144
+ const preToolUseHooks = [
145
+ createPreToolUseHook(settingsManager, logger),
146
+ createSubagentRewriteHook(logger, registeredAgents),
147
+ ];
148
+ if (cloudMode) {
149
+ preToolUseHooks.push(createSignedCommitGuardHook(logger));
150
+ }
151
+
140
152
  return {
141
153
  ...userHooks,
142
154
  PostToolUse: [
143
155
  ...(userHooks?.PostToolUse || []),
144
156
  { hooks: postToolUseHooks },
145
157
  ],
146
- PreToolUse: [
147
- ...(userHooks?.PreToolUse || []),
148
- {
149
- hooks: [
150
- createPreToolUseHook(settingsManager, logger),
151
- createSubagentRewriteHook(logger, registeredAgents),
152
- ],
153
- },
154
- ],
158
+ PreToolUse: [...(userHooks?.PreToolUse || []), { hooks: preToolUseHooks }],
155
159
  };
156
160
  }
157
161
 
@@ -352,6 +356,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
352
356
  params.enrichmentDeps,
353
357
  params.enrichedReadCache,
354
358
  registeredAgentNames,
359
+ params.cloudMode ?? false,
355
360
  ),
356
361
  outputFormat: params.outputFormat,
357
362
  abortController: getAbortController(
@@ -120,6 +120,7 @@ export type SDKMessageFilter = {
120
120
 
121
121
  export type NewSessionMeta = {
122
122
  taskRunId?: string;
123
+ taskId?: string;
123
124
  disableBuiltInTools?: boolean;
124
125
  systemPrompt?: unknown;
125
126
  sessionId?: string;
@@ -38,6 +38,7 @@ import {
38
38
  type SetSessionModeRequest,
39
39
  type SetSessionModeResponse,
40
40
  } from "@agentclientprotocol/sdk";
41
+ import { ghTokenEnv } from "@posthog/git/signed-commit";
41
42
  import packageJson from "../../../package.json" with { type: "json" };
42
43
  import {
43
44
  isMethod,
@@ -56,6 +57,7 @@ import {
56
57
  type PermissionMode,
57
58
  } from "../../execution-mode";
58
59
  import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../../types";
60
+ import { isCloudRun, resolveGithubToken } from "../../utils/common";
59
61
  import { Logger } from "../../utils/logger";
60
62
  import {
61
63
  nodeReadableToWebReadable,
@@ -63,6 +65,12 @@ import {
63
65
  } from "../../utils/streams";
64
66
  import { BaseAcpAgent, type BaseSession } from "../base-acp-agent";
65
67
  import { classifyAgentError } from "../error-classification";
68
+ import {
69
+ enabledLocalTools,
70
+ LOCAL_TOOLS_MCP_NAME,
71
+ type LocalToolCtx,
72
+ } from "../local-tools";
73
+ import { resolveTaskId } from "../session-meta";
66
74
  import { createCodexClient } from "./codex-client";
67
75
  import { normalizeCodexConfigOptions } from "./models";
68
76
  import {
@@ -193,8 +201,7 @@ const STRUCTURED_OUTPUT_INSTRUCTIONS = `\n\nWhen you have completed the task, ca
193
201
  * harness/bin.js, etc), `import.meta.dirname` sits at different depths. Walk
194
202
  * up until we find the script so each bundle locates the shared dist asset.
195
203
  */
196
- function resolveStructuredOutputMcpScript(): string {
197
- const rel = "adapters/codex/structured-output-mcp-server.js";
204
+ function resolveBundledMcpScript(rel: string): string {
198
205
  let dir = import.meta.dirname ?? __dirname;
199
206
  for (let i = 0; i < 5; i++) {
200
207
  const candidate = resolvePath(dir, rel);
@@ -209,7 +216,9 @@ function resolveStructuredOutputMcpScript(): string {
209
216
  function buildStructuredOutputMcpServer(
210
217
  jsonSchema: Record<string, unknown>,
211
218
  ): McpServerStdio {
212
- const scriptPath = resolveStructuredOutputMcpScript();
219
+ const scriptPath = resolveBundledMcpScript(
220
+ "adapters/codex/structured-output-mcp-server.js",
221
+ );
213
222
  const schemaBase64 = Buffer.from(JSON.stringify(jsonSchema)).toString(
214
223
  "base64",
215
224
  );
@@ -221,6 +230,41 @@ function buildStructuredOutputMcpServer(
221
230
  };
222
231
  }
223
232
 
233
+ /**
234
+ * Builds the stdio MCP server config exposing the enabled local tools. Context
235
+ * (cwd, taskId, token) and the enabled tool names are passed base64/CSV-encoded
236
+ * so the child registers the same tools the Claude adapter exposes in-process.
237
+ */
238
+ function buildLocalToolsMcpServer(
239
+ ctx: LocalToolCtx,
240
+ enabledNames: string[],
241
+ ): McpServerStdio {
242
+ const scriptPath = resolveBundledMcpScript(
243
+ "adapters/codex/local-tools-mcp-server.js",
244
+ );
245
+ const ctxBase64 = Buffer.from(JSON.stringify(ctx)).toString("base64");
246
+ const env = [
247
+ { name: "POSTHOG_LOCAL_TOOLS_CTX", value: ctxBase64 },
248
+ { name: "POSTHOG_LOCAL_TOOLS_ENABLED", value: enabledNames.join(",") },
249
+ ];
250
+ if (ctx.token) {
251
+ // Token also on the child env so its own git remote ops (fetch/ls-remote)
252
+ // authenticate; the var names come from the single shared source.
253
+ env.push(
254
+ ...Object.entries(ghTokenEnv(ctx.token)).map(([name, value]) => ({
255
+ name,
256
+ value,
257
+ })),
258
+ );
259
+ }
260
+ return {
261
+ name: LOCAL_TOOLS_MCP_NAME,
262
+ command: process.execPath,
263
+ args: [scriptPath],
264
+ env,
265
+ };
266
+ }
267
+
224
268
  export class CodexAcpAgent extends BaseAcpAgent {
225
269
  readonly adapterName = "codex";
226
270
  declare session: CodexSession;
@@ -338,7 +382,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
338
382
  const meta = params._meta as NewSessionMeta | undefined;
339
383
  const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
340
384
 
341
- const injectedParams = this.applyStructuredOutput(params, meta);
385
+ const injectedParams = this.applyLocalTools(
386
+ this.applyStructuredOutput(params, meta),
387
+ meta,
388
+ );
342
389
  const response = await this.codexConnection.newSession(injectedParams);
343
390
  response.configOptions = normalizeCodexConfigOptions(
344
391
  response.configOptions,
@@ -347,7 +394,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
347
394
  // Initialize session state
348
395
  this.sessionState = createSessionState(response.sessionId, params.cwd, {
349
396
  taskRunId: meta?.taskRunId,
350
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
397
+ taskId: resolveTaskId(meta),
351
398
  modeId: response.modes?.currentModeId ?? "auto",
352
399
  modelId: response.models?.currentModelId,
353
400
  permissionMode: requestedPermissionMode,
@@ -380,7 +427,10 @@ export class CodexAcpAgent extends BaseAcpAgent {
380
427
 
381
428
  async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
382
429
  const meta = params._meta as NewSessionMeta | undefined;
383
- const injectedParams = this.applyStructuredOutput(params, meta);
430
+ const injectedParams = this.applyLocalTools(
431
+ this.applyStructuredOutput(params, meta),
432
+ meta,
433
+ );
384
434
  const response = await this.codexConnection.loadSession(injectedParams);
385
435
  response.configOptions = normalizeCodexConfigOptions(
386
436
  response.configOptions,
@@ -396,7 +446,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
396
446
  // not, which silently broke task-completion tracking on re-attach.
397
447
  this.sessionState = createSessionState(params.sessionId, params.cwd, {
398
448
  taskRunId: meta?.taskRunId,
399
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
449
+ taskId: resolveTaskId(meta),
400
450
  modeId: response.modes?.currentModeId ?? "auto",
401
451
  permissionMode: currentPermissionMode,
402
452
  });
@@ -418,13 +468,16 @@ export class CodexAcpAgent extends BaseAcpAgent {
418
468
  params: ResumeSessionRequest,
419
469
  ): Promise<ResumeSessionResponse> {
420
470
  const meta = params._meta as NewSessionMeta | undefined;
421
- const injectedParams = this.applyStructuredOutput(
422
- {
423
- sessionId: params.sessionId,
424
- cwd: params.cwd,
425
- mcpServers: params.mcpServers ?? [],
426
- _meta: params._meta,
427
- },
471
+ const injectedParams = this.applyLocalTools(
472
+ this.applyStructuredOutput(
473
+ {
474
+ sessionId: params.sessionId,
475
+ cwd: params.cwd,
476
+ mcpServers: params.mcpServers ?? [],
477
+ _meta: params._meta,
478
+ },
479
+ meta,
480
+ ),
428
481
  meta,
429
482
  );
430
483
 
@@ -439,7 +492,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
439
492
  );
440
493
  this.sessionState = createSessionState(params.sessionId, params.cwd, {
441
494
  taskRunId: meta?.taskRunId,
442
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
495
+ taskId: resolveTaskId(meta),
443
496
  modeId: loadResponse.modes?.currentModeId ?? "auto",
444
497
  permissionMode: currentPermissionMode,
445
498
  });
@@ -465,12 +518,15 @@ export class CodexAcpAgent extends BaseAcpAgent {
465
518
  params: ForkSessionRequest,
466
519
  ): Promise<ForkSessionResponse> {
467
520
  const meta = params._meta as NewSessionMeta | undefined;
468
- const injectedParams = this.applyStructuredOutput(
469
- {
470
- cwd: params.cwd,
471
- mcpServers: params.mcpServers ?? [],
472
- _meta: params._meta,
473
- },
521
+ const injectedParams = this.applyLocalTools(
522
+ this.applyStructuredOutput(
523
+ {
524
+ cwd: params.cwd,
525
+ mcpServers: params.mcpServers ?? [],
526
+ _meta: params._meta,
527
+ },
528
+ meta,
529
+ ),
474
530
  meta,
475
531
  );
476
532
 
@@ -483,7 +539,7 @@ export class CodexAcpAgent extends BaseAcpAgent {
483
539
  const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
484
540
  this.sessionState = createSessionState(newResponse.sessionId, params.cwd, {
485
541
  taskRunId: meta?.taskRunId,
486
- taskId: meta?.taskId ?? meta?.persistence?.taskId,
542
+ taskId: resolveTaskId(meta),
487
543
  modeId: newResponse.modes?.currentModeId ?? "auto",
488
544
  permissionMode: requestedPermissionMode,
489
545
  });
@@ -531,6 +587,45 @@ export class CodexAcpAgent extends BaseAcpAgent {
531
587
  };
532
588
  }
533
589
 
590
+ /**
591
+ * Injects the stdio general local-tools MCP server. Tools self-gate via the
592
+ * registry (e.g. signed-commit is cloud-only and needs a GH token), so the
593
+ * server is only injected when at least one tool's gate passes. Their
594
+ * instructions already live in the shared cloud system prompt, so only the
595
+ * server needs injecting here.
596
+ */
597
+ private applyLocalTools<
598
+ T extends { cwd?: string; mcpServers?: McpServer[]; _meta?: unknown },
599
+ >(request: T, meta: NewSessionMeta | undefined): T {
600
+ const cwd = request.cwd;
601
+ if (!cwd) {
602
+ return request;
603
+ }
604
+ const ctx: LocalToolCtx = {
605
+ cwd,
606
+ token: resolveGithubToken(),
607
+ taskId: resolveTaskId(meta),
608
+ };
609
+ const tools = enabledLocalTools(ctx, meta);
610
+ if (tools.length === 0) {
611
+ if (isCloudRun(meta)) {
612
+ this.logger.warn(
613
+ "Cloud run registered no local tools — missing GH_TOKEN/GITHUB_TOKEN? signed commits unavailable",
614
+ );
615
+ }
616
+ return request;
617
+ }
618
+
619
+ const mcpServer = buildLocalToolsMcpServer(
620
+ ctx,
621
+ tools.map((t) => t.name),
622
+ );
623
+ return {
624
+ ...request,
625
+ mcpServers: [...(request.mcpServers ?? []), mcpServer],
626
+ };
627
+ }
628
+
534
629
  private async applyInitialPermissionMode(
535
630
  sessionId: string,
536
631
  permissionMode?: string,