@posthog/agent 2.3.256 → 2.3.261

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.
package/dist/types.d.ts CHANGED
@@ -84,6 +84,8 @@ interface TaskExecutionOptions {
84
84
  codexBinaryPath?: string;
85
85
  instructions?: string;
86
86
  processCallbacks?: ProcessSpawnedCallback;
87
+ /** Callback invoked when the agent calls the create_output tool for structured output */
88
+ onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
87
89
  }
88
90
  type LogLevel = "debug" | "info" | "warn" | "error";
89
91
  type OnLogCallback = (level: LogLevel, scope: string, message: string, data?: unknown) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.256",
3
+ "version": "2.3.261",
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": {
@@ -82,11 +82,12 @@
82
82
  "tsx": "^4.20.6",
83
83
  "typescript": "^5.5.0",
84
84
  "vitest": "^2.1.8",
85
- "@posthog/shared": "1.0.0",
86
- "@posthog/git": "1.0.0"
85
+ "@posthog/git": "1.0.0",
86
+ "@posthog/shared": "1.0.0"
87
87
  },
88
88
  "dependencies": {
89
89
  "@agentclientprotocol/sdk": "0.16.1",
90
+ "ajv": "^8.17.1",
90
91
  "@anthropic-ai/claude-agent-sdk": "0.2.76",
91
92
  "@anthropic-ai/sdk": "^0.78.0",
92
93
  "@hono/node-server": "^1.19.9",
@@ -24,6 +24,8 @@ export type AcpConnectionConfig = {
24
24
  processCallbacks?: ProcessSpawnedCallback;
25
25
  codexOptions?: CodexProcessOptions;
26
26
  allowedModelIds?: Set<string>;
27
+ /** Callback invoked when the agent calls the create_output tool for structured output */
28
+ onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
27
29
  };
28
30
 
29
31
  export type AcpConnection = {
@@ -97,7 +99,10 @@ function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
97
99
 
98
100
  let agent: ClaudeAcpAgent | null = null;
99
101
  const agentConnection = new AgentSideConnection((client) => {
100
- agent = new ClaudeAcpAgent(client, config.processCallbacks);
102
+ agent = new ClaudeAcpAgent(client, {
103
+ ...config.processCallbacks,
104
+ onStructuredOutput: config.onStructuredOutput,
105
+ });
101
106
  logger.info(`Created ${agent.adapterName} agent`);
102
107
  return agent;
103
108
  }, agentStream);
@@ -109,6 +109,7 @@ export interface ClaudeAcpAgentOptions {
109
109
  onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
110
110
  onProcessExited?: (pid: number) => void;
111
111
  onMcpServersReady?: (serverNames: string[]) => void;
112
+ onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
112
113
  }
113
114
 
114
115
  export class ClaudeAcpAgent extends BaseAcpAgent {
@@ -483,6 +484,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
483
484
  const result = handleResultMessage(message);
484
485
  if (result.error) throw result.error;
485
486
 
487
+ // Deliver structured output from SDK's native outputFormat
488
+ if (
489
+ message.subtype === "success" &&
490
+ message.structured_output != null &&
491
+ this.options?.onStructuredOutput
492
+ ) {
493
+ await this.options.onStructuredOutput(
494
+ message.structured_output as Record<string, unknown>,
495
+ );
496
+ }
497
+
486
498
  // For local-only commands, forward the result text to the client
487
499
  if (
488
500
  isLocalOnlyCommand &&
@@ -825,6 +837,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
825
837
  : {};
826
838
  const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
827
839
 
840
+ // Configure structured output via SDK's native outputFormat
841
+ const outputFormat =
842
+ meta?.jsonSchema && this.options?.onStructuredOutput
843
+ ? { type: "json_schema" as const, schema: meta.jsonSchema }
844
+ : undefined;
845
+
828
846
  this.logger.info(isResume ? "Resuming session" : "Creating new session", {
829
847
  sessionId,
830
848
  taskId,
@@ -854,6 +872,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
854
872
  ...(meta?.additionalRoots ?? []),
855
873
  ],
856
874
  disableBuiltInTools: meta?.disableBuiltInTools,
875
+ outputFormat,
857
876
  settingsManager,
858
877
  onModeChange: this.createOnModeChange(),
859
878
  onProcessSpawned: this.options?.onProcessSpawned,
@@ -6,6 +6,7 @@ import type {
6
6
  CanUseTool,
7
7
  McpServerConfig,
8
8
  Options,
9
+ OutputFormat,
9
10
  SpawnedProcess,
10
11
  SpawnOptions,
11
12
  } from "@anthropic-ai/claude-agent-sdk";
@@ -42,6 +43,7 @@ export interface BuildOptionsParams {
42
43
  forkSession?: boolean;
43
44
  additionalDirectories?: string[];
44
45
  disableBuiltInTools?: boolean;
46
+ outputFormat?: OutputFormat;
45
47
  settingsManager: SettingsManager;
46
48
  onModeChange?: OnModeChange;
47
49
  onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
@@ -268,6 +270,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
268
270
  params.settingsManager,
269
271
  params.logger,
270
272
  ),
273
+ outputFormat: params.outputFormat,
271
274
  abortController: getAbortController(
272
275
  params.userProvidedOptions?.abortController,
273
276
  ),
@@ -110,6 +110,7 @@ export type NewSessionMeta = {
110
110
  allowedDomains?: string[];
111
111
  /** Model ID to use for this session (e.g. "claude-sonnet-4-6") */
112
112
  model?: string;
113
+ jsonSchema?: Record<string, unknown> | null;
113
114
  claudeCode?: {
114
115
  options?: Options;
115
116
  };
package/src/agent.ts CHANGED
@@ -122,6 +122,7 @@ export class Agent {
122
122
  deviceType: "local",
123
123
  logger: this.logger,
124
124
  processCallbacks: options.processCallbacks,
125
+ onStructuredOutput: options.onStructuredOutput,
125
126
  allowedModelIds,
126
127
  codexOptions:
127
128
  options.adapter === "codex" && gatewayConfig
@@ -158,6 +158,20 @@ export class PostHogAPIClient {
158
158
  );
159
159
  }
160
160
 
161
+ async setTaskRunOutput(
162
+ taskId: string,
163
+ runId: string,
164
+ output: Record<string, unknown>,
165
+ ): Promise<TaskRun> {
166
+ return this.apiRequest(
167
+ `/api/projects/${this.getTeamId()}/tasks/${taskId}/runs/${runId}/set_output/`,
168
+ {
169
+ method: "PATCH",
170
+ body: JSON.stringify(output),
171
+ },
172
+ );
173
+ }
174
+
161
175
  async appendTaskRunLog(
162
176
  taskId: string,
163
177
  runId: string,
@@ -20,6 +20,7 @@ interface TestableServer {
20
20
  detectAndAttachPrUrl(payload: unknown, update: unknown): void;
21
21
  detectedPrUrl: string | null;
22
22
  buildCloudSystemPrompt(prUrl?: string | null): string;
23
+ buildDetectedPrContext(prUrl: string): string;
23
24
  }
24
25
 
25
26
  // The Claude Agent SDK has an internal readMessages() loop that rejects with
@@ -380,14 +381,17 @@ describe("AgentServer HTTP Mode", () => {
380
381
  });
381
382
 
382
383
  describe("buildCloudSystemPrompt", () => {
383
- it("returns PR-aware prompt when prUrl is provided", () => {
384
+ it("returns review-first prompt for existing PRs on non-Slack runs", () => {
384
385
  const s = createServer();
385
386
  const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(
386
387
  "https://github.com/org/repo/pull/1",
387
388
  );
388
- expect(prompt).toContain("Do NOT create a new branch");
389
+ expect(prompt).toContain("stop with local changes ready for review");
389
390
  expect(prompt).toContain("https://github.com/org/repo/pull/1");
390
- expect(prompt).toContain("gh pr checkout");
391
+ expect(prompt).toContain(
392
+ "Do NOT create new commits, push to the branch, or update the pull request unless the user explicitly asks.",
393
+ );
394
+ expect(prompt).not.toContain("gh pr checkout");
391
395
  expect(prompt).not.toContain("Create a draft pull request");
392
396
  expect(prompt).toContain("Generated-By: PostHog Code");
393
397
  expect(prompt).toContain("Task-Id: test-task-id");
@@ -396,12 +400,13 @@ describe("AgentServer HTTP Mode", () => {
396
400
  it("returns default prompt when no prUrl", () => {
397
401
  const s = createServer();
398
402
  const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
399
- expect(prompt).toContain("posthog-code/");
400
- expect(prompt).toContain("Create a draft pull request");
401
- expect(prompt).toContain("gh pr create --draft");
403
+ expect(prompt).toContain("stop with local changes ready for review");
404
+ expect(prompt).toContain(
405
+ "Do NOT create a branch, commit, push, or open a pull request unless the user explicitly asks.",
406
+ );
402
407
  expect(prompt).toContain("Generated-By: PostHog Code");
403
408
  expect(prompt).toContain("Task-Id: test-task-id");
404
- expect(prompt).toContain("Created with [PostHog Code]");
409
+ expect(prompt).not.toContain("gh pr create --draft");
405
410
  });
406
411
 
407
412
  it("returns default prompt when prUrl is null", () => {
@@ -409,12 +414,41 @@ describe("AgentServer HTTP Mode", () => {
409
414
  const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(
410
415
  null,
411
416
  );
417
+ expect(prompt).toContain("stop with local changes ready for review");
418
+ });
419
+
420
+ it("returns auto-PR prompt for Slack-origin runs", () => {
421
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
422
+ const s = createServer();
423
+ const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
412
424
  expect(prompt).toContain("posthog-code/");
413
425
  expect(prompt).toContain("Create a draft pull request");
414
426
  expect(prompt).toContain("gh pr create --draft");
427
+ expect(prompt).toContain("Generated-By: PostHog Code");
428
+ expect(prompt).toContain("Task-Id: test-task-id");
429
+ expect(prompt).toContain("Created with [PostHog Code]");
430
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
431
+ });
432
+
433
+ it("returns PR-update prompt for existing PRs on Slack-origin runs", () => {
434
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
435
+ const s = createServer();
436
+ const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(
437
+ "https://github.com/org/repo/pull/1",
438
+ );
439
+ expect(prompt).toContain(
440
+ "gh pr checkout https://github.com/org/repo/pull/1",
441
+ );
442
+ expect(prompt).toContain(
443
+ "Stage and commit all changes with a clear commit message",
444
+ );
445
+ expect(prompt).toContain("Push to the existing PR branch");
446
+ expect(prompt).not.toContain("Create a draft pull request");
447
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
415
448
  });
416
449
 
417
450
  it("includes --base flag when baseBranch is configured", () => {
451
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
418
452
  server = new AgentServer({
419
453
  port,
420
454
  jwtPublicKey: TEST_PUBLIC_KEY,
@@ -433,13 +467,112 @@ describe("AgentServer HTTP Mode", () => {
433
467
  expect(prompt).toContain(
434
468
  "gh pr create --draft --base add-yolo-to-readme",
435
469
  );
470
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
436
471
  });
437
472
 
438
473
  it("omits --base flag when baseBranch is not configured", () => {
474
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
439
475
  const s = createServer();
440
476
  const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt();
441
477
  expect(prompt).toContain("gh pr create --draft`");
442
478
  expect(prompt).not.toContain("--base");
479
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
480
+ });
481
+
482
+ it("disables auto-publish for Slack-origin runs when createPr is false", () => {
483
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
484
+ server = new AgentServer({
485
+ port,
486
+ jwtPublicKey: TEST_PUBLIC_KEY,
487
+ repositoryPath: repo.path,
488
+ apiUrl: "http://localhost:8000",
489
+ apiKey: "test-api-key",
490
+ projectId: 1,
491
+ mode: "interactive",
492
+ taskId: "test-task-id",
493
+ runId: "test-run-id",
494
+ createPr: false,
495
+ });
496
+ const prompt = (
497
+ server as unknown as TestableServer
498
+ ).buildCloudSystemPrompt();
499
+ expect(prompt).toContain("stop with local changes ready for review");
500
+ expect(prompt).not.toContain("gh pr create --draft");
501
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
502
+ });
503
+
504
+ it("disables auto-publish for existing PRs when createPr is false", () => {
505
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
506
+ server = new AgentServer({
507
+ port,
508
+ jwtPublicKey: TEST_PUBLIC_KEY,
509
+ repositoryPath: repo.path,
510
+ apiUrl: "http://localhost:8000",
511
+ apiKey: "test-api-key",
512
+ projectId: 1,
513
+ mode: "interactive",
514
+ taskId: "test-task-id",
515
+ runId: "test-run-id",
516
+ createPr: false,
517
+ });
518
+ const prompt = (
519
+ server as unknown as TestableServer
520
+ ).buildCloudSystemPrompt("https://github.com/org/repo/pull/1");
521
+ expect(prompt).toContain("stop with local changes ready for review");
522
+ expect(prompt).not.toContain("gh pr checkout");
523
+ expect(prompt).not.toContain("Push to the existing PR branch");
524
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
525
+ });
526
+ });
527
+
528
+ describe("buildDetectedPrContext", () => {
529
+ const prUrl = "https://github.com/org/repo/pull/1";
530
+
531
+ it("returns review-first PR context for non-Slack runs", () => {
532
+ const s = createServer();
533
+ const context = (s as unknown as TestableServer).buildDetectedPrContext(
534
+ prUrl,
535
+ );
536
+ expect(context).toContain("stop with local changes ready for review");
537
+ expect(context).toContain(
538
+ "Do NOT create commits, push to the PR branch, update the pull request",
539
+ );
540
+ expect(context).not.toContain("gh pr checkout");
541
+ });
542
+
543
+ it("returns auto-update PR context for Slack-origin runs", () => {
544
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
545
+ const s = createServer();
546
+ const context = (s as unknown as TestableServer).buildDetectedPrContext(
547
+ prUrl,
548
+ );
549
+ expect(context).toContain(`gh pr checkout ${prUrl}`);
550
+ expect(context).toContain(
551
+ "Make changes, commit, and push to that branch",
552
+ );
553
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
554
+ });
555
+
556
+ it("returns review-first PR context when createPr is false", () => {
557
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
558
+ server = new AgentServer({
559
+ port,
560
+ jwtPublicKey: TEST_PUBLIC_KEY,
561
+ repositoryPath: repo.path,
562
+ apiUrl: "http://localhost:8000",
563
+ apiKey: "test-api-key",
564
+ projectId: 1,
565
+ mode: "interactive",
566
+ taskId: "test-task-id",
567
+ runId: "test-run-id",
568
+ createPr: false,
569
+ });
570
+ const context = (
571
+ server as unknown as TestableServer
572
+ ).buildDetectedPrContext(prUrl);
573
+ expect(context).toContain("stop with local changes ready for review");
574
+ expect(context).not.toContain("gh pr checkout");
575
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
443
576
  });
444
577
  });
445
578
  });
@@ -1,4 +1,8 @@
1
- import type { ContentBlock } from "@agentclientprotocol/sdk";
1
+ import type {
2
+ ContentBlock,
3
+ RequestPermissionRequest,
4
+ RequestPermissionResponse,
5
+ } from "@agentclientprotocol/sdk";
2
6
  import {
3
7
  ClientSideConnection,
4
8
  ndJsonStream,
@@ -511,13 +515,9 @@ export class AgentServer {
511
515
  prompt,
512
516
  ...(this.detectedPrUrl && {
513
517
  _meta: {
514
- prContext:
515
- `IMPORTANT OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.\n` +
516
- `You already have an open pull request: ${this.detectedPrUrl}\n` +
517
- `You MUST:\n` +
518
- `1. Check out the existing PR branch with \`gh pr checkout ${this.detectedPrUrl}\`\n` +
519
- `2. Make changes, commit, and push to that branch\n` +
520
- `You MUST NOT create a new branch, close the existing PR, or create a new PR.`,
518
+ // Keep the live-session PR override aligned with the startup
519
+ // prompt policy so non-Slack runs remain review-first.
520
+ prContext: this.buildDetectedPrContext(this.detectedPrUrl),
521
521
  },
522
522
  }),
523
523
  });
@@ -665,6 +665,15 @@ export class AgentServer {
665
665
  taskId: payload.task_id,
666
666
  deviceType: deviceInfo.type,
667
667
  logWriter,
668
+ onStructuredOutput: async (output) => {
669
+ await this.posthogAPI.setTaskRunOutput(
670
+ payload.task_id,
671
+ payload.run_id,
672
+ {
673
+ output,
674
+ },
675
+ );
676
+ },
668
677
  });
669
678
 
670
679
  // Tap both streams to broadcast all ACP messages via SSE (mimics local transport)
@@ -700,18 +709,25 @@ export class AgentServer {
700
709
  clientCapabilities: {},
701
710
  });
702
711
 
703
- let preTaskRun: TaskRun | null = null;
704
- try {
705
- preTaskRun = await this.posthogAPI.getTaskRun(
706
- payload.task_id,
707
- payload.run_id,
708
- );
709
- } catch {
710
- this.logger.warn("Failed to fetch task run for session context", {
711
- taskId: payload.task_id,
712
- runId: payload.run_id,
713
- });
714
- }
712
+ const [preTaskRun, preTask] = await Promise.all([
713
+ this.posthogAPI
714
+ .getTaskRun(payload.task_id, payload.run_id)
715
+ .catch((err) => {
716
+ this.logger.warn("Failed to fetch task run for session context", {
717
+ taskId: payload.task_id,
718
+ runId: payload.run_id,
719
+ error: err,
720
+ });
721
+ return null;
722
+ }),
723
+ this.posthogAPI.getTask(payload.task_id).catch((err) => {
724
+ this.logger.warn("Failed to fetch task for session context", {
725
+ taskId: payload.task_id,
726
+ error: err,
727
+ });
728
+ return null;
729
+ }),
730
+ ]);
715
731
 
716
732
  const prUrl =
717
733
  typeof (preTaskRun?.state as Record<string, unknown>)
@@ -732,6 +748,7 @@ export class AgentServer {
732
748
  taskRunId: payload.run_id,
733
749
  systemPrompt: this.buildSessionSystemPrompt(prUrl),
734
750
  allowedDomains: this.config.allowedDomains,
751
+ jsonSchema: preTask?.json_schema ?? null,
735
752
  ...(this.config.claudeCode?.plugins?.length && {
736
753
  claudeCode: {
737
754
  options: {
@@ -1104,13 +1121,53 @@ export class AgentServer {
1104
1121
  return { append: cloudAppend };
1105
1122
  }
1106
1123
 
1124
+ private getCloudInteractionOrigin(): string | undefined {
1125
+ return (
1126
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
1127
+ process.env.CODE_INTERACTION_ORIGIN ??
1128
+ process.env.TWIG_INTERACTION_ORIGIN
1129
+ );
1130
+ }
1131
+
1132
+ /**
1133
+ * Slack-origin cloud runs auto-publish by default. Every other origin is
1134
+ * review-first unless the user explicitly asks, and createPr=false always
1135
+ * disables publishing.
1136
+ */
1137
+ private shouldAutoPublishCloudChanges(): boolean {
1138
+ return (
1139
+ this.getCloudInteractionOrigin() === "slack" &&
1140
+ this.config.createPr !== false
1141
+ );
1142
+ }
1143
+
1144
+ private buildDetectedPrContext(prUrl: string): string {
1145
+ if (!this.shouldAutoPublishCloudChanges()) {
1146
+ return (
1147
+ `An open pull request already exists: ${prUrl}\n` +
1148
+ `Use that PR as context if it is helpful, but stop with local changes ready for review.\n` +
1149
+ `Do NOT create commits, push to the PR branch, update the pull request, create a new branch, or create a new pull request unless the user explicitly asks.`
1150
+ );
1151
+ }
1152
+
1153
+ return (
1154
+ `IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.\n` +
1155
+ `You already have an open pull request: ${prUrl}\n` +
1156
+ `You MUST:\n` +
1157
+ `1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`\n` +
1158
+ `2. Make changes, commit, and push to that branch\n` +
1159
+ `You MUST NOT create a new branch, close the existing PR, or create a new PR.`
1160
+ );
1161
+ }
1162
+
1107
1163
  private buildCloudSystemPrompt(prUrl?: string | null): string {
1108
1164
  const taskId = this.config.taskId;
1165
+ const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges();
1109
1166
  const attributionInstructions = `
1110
1167
  ## Attribution
1111
1168
  Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines).
1112
1169
 
1113
- Instead, add the following trailers to EVERY commit message (after a blank line at the end):
1170
+ If you create a commit, add the following trailers to the commit message (after a blank line at the end):
1114
1171
  Generated-By: PostHog Code
1115
1172
  Task-Id: ${taskId}
1116
1173
 
@@ -1126,6 +1183,21 @@ EOF
1126
1183
  \`\`\``;
1127
1184
 
1128
1185
  if (prUrl) {
1186
+ if (!shouldAutoCreatePr) {
1187
+ return `
1188
+ # Cloud Task Execution
1189
+
1190
+ This task already has an open pull request: ${prUrl}
1191
+
1192
+ Do the requested work, but stop with local changes ready for review.
1193
+
1194
+ Important:
1195
+ - Do NOT create new commits, push to the branch, or update the pull request unless the user explicitly asks.
1196
+ - Do NOT create a new branch or a new pull request.
1197
+ ${attributionInstructions}
1198
+ `;
1199
+ }
1200
+
1129
1201
  return `
1130
1202
  # Cloud Task Execution
1131
1203
 
@@ -1163,6 +1235,18 @@ Important:
1163
1235
  `;
1164
1236
  }
1165
1237
 
1238
+ if (!shouldAutoCreatePr) {
1239
+ return `
1240
+ # Cloud Task Execution
1241
+
1242
+ Do the requested work, but stop with local changes ready for review.
1243
+
1244
+ Important:
1245
+ - Do NOT create a branch, commit, push, or open a pull request unless the user explicitly asks.
1246
+ ${attributionInstructions}
1247
+ `;
1248
+ }
1249
+
1166
1250
  return `
1167
1251
  # Cloud Task Execution
1168
1252
 
@@ -1287,19 +1371,64 @@ ${attributionInstructions}
1287
1371
  });
1288
1372
  }
1289
1373
 
1374
+ private buildSlackQuestionRelayResponse(
1375
+ payload: JwtPayload,
1376
+ toolMeta: Record<string, unknown> | null | undefined,
1377
+ ): RequestPermissionResponse {
1378
+ this.relaySlackQuestion(payload, toolMeta);
1379
+ return {
1380
+ outcome: { outcome: "cancelled" as const },
1381
+ _meta: {
1382
+ message:
1383
+ "This question has been relayed to the Slack thread where this task originated. " +
1384
+ "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
1385
+ "Simply let the user know you are waiting for their reply.",
1386
+ },
1387
+ };
1388
+ }
1389
+
1390
+ private shouldBlockPublishPermission(
1391
+ params: RequestPermissionRequest,
1392
+ ): boolean {
1393
+ if (this.config.createPr !== false) {
1394
+ return false;
1395
+ }
1396
+
1397
+ const meta =
1398
+ params.toolCall?._meta &&
1399
+ typeof params.toolCall._meta === "object" &&
1400
+ !Array.isArray(params.toolCall._meta)
1401
+ ? (params.toolCall._meta as Record<string, unknown>)
1402
+ : null;
1403
+ const rawInput =
1404
+ params.toolCall?.rawInput &&
1405
+ typeof params.toolCall.rawInput === "object" &&
1406
+ !Array.isArray(params.toolCall.rawInput)
1407
+ ? (params.toolCall.rawInput as Record<string, unknown>)
1408
+ : null;
1409
+ const toolName = typeof meta?.toolName === "string" ? meta.toolName : null;
1410
+ const command =
1411
+ typeof rawInput?.command === "string" ? rawInput.command : null;
1412
+
1413
+ return Boolean(
1414
+ toolName &&
1415
+ (toolName === "Bash" || toolName.includes("bash")) &&
1416
+ command &&
1417
+ /\bgit\s+push\b|\bgh\s+pr\s+(create|edit|ready|merge)\b/.test(command),
1418
+ );
1419
+ }
1420
+
1290
1421
  private createCloudClient(payload: JwtPayload) {
1291
1422
  const mode = this.getEffectiveMode(payload);
1292
1423
  const interactionOrigin =
1424
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
1293
1425
  process.env.CODE_INTERACTION_ORIGIN ??
1294
1426
  process.env.TWIG_INTERACTION_ORIGIN;
1295
1427
 
1296
1428
  return {
1297
- requestPermission: async (params: {
1298
- options: Array<{ kind: string; optionId: string; name?: string }>;
1299
- toolCall?: {
1300
- _meta?: Record<string, unknown> | null;
1301
- };
1302
- }) => {
1429
+ requestPermission: async (
1430
+ params: RequestPermissionRequest,
1431
+ ): Promise<RequestPermissionResponse> => {
1303
1432
  // Background mode: always auto-approve permissions
1304
1433
  // Interactive mode: also auto-approve for now (user can monitor via SSE)
1305
1434
  // Future: interactive mode could pause and wait for user approval via SSE
@@ -1318,19 +1447,23 @@ ${attributionInstructions}
1318
1447
  if (interactionOrigin === "slack") {
1319
1448
  const codeToolKind = params.toolCall?._meta?.codeToolKind;
1320
1449
  if (codeToolKind === "question") {
1321
- this.relaySlackQuestion(payload, params.toolCall?._meta);
1322
- return {
1323
- outcome: { outcome: "cancelled" as const },
1324
- _meta: {
1325
- message:
1326
- "This question has been relayed to the Slack thread where this task originated. " +
1327
- "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
1328
- "Simply let the user know you are waiting for their reply.",
1329
- },
1330
- };
1450
+ return this.buildSlackQuestionRelayResponse(
1451
+ payload,
1452
+ params.toolCall?._meta,
1453
+ );
1331
1454
  }
1332
1455
  }
1333
1456
 
1457
+ if (this.shouldBlockPublishPermission(params)) {
1458
+ return {
1459
+ outcome: { outcome: "cancelled" },
1460
+ _meta: {
1461
+ message:
1462
+ "This run is configured to stop before publishing. Do not push commits or create/update pull requests unless the user explicitly asks.",
1463
+ },
1464
+ };
1465
+ }
1466
+
1334
1467
  return {
1335
1468
  outcome: {
1336
1469
  outcome: "selected" as const,