@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/dist/agent.js +9 -2
- package/dist/agent.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/posthog-api.js +1 -1
- package/dist/posthog-api.js.map +1 -1
- package/dist/server/agent-server.d.ts +12 -0
- package/dist/server/agent-server.js +135 -5
- package/dist/server/agent-server.js.map +1 -1
- package/dist/server/bin.cjs +135 -5
- package/dist/server/bin.cjs.map +1 -1
- package/package.json +3 -3
- package/src/acp-extensions.ts +3 -0
- package/src/adapters/claude/permissions/permission-handlers.ts +11 -5
- package/src/server/agent-server.ts +176 -4
- package/src/server/schemas.test.ts +52 -0
- package/src/server/schemas.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@posthog/agent",
|
|
3
|
-
"version": "2.3.
|
|
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/
|
|
86
|
-
"@posthog/
|
|
85
|
+
"@posthog/shared": "1.0.0",
|
|
86
|
+
"@posthog/git": "1.0.0"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
89
|
"@agentclientprotocol/sdk": "0.16.1",
|
package/src/acp-extensions.ts
CHANGED
|
@@ -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
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
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
|
});
|
package/src/server/schemas.ts
CHANGED
|
@@ -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;
|