@posthog/agent 2.3.259 → 2.3.263

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.
@@ -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,
@@ -14,6 +18,7 @@ import {
14
18
  type InProcessAcpConnection,
15
19
  } from "../adapters/acp-connection";
16
20
  import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration";
21
+ import type { CodeExecutionMode } from "../execution-mode";
17
22
  import { PostHogAPIClient } from "../posthog-api";
18
23
  import {
19
24
  type ConversationTurn,
@@ -157,6 +162,10 @@ interface ActiveSession {
157
162
  sseController: SseController | null;
158
163
  deviceInfo: DeviceInfo;
159
164
  logWriter: SessionLogWriter;
165
+ /** Current permission mode, tracked for relay decisions */
166
+ permissionMode: CodeExecutionMode;
167
+ /** Whether a desktop client has ever connected via SSE during this session */
168
+ hasDesktopConnected: boolean;
160
169
  }
161
170
 
162
171
  export class AgentServer {
@@ -176,6 +185,15 @@ export class AgentServer {
176
185
  // causing a second session to be created and duplicate Slack messages to be sent.
177
186
  private initializationPromise: Promise<void> | null = null;
178
187
  private pendingEvents: Record<string, unknown>[] = [];
188
+ private pendingPermissions = new Map<
189
+ string,
190
+ {
191
+ resolve: (response: {
192
+ outcome: { outcome: "selected"; optionId: string };
193
+ _meta?: Record<string, unknown>;
194
+ }) => void;
195
+ }
196
+ >();
179
197
 
180
198
  private detachSseController(controller: SseController): void {
181
199
  if (this.session?.sseController === controller) {
@@ -228,6 +246,10 @@ export class AgentServer {
228
246
  return payload.mode ?? this.config.mode;
229
247
  }
230
248
 
249
+ private getSessionPermissionMode(): CodeExecutionMode {
250
+ return this.session?.permissionMode ?? "default";
251
+ }
252
+
231
253
  private createApp(): Hono {
232
254
  const app = new Hono();
233
255
 
@@ -281,6 +303,7 @@ export class AgentServer {
281
303
  await this.initializeSession(payload, sseController);
282
304
  } else {
283
305
  this.session.sseController = sseController;
306
+ this.session.hasDesktopConnected = true;
284
307
  this.replayPendingEvents();
285
308
  }
286
309
 
@@ -511,13 +534,9 @@ export class AgentServer {
511
534
  prompt,
512
535
  ...(this.detectedPrUrl && {
513
536
  _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.`,
537
+ // Keep the live-session PR override aligned with the startup
538
+ // prompt policy so non-Slack runs remain review-first.
539
+ prContext: this.buildDetectedPrContext(this.detectedPrUrl),
521
540
  },
522
541
  }),
523
542
  });
@@ -579,6 +598,51 @@ export class AgentServer {
579
598
  return { closed: true };
580
599
  }
581
600
 
601
+ case "posthog/set_config_option":
602
+ case "set_config_option": {
603
+ const configId = params.configId as string;
604
+ const value = params.value as string;
605
+
606
+ this.logger.info("Set config option requested", { configId, value });
607
+
608
+ const result =
609
+ await this.session.clientConnection.setSessionConfigOption({
610
+ sessionId: this.session.acpSessionId,
611
+ configId,
612
+ value,
613
+ });
614
+
615
+ return {
616
+ configOptions: result.configOptions,
617
+ };
618
+ }
619
+
620
+ case POSTHOG_NOTIFICATIONS.PERMISSION_RESPONSE:
621
+ case "permission_response": {
622
+ const requestId = params.requestId as string;
623
+ const optionId = params.optionId as string;
624
+ const customInput = params.customInput as string | undefined;
625
+ const answers = params.answers as Record<string, string> | undefined;
626
+
627
+ this.logger.info("Permission response received", {
628
+ requestId,
629
+ optionId,
630
+ });
631
+
632
+ const resolved = this.resolvePermission(
633
+ requestId,
634
+ optionId,
635
+ customInput,
636
+ answers,
637
+ );
638
+ if (!resolved) {
639
+ throw new Error(
640
+ `No pending permission request found for id: ${requestId}`,
641
+ );
642
+ }
643
+ return { resolved: true };
644
+ }
645
+
582
646
  default:
583
647
  throw new Error(`Unknown method: ${method}`);
584
648
  }
@@ -740,6 +804,14 @@ export class AgentServer {
740
804
  this.detectedPrUrl = prUrl;
741
805
  }
742
806
 
807
+ const runState = preTaskRun?.state as Record<string, unknown> | undefined;
808
+ // Cloud runs default to bypassPermissions (auto-approve everything).
809
+ // Only PostHog Code sets initial_permission_mode explicitly (e.g., "plan").
810
+ const initialPermissionMode: CodeExecutionMode =
811
+ typeof runState?.initial_permission_mode === "string"
812
+ ? (runState.initial_permission_mode as CodeExecutionMode)
813
+ : "bypassPermissions";
814
+
743
815
  const sessionResponse = await clientConnection.newSession({
744
816
  cwd: this.config.repositoryPath ?? "/tmp/workspace",
745
817
  mcpServers: this.config.mcpServers ?? [],
@@ -749,6 +821,7 @@ export class AgentServer {
749
821
  systemPrompt: this.buildSessionSystemPrompt(prUrl),
750
822
  allowedDomains: this.config.allowedDomains,
751
823
  jsonSchema: preTask?.json_schema ?? null,
824
+ permissionMode: initialPermissionMode,
752
825
  ...(this.config.claudeCode?.plugins?.length && {
753
826
  claudeCode: {
754
827
  options: {
@@ -774,6 +847,8 @@ export class AgentServer {
774
847
  sseController,
775
848
  deviceInfo,
776
849
  logWriter,
850
+ permissionMode: initialPermissionMode,
851
+ hasDesktopConnected: sseController !== null,
777
852
  };
778
853
 
779
854
  this.logger = new Logger({
@@ -791,6 +866,7 @@ export class AgentServer {
791
866
  this.logger.info(
792
867
  `Agent version: ${this.config.version ?? packageJson.version}`,
793
868
  );
869
+ this.logger.info(`Initial permission mode: ${initialPermissionMode}`);
794
870
 
795
871
  // Signal in_progress so the UI can start polling for updates
796
872
  this.posthogAPI
@@ -1121,13 +1197,53 @@ export class AgentServer {
1121
1197
  return { append: cloudAppend };
1122
1198
  }
1123
1199
 
1200
+ private getCloudInteractionOrigin(): string | undefined {
1201
+ return (
1202
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
1203
+ process.env.CODE_INTERACTION_ORIGIN ??
1204
+ process.env.TWIG_INTERACTION_ORIGIN
1205
+ );
1206
+ }
1207
+
1208
+ /**
1209
+ * Slack-origin cloud runs auto-publish by default. Every other origin is
1210
+ * review-first unless the user explicitly asks, and createPr=false always
1211
+ * disables publishing.
1212
+ */
1213
+ private shouldAutoPublishCloudChanges(): boolean {
1214
+ return (
1215
+ this.getCloudInteractionOrigin() === "slack" &&
1216
+ this.config.createPr !== false
1217
+ );
1218
+ }
1219
+
1220
+ private buildDetectedPrContext(prUrl: string): string {
1221
+ if (!this.shouldAutoPublishCloudChanges()) {
1222
+ return (
1223
+ `An open pull request already exists: ${prUrl}\n` +
1224
+ `Use that PR as context if it is helpful, but stop with local changes ready for review.\n` +
1225
+ `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.`
1226
+ );
1227
+ }
1228
+
1229
+ return (
1230
+ `IMPORTANT — OVERRIDE PREVIOUS INSTRUCTIONS ABOUT CREATING BRANCHES/PRs.\n` +
1231
+ `You already have an open pull request: ${prUrl}\n` +
1232
+ `You MUST:\n` +
1233
+ `1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`\n` +
1234
+ `2. Make changes, commit, and push to that branch\n` +
1235
+ `You MUST NOT create a new branch, close the existing PR, or create a new PR.`
1236
+ );
1237
+ }
1238
+
1124
1239
  private buildCloudSystemPrompt(prUrl?: string | null): string {
1125
1240
  const taskId = this.config.taskId;
1241
+ const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges();
1126
1242
  const attributionInstructions = `
1127
1243
  ## Attribution
1128
1244
  Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines).
1129
1245
 
1130
- Instead, add the following trailers to EVERY commit message (after a blank line at the end):
1246
+ If you create a commit, add the following trailers to the commit message (after a blank line at the end):
1131
1247
  Generated-By: PostHog Code
1132
1248
  Task-Id: ${taskId}
1133
1249
 
@@ -1143,6 +1259,21 @@ EOF
1143
1259
  \`\`\``;
1144
1260
 
1145
1261
  if (prUrl) {
1262
+ if (!shouldAutoCreatePr) {
1263
+ return `
1264
+ # Cloud Task Execution
1265
+
1266
+ This task already has an open pull request: ${prUrl}
1267
+
1268
+ Do the requested work, but stop with local changes ready for review.
1269
+
1270
+ Important:
1271
+ - Do NOT create new commits, push to the branch, or update the pull request unless the user explicitly asks.
1272
+ - Do NOT create a new branch or a new pull request.
1273
+ ${attributionInstructions}
1274
+ `;
1275
+ }
1276
+
1146
1277
  return `
1147
1278
  # Cloud Task Execution
1148
1279
 
@@ -1180,6 +1311,18 @@ Important:
1180
1311
  `;
1181
1312
  }
1182
1313
 
1314
+ if (!shouldAutoCreatePr) {
1315
+ return `
1316
+ # Cloud Task Execution
1317
+
1318
+ Do the requested work, but stop with local changes ready for review.
1319
+
1320
+ Important:
1321
+ - Do NOT create a branch, commit, push, or open a pull request unless the user explicitly asks.
1322
+ ${attributionInstructions}
1323
+ `;
1324
+ }
1325
+
1183
1326
  return `
1184
1327
  # Cloud Task Execution
1185
1328
 
@@ -1304,25 +1447,68 @@ ${attributionInstructions}
1304
1447
  });
1305
1448
  }
1306
1449
 
1450
+ private buildSlackQuestionRelayResponse(
1451
+ payload: JwtPayload,
1452
+ toolMeta: Record<string, unknown> | null | undefined,
1453
+ ): RequestPermissionResponse {
1454
+ this.relaySlackQuestion(payload, toolMeta);
1455
+ return {
1456
+ outcome: { outcome: "cancelled" as const },
1457
+ _meta: {
1458
+ message:
1459
+ "This question has been relayed to the Slack thread where this task originated. " +
1460
+ "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
1461
+ "Simply let the user know you are waiting for their reply.",
1462
+ },
1463
+ };
1464
+ }
1465
+
1466
+ private shouldBlockPublishPermission(
1467
+ params: RequestPermissionRequest,
1468
+ ): boolean {
1469
+ if (this.config.createPr !== false) {
1470
+ return false;
1471
+ }
1472
+
1473
+ const meta =
1474
+ params.toolCall?._meta &&
1475
+ typeof params.toolCall._meta === "object" &&
1476
+ !Array.isArray(params.toolCall._meta)
1477
+ ? (params.toolCall._meta as Record<string, unknown>)
1478
+ : null;
1479
+ const rawInput =
1480
+ params.toolCall?.rawInput &&
1481
+ typeof params.toolCall.rawInput === "object" &&
1482
+ !Array.isArray(params.toolCall.rawInput)
1483
+ ? (params.toolCall.rawInput as Record<string, unknown>)
1484
+ : null;
1485
+ const toolName = typeof meta?.toolName === "string" ? meta.toolName : null;
1486
+ const command =
1487
+ typeof rawInput?.command === "string" ? rawInput.command : null;
1488
+
1489
+ return Boolean(
1490
+ toolName &&
1491
+ (toolName === "Bash" || toolName.includes("bash")) &&
1492
+ command &&
1493
+ /\bgit\s+push\b|\bgh\s+pr\s+(create|edit|ready|merge)\b/.test(command),
1494
+ );
1495
+ }
1496
+
1307
1497
  private createCloudClient(payload: JwtPayload) {
1308
1498
  const mode = this.getEffectiveMode(payload);
1309
1499
  const interactionOrigin =
1500
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN ??
1310
1501
  process.env.CODE_INTERACTION_ORIGIN ??
1311
1502
  process.env.TWIG_INTERACTION_ORIGIN;
1312
1503
 
1313
1504
  return {
1314
- requestPermission: async (params: {
1315
- options: Array<{ kind: string; optionId: string; name?: string }>;
1316
- toolCall?: {
1317
- _meta?: Record<string, unknown> | null;
1318
- };
1319
- }) => {
1320
- // Background mode: always auto-approve permissions
1321
- // Interactive mode: also auto-approve for now (user can monitor via SSE)
1322
- // Future: interactive mode could pause and wait for user approval via SSE
1505
+ requestPermission: async (
1506
+ params: RequestPermissionRequest,
1507
+ ): Promise<RequestPermissionResponse> => {
1323
1508
  this.logger.debug("Permission request", {
1324
1509
  mode,
1325
1510
  interactionOrigin,
1511
+ kind: params.toolCall?.kind,
1326
1512
  options: params.options,
1327
1513
  });
1328
1514
 
@@ -1332,22 +1518,50 @@ ${attributionInstructions}
1332
1518
  const selectedOptionId =
1333
1519
  allowOption?.optionId ?? params.options[0].optionId;
1334
1520
 
1521
+ const codeToolKind = params.toolCall?._meta?.codeToolKind;
1522
+ const isPlanApproval = params.toolCall?.kind === "switch_mode";
1523
+
1524
+ // Relay questions to Slack when interaction originated there
1335
1525
  if (interactionOrigin === "slack") {
1336
- const codeToolKind = params.toolCall?._meta?.codeToolKind;
1337
1526
  if (codeToolKind === "question") {
1338
- this.relaySlackQuestion(payload, params.toolCall?._meta);
1339
- return {
1340
- outcome: { outcome: "cancelled" as const },
1341
- _meta: {
1342
- message:
1343
- "This question has been relayed to the Slack thread where this task originated. " +
1344
- "The user will reply there. Do NOT re-ask the question or pick an answer yourself. " +
1345
- "Simply let the user know you are waiting for their reply.",
1346
- },
1347
- };
1527
+ return this.buildSlackQuestionRelayResponse(
1528
+ payload,
1529
+ params.toolCall?._meta,
1530
+ );
1348
1531
  }
1349
1532
  }
1350
1533
 
1534
+ // Relay permission requests to the desktop app when:
1535
+ // - Questions: always relay (need human answers regardless of mode)
1536
+ // - Plan approvals: always relay
1537
+ // - Edit/bash in "default" mode: relay for manual approval
1538
+ // Other modes auto-approve. No client connected → auto-approve.
1539
+ {
1540
+ const isQuestion = codeToolKind === "question";
1541
+ const sessionPermissionMode = this.getSessionPermissionMode();
1542
+ const needsRelay =
1543
+ isQuestion || isPlanApproval || sessionPermissionMode === "default";
1544
+
1545
+ if (needsRelay && this.session?.hasDesktopConnected) {
1546
+ this.logger.info("Relaying permission to connected client", {
1547
+ kind: params.toolCall?.kind,
1548
+ isQuestion,
1549
+ sessionPermissionMode,
1550
+ });
1551
+ return this.relayPermissionToClient(params);
1552
+ }
1553
+ }
1554
+
1555
+ if (this.shouldBlockPublishPermission(params)) {
1556
+ return {
1557
+ outcome: { outcome: "cancelled" },
1558
+ _meta: {
1559
+ message:
1560
+ "This run is configured to stop before publishing. Do not push commits or create/update pull requests unless the user explicitly asks.",
1561
+ },
1562
+ };
1563
+ }
1564
+
1351
1565
  return {
1352
1566
  outcome: {
1353
1567
  outcome: "selected" as const,
@@ -1365,6 +1579,19 @@ ${attributionInstructions}
1365
1579
  sessionId: string;
1366
1580
  update?: Record<string, unknown>;
1367
1581
  }) => {
1582
+ // Track permission mode changes for relay decisions
1583
+ if (
1584
+ params.update?.sessionUpdate === "current_mode_update" &&
1585
+ typeof params.update?.currentModeId === "string" &&
1586
+ this.session
1587
+ ) {
1588
+ this.session.permissionMode = params.update
1589
+ .currentModeId as CodeExecutionMode;
1590
+ this.logger.info("Permission mode updated", {
1591
+ mode: params.update.currentModeId,
1592
+ });
1593
+ }
1594
+
1368
1595
  // session/update notifications flow through the tapped stream (like local transport)
1369
1596
  // Only handle tree state capture for file changes here
1370
1597
  if (params.update?.sessionUpdate === "tool_call_update") {
@@ -1614,6 +1841,16 @@ ${attributionInstructions}
1614
1841
  this.logger.error("Failed to flush session logs", error);
1615
1842
  }
1616
1843
 
1844
+ // Drain pending permissions before ACP cleanup to avoid deadlocks —
1845
+ // cleanup may await operations that are blocked on a permission response.
1846
+ for (const [, pending] of this.pendingPermissions) {
1847
+ pending.resolve({
1848
+ outcome: { outcome: "selected", optionId: "reject" },
1849
+ _meta: { customInput: "Session is shutting down." },
1850
+ });
1851
+ }
1852
+ this.pendingPermissions.clear();
1853
+
1617
1854
  try {
1618
1855
  await this.session.acpConnection.cleanup();
1619
1856
  } catch (error) {
@@ -1707,4 +1944,55 @@ ${attributionInstructions}
1707
1944
  this.detachSseController(controller);
1708
1945
  }
1709
1946
  }
1947
+
1948
+ /**
1949
+ * Relay a permission request (e.g., plan approval) to the connected desktop
1950
+ * app via SSE and wait for a response via the `/command` endpoint.
1951
+ *
1952
+ * The promise waits indefinitely — if SSE is disconnected, the event is
1953
+ * buffered by broadcastEvent and replayed when the client reconnects. Session
1954
+ * cleanup force-resolves all pending permissions, so there is no leak.
1955
+ */
1956
+ private relayPermissionToClient(params: {
1957
+ options: Array<{ kind: string; optionId: string; name?: string }>;
1958
+ toolCall?: Record<string, unknown> | null;
1959
+ }): Promise<{
1960
+ outcome: { outcome: "selected"; optionId: string };
1961
+ _meta?: Record<string, unknown>;
1962
+ }> {
1963
+ const requestId = crypto.randomUUID();
1964
+
1965
+ this.broadcastEvent({
1966
+ type: "permission_request",
1967
+ requestId,
1968
+ options: params.options,
1969
+ toolCall: params.toolCall,
1970
+ });
1971
+
1972
+ return new Promise((resolve) => {
1973
+ this.pendingPermissions.set(requestId, { resolve });
1974
+ });
1975
+ }
1976
+
1977
+ private resolvePermission(
1978
+ requestId: string,
1979
+ optionId: string,
1980
+ customInput?: string,
1981
+ answers?: Record<string, string>,
1982
+ ): boolean {
1983
+ const pending = this.pendingPermissions.get(requestId);
1984
+ if (!pending) return false;
1985
+
1986
+ this.pendingPermissions.delete(requestId);
1987
+
1988
+ const meta: Record<string, unknown> = {};
1989
+ if (customInput) meta.customInput = customInput;
1990
+ if (answers) meta.answers = answers;
1991
+
1992
+ pending.resolve({
1993
+ outcome: { outcome: "selected" as const, optionId },
1994
+ ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}),
1995
+ });
1996
+ return true;
1997
+ }
1710
1998
  }
package/src/server/bin.ts CHANGED
@@ -30,6 +30,16 @@ const envSchema = z.object({
30
30
 
31
31
  const program = new Command();
32
32
 
33
+ function parseBooleanOption(
34
+ raw: string | undefined,
35
+ flag: string,
36
+ ): boolean | undefined {
37
+ if (raw === undefined) return undefined;
38
+ if (raw === "true") return true;
39
+ if (raw === "false") return false;
40
+ program.error(`${flag} must be either "true" or "false"`);
41
+ }
42
+
33
43
  function parseJsonOption<S extends z.ZodType>(
34
44
  raw: string | undefined,
35
45
  schema: S,
@@ -70,6 +80,7 @@ program
70
80
  "--mcpServers <json>",
71
81
  "MCP servers config as JSON array (ACP McpServer[] format)",
72
82
  )
83
+ .option("--createPr <boolean>", "Whether this run may publish changes")
73
84
  .option("--baseBranch <branch>", "Base branch for PR creation")
74
85
  .option(
75
86
  "--claudeCodeConfig <json>",
@@ -93,6 +104,7 @@ program
93
104
  const env = envResult.data;
94
105
 
95
106
  const mode = options.mode === "background" ? "background" : "interactive";
107
+ const createPr = parseBooleanOption(options.createPr, "--createPr");
96
108
 
97
109
  const mcpServers = parseJsonOption(
98
110
  options.mcpServers,
@@ -122,6 +134,7 @@ program
122
134
  mode,
123
135
  taskId: options.taskId,
124
136
  runId: options.runId,
137
+ createPr,
125
138
  mcpServers,
126
139
  baseBranch: options.baseBranch,
127
140
  claudeCode,
@@ -172,13 +172,13 @@ describe("Question relay", () => {
172
172
  { kind: "allow_once", optionId: "allow", name: "Allow" },
173
173
  ];
174
174
 
175
- describe("with CODE_INTERACTION_ORIGIN=slack", () => {
175
+ describe("with POSTHOG_CODE_INTERACTION_ORIGIN=slack", () => {
176
176
  beforeEach(() => {
177
- process.env.CODE_INTERACTION_ORIGIN = "slack";
177
+ process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack";
178
178
  });
179
179
 
180
180
  afterEach(() => {
181
- delete process.env.CODE_INTERACTION_ORIGIN;
181
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
182
182
  });
183
183
 
184
184
  it("returns cancelled with relay message for question tool", async () => {
@@ -220,9 +220,9 @@ describe("Question relay", () => {
220
220
  });
221
221
  });
222
222
 
223
- describe("without CODE_INTERACTION_ORIGIN", () => {
223
+ describe("without POSTHOG_CODE_INTERACTION_ORIGIN", () => {
224
224
  beforeEach(() => {
225
- delete process.env.CODE_INTERACTION_ORIGIN;
225
+ delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN;
226
226
  });
227
227
 
228
228
  it("auto-approves question tools (no Slack relay)", async () => {
@@ -301,6 +301,35 @@ describe("Question relay", () => {
301
301
  expect(appendRawLine).toHaveBeenCalledTimes(2);
302
302
  });
303
303
  });
304
+
305
+ describe("with createPr disabled", () => {
306
+ it("cancels publish commands", async () => {
307
+ server = new AgentServer({
308
+ port,
309
+ jwtPublicKey: "unused-in-unit-tests",
310
+ repositoryPath: repo.path,
311
+ apiUrl: "http://localhost:8000",
312
+ apiKey: "test-api-key",
313
+ projectId: 1,
314
+ mode: "interactive",
315
+ taskId: "test-task-id",
316
+ runId: "test-run-id",
317
+ createPr: false,
318
+ }) as unknown as TestableAgentServer;
319
+
320
+ const client = server.createCloudClient(TEST_PAYLOAD);
321
+ const result = await client.requestPermission({
322
+ options: ALLOW_OPTIONS,
323
+ toolCall: {
324
+ rawInput: { command: "git push origin my-branch" },
325
+ _meta: { toolName: "Bash" },
326
+ },
327
+ });
328
+
329
+ expect(result.outcome.outcome).toBe("cancelled");
330
+ expect(result._meta?.message).toContain("stop before publishing");
331
+ });
332
+ });
304
333
  });
305
334
 
306
335
  describe("relayAgentResponse duplicate suppression", () => {
@@ -132,4 +132,56 @@ describe("validateCommandParams", () => {
132
132
 
133
133
  expect(result.success).toBe(false);
134
134
  });
135
+
136
+ it("accepts valid permission_response", () => {
137
+ const result = validateCommandParams("permission_response", {
138
+ requestId: "abc-123",
139
+ optionId: "acceptEdits",
140
+ });
141
+
142
+ expect(result.success).toBe(true);
143
+ });
144
+
145
+ it("accepts permission_response with customInput", () => {
146
+ const result = validateCommandParams("permission_response", {
147
+ requestId: "abc-123",
148
+ optionId: "reject_with_feedback",
149
+ customInput: "Please change the approach",
150
+ });
151
+
152
+ expect(result.success).toBe(true);
153
+ });
154
+
155
+ it("rejects permission_response without requestId", () => {
156
+ const result = validateCommandParams("permission_response", {
157
+ optionId: "acceptEdits",
158
+ });
159
+
160
+ expect(result.success).toBe(false);
161
+ });
162
+
163
+ it("rejects permission_response without optionId", () => {
164
+ const result = validateCommandParams("permission_response", {
165
+ requestId: "abc-123",
166
+ });
167
+
168
+ expect(result.success).toBe(false);
169
+ });
170
+
171
+ it("accepts valid set_config_option", () => {
172
+ const result = validateCommandParams("set_config_option", {
173
+ configId: "mode",
174
+ value: "plan",
175
+ });
176
+
177
+ expect(result.success).toBe(true);
178
+ });
179
+
180
+ it("rejects set_config_option without configId", () => {
181
+ const result = validateCommandParams("set_config_option", {
182
+ value: "plan",
183
+ });
184
+
185
+ expect(result.success).toBe(false);
186
+ });
135
187
  });
@@ -48,6 +48,18 @@ export const userMessageParamsSchema = z.object({
48
48
  ]),
49
49
  });
50
50
 
51
+ export const permissionResponseParamsSchema = z.object({
52
+ requestId: z.string().min(1, "requestId is required"),
53
+ optionId: z.string().min(1, "optionId is required"),
54
+ customInput: z.string().optional(),
55
+ answers: z.record(z.string(), z.string()).optional(),
56
+ });
57
+
58
+ export const setConfigOptionParamsSchema = z.object({
59
+ configId: z.string().min(1, "configId is required"),
60
+ value: z.string().min(1, "value is required"),
61
+ });
62
+
51
63
  export const commandParamsSchemas = {
52
64
  user_message: userMessageParamsSchema,
53
65
  "posthog/user_message": userMessageParamsSchema,
@@ -55,6 +67,10 @@ export const commandParamsSchemas = {
55
67
  "posthog/cancel": z.object({}).optional(),
56
68
  close: z.object({}).optional(),
57
69
  "posthog/close": z.object({}).optional(),
70
+ permission_response: permissionResponseParamsSchema,
71
+ "posthog/permission_response": permissionResponseParamsSchema,
72
+ set_config_option: setConfigOptionParamsSchema,
73
+ "posthog/set_config_option": setConfigOptionParamsSchema,
58
74
  } as const;
59
75
 
60
76
  export type CommandMethod = keyof typeof commandParamsSchemas;
@@ -18,6 +18,7 @@ export interface AgentServerConfig {
18
18
  mode: AgentMode;
19
19
  taskId: string;
20
20
  runId: string;
21
+ createPr?: boolean;
21
22
  version?: string;
22
23
  mcpServers?: RemoteMcpServer[];
23
24
  baseBranch?: string;