@posthog/agent 2.3.261 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/agent",
3
- "version": "2.3.261",
3
+ "version": "2.3.263",
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,8 +82,8 @@
82
82
  "tsx": "^4.20.6",
83
83
  "typescript": "^5.5.0",
84
84
  "vitest": "^2.1.8",
85
- "@posthog/git": "1.0.0",
86
- "@posthog/shared": "1.0.0"
85
+ "@posthog/shared": "1.0.0",
86
+ "@posthog/git": "1.0.0"
87
87
  },
88
88
  "dependencies": {
89
89
  "@agentclientprotocol/sdk": "0.16.1",
@@ -63,6 +63,9 @@ export const POSTHOG_NOTIFICATIONS = {
63
63
 
64
64
  /** Token usage update for a session turn */
65
65
  USAGE_UPDATE: "_posthog/usage_update",
66
+
67
+ /** Response to a relayed permission request (plan approval, question) */
68
+ PERMISSION_RESPONSE: "_posthog/permission_response",
66
69
  } as const;
67
70
 
68
71
  type NotificationMethod =
@@ -490,11 +490,17 @@ export async function canUseTool(
490
490
  return planFileResult;
491
491
  }
492
492
 
493
- // if (session.permissionMode === "dontAsk") {
494
- // const message = "Tool not pre-approved. Denied by dontAsk mode.";
495
- // await emitToolDenial(context, message);
496
- // return { behavior: "deny", message, interrupt: false };
497
- // }
493
+ // In plan mode, deny tools that aren't in the allowed set. The agent must
494
+ // write its plan to ~/.claude/plans/ and call ExitPlanMode before it can
495
+ // use write or bash tools. Without this guard, cloud runs auto-approve
496
+ // restricted tools and the agent skips planning entirely.
497
+ if (session.permissionMode === "plan") {
498
+ const message =
499
+ "This tool is not available in plan mode. Write your plan " +
500
+ `to a file in ${getClaudePlansDir()} and call ExitPlanMode when ready.`;
501
+ await emitToolDenial(context, message);
502
+ return { behavior: "deny", message, interrupt: false };
503
+ }
498
504
 
499
505
  return handleDefaultPermissionFlow(context);
500
506
  }
@@ -18,6 +18,7 @@ import {
18
18
  type InProcessAcpConnection,
19
19
  } from "../adapters/acp-connection";
20
20
  import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration";
21
+ import type { CodeExecutionMode } from "../execution-mode";
21
22
  import { PostHogAPIClient } from "../posthog-api";
22
23
  import {
23
24
  type ConversationTurn,
@@ -161,6 +162,10 @@ interface ActiveSession {
161
162
  sseController: SseController | null;
162
163
  deviceInfo: DeviceInfo;
163
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;
164
169
  }
165
170
 
166
171
  export class AgentServer {
@@ -180,6 +185,15 @@ export class AgentServer {
180
185
  // causing a second session to be created and duplicate Slack messages to be sent.
181
186
  private initializationPromise: Promise<void> | null = null;
182
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
+ >();
183
197
 
184
198
  private detachSseController(controller: SseController): void {
185
199
  if (this.session?.sseController === controller) {
@@ -232,6 +246,10 @@ export class AgentServer {
232
246
  return payload.mode ?? this.config.mode;
233
247
  }
234
248
 
249
+ private getSessionPermissionMode(): CodeExecutionMode {
250
+ return this.session?.permissionMode ?? "default";
251
+ }
252
+
235
253
  private createApp(): Hono {
236
254
  const app = new Hono();
237
255
 
@@ -285,6 +303,7 @@ export class AgentServer {
285
303
  await this.initializeSession(payload, sseController);
286
304
  } else {
287
305
  this.session.sseController = sseController;
306
+ this.session.hasDesktopConnected = true;
288
307
  this.replayPendingEvents();
289
308
  }
290
309
 
@@ -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
@@ -1429,12 +1505,10 @@ ${attributionInstructions}
1429
1505
  requestPermission: async (
1430
1506
  params: RequestPermissionRequest,
1431
1507
  ): Promise<RequestPermissionResponse> => {
1432
- // Background mode: always auto-approve permissions
1433
- // Interactive mode: also auto-approve for now (user can monitor via SSE)
1434
- // Future: interactive mode could pause and wait for user approval via SSE
1435
1508
  this.logger.debug("Permission request", {
1436
1509
  mode,
1437
1510
  interactionOrigin,
1511
+ kind: params.toolCall?.kind,
1438
1512
  options: params.options,
1439
1513
  });
1440
1514
 
@@ -1444,8 +1518,11 @@ ${attributionInstructions}
1444
1518
  const selectedOptionId =
1445
1519
  allowOption?.optionId ?? params.options[0].optionId;
1446
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
1447
1525
  if (interactionOrigin === "slack") {
1448
- const codeToolKind = params.toolCall?._meta?.codeToolKind;
1449
1526
  if (codeToolKind === "question") {
1450
1527
  return this.buildSlackQuestionRelayResponse(
1451
1528
  payload,
@@ -1454,6 +1531,27 @@ ${attributionInstructions}
1454
1531
  }
1455
1532
  }
1456
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
+
1457
1555
  if (this.shouldBlockPublishPermission(params)) {
1458
1556
  return {
1459
1557
  outcome: { outcome: "cancelled" },
@@ -1481,6 +1579,19 @@ ${attributionInstructions}
1481
1579
  sessionId: string;
1482
1580
  update?: Record<string, unknown>;
1483
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
+
1484
1595
  // session/update notifications flow through the tapped stream (like local transport)
1485
1596
  // Only handle tree state capture for file changes here
1486
1597
  if (params.update?.sessionUpdate === "tool_call_update") {
@@ -1730,6 +1841,16 @@ ${attributionInstructions}
1730
1841
  this.logger.error("Failed to flush session logs", error);
1731
1842
  }
1732
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
+
1733
1854
  try {
1734
1855
  await this.session.acpConnection.cleanup();
1735
1856
  } catch (error) {
@@ -1823,4 +1944,55 @@ ${attributionInstructions}
1823
1944
  this.detachSseController(controller);
1824
1945
  }
1825
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
+ }
1826
1998
  }
@@ -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;