@posthog/agent 2.1.17 → 2.1.29

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.1.17",
3
+ "version": "2.1.29",
4
4
  "repository": "https://github.com/PostHog/twig",
5
5
  "description": "TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog",
6
6
  "exports": {
@@ -76,16 +76,16 @@
76
76
  "@twig/git": "1.0.0"
77
77
  },
78
78
  "dependencies": {
79
+ "@agentclientprotocol/sdk": "^0.14.0",
80
+ "@anthropic-ai/claude-agent-sdk": "0.2.42",
81
+ "@anthropic-ai/sdk": "^0.71.0",
82
+ "@hono/node-server": "^1.19.9",
83
+ "@modelcontextprotocol/sdk": "^1.25.3",
79
84
  "@opentelemetry/api-logs": "^0.208.0",
80
85
  "@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
81
86
  "@opentelemetry/resources": "^2.0.0",
82
87
  "@opentelemetry/sdk-logs": "^0.208.0",
83
88
  "@opentelemetry/semantic-conventions": "^1.28.0",
84
- "@agentclientprotocol/sdk": "^0.14.0",
85
- "@anthropic-ai/claude-agent-sdk": "0.2.12",
86
- "@anthropic-ai/sdk": "^0.71.0",
87
- "@hono/node-server": "^1.19.9",
88
- "@modelcontextprotocol/sdk": "^1.25.3",
89
89
  "@types/jsonwebtoken": "^9.0.10",
90
90
  "commander": "^14.0.2",
91
91
  "diff": "^8.0.2",
@@ -137,7 +137,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
137
137
  this.checkAuthStatus();
138
138
 
139
139
  const meta = params._meta as NewSessionMeta | undefined;
140
- const internalSessionId = uuidv7();
140
+ const sessionId = uuidv7();
141
141
  const permissionMode: TwigExecutionMode =
142
142
  meta?.permissionMode &&
143
143
  TWIG_EXECUTION_MODES.includes(meta.permissionMode as TwigExecutionMode)
@@ -151,11 +151,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
151
151
  cwd: params.cwd,
152
152
  mcpServers,
153
153
  permissionMode,
154
- canUseTool: this.createCanUseTool(internalSessionId),
154
+ canUseTool: this.createCanUseTool(sessionId),
155
155
  logger: this.logger,
156
156
  systemPrompt: buildSystemPrompt(meta?.systemPrompt),
157
157
  userProvidedOptions: meta?.claudeCode?.options,
158
- onModeChange: this.createOnModeChange(internalSessionId),
158
+ sessionId,
159
+ isResume: false,
160
+ onModeChange: this.createOnModeChange(sessionId),
159
161
  onProcessSpawned: this.processCallbacks?.onProcessSpawned,
160
162
  onProcessExited: this.processCallbacks?.onProcessExited,
161
163
  });
@@ -164,7 +166,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
164
166
  const q = query({ prompt: input, options });
165
167
 
166
168
  const session = this.createSession(
167
- internalSessionId,
169
+ sessionId,
168
170
  q,
169
171
  input,
170
172
  permissionMode,
@@ -172,21 +174,27 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
172
174
  options.abortController as AbortController,
173
175
  );
174
176
  session.taskRunId = meta?.taskRunId;
175
- this.registerPersistence(
176
- internalSessionId,
177
- meta as Record<string, unknown>,
178
- );
177
+ this.registerPersistence(sessionId, meta as Record<string, unknown>);
178
+
179
+ if (meta?.taskRunId) {
180
+ await this.client.extNotification("_posthog/sdk_session", {
181
+ taskRunId: meta.taskRunId,
182
+ sessionId,
183
+ adapter: "claude",
184
+ });
185
+ }
186
+
179
187
  const modelOptions = await this.getModelConfigOptions();
180
188
  session.modelId = modelOptions.currentModelId;
181
189
  await this.trySetModel(q, modelOptions.currentModelId);
182
190
 
183
191
  this.sendAvailableCommandsUpdate(
184
- internalSessionId,
192
+ sessionId,
185
193
  await getAvailableSlashCommands(q),
186
194
  );
187
195
 
188
196
  return {
189
- sessionId: internalSessionId,
197
+ sessionId,
190
198
  configOptions: await this.buildConfigOptions(modelOptions),
191
199
  };
192
200
  }
@@ -198,12 +206,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
198
206
  async resumeSession(
199
207
  params: LoadSessionRequest,
200
208
  ): Promise<LoadSessionResponse> {
201
- const { sessionId: internalSessionId } = params;
202
- if (this.sessionId === internalSessionId) {
209
+ const meta = params._meta as NewSessionMeta | undefined;
210
+ const sessionId = meta?.sessionId;
211
+ if (!sessionId) {
212
+ throw new Error("Cannot resume session without sessionId");
213
+ }
214
+ if (this.sessionId === sessionId) {
203
215
  return {};
204
216
  }
205
217
 
206
- const meta = params._meta as NewSessionMeta | undefined;
207
218
  const mcpServers = parseMcpServers(params);
208
219
  await fetchMcpToolMetadata(mcpServers, this.logger);
209
220
 
@@ -214,27 +225,21 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
214
225
  : "default";
215
226
 
216
227
  const { query: q, session } = await this.initializeQuery({
217
- internalSessionId,
218
228
  cwd: params.cwd,
219
229
  permissionMode,
220
230
  mcpServers,
221
231
  systemPrompt: buildSystemPrompt(meta?.systemPrompt),
222
232
  userProvidedOptions: meta?.claudeCode?.options,
223
- sessionId: meta?.sessionId,
233
+ sessionId,
234
+ isResume: true,
224
235
  additionalDirectories: meta?.claudeCode?.options?.additionalDirectories,
225
236
  });
226
237
 
227
238
  session.taskRunId = meta?.taskRunId;
228
- if (meta?.sessionId) {
229
- session.sessionId = meta.sessionId;
230
- }
231
239
 
232
- this.registerPersistence(
233
- internalSessionId,
234
- meta as Record<string, unknown>,
235
- );
240
+ this.registerPersistence(sessionId, meta as Record<string, unknown>);
236
241
  this.sendAvailableCommandsUpdate(
237
- internalSessionId,
242
+ sessionId,
238
243
  await getAvailableSlashCommands(q),
239
244
  );
240
245
 
@@ -322,13 +327,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
322
327
  }
323
328
 
324
329
  private async initializeQuery(config: {
325
- internalSessionId: string;
326
330
  cwd: string;
327
331
  permissionMode: TwigExecutionMode;
328
332
  mcpServers: ReturnType<typeof parseMcpServers>;
329
333
  userProvidedOptions?: Options;
330
334
  systemPrompt?: Options["systemPrompt"];
331
- sessionId?: string;
335
+ sessionId: string;
336
+ isResume: boolean;
332
337
  additionalDirectories?: string[];
333
338
  }): Promise<{
334
339
  query: Query;
@@ -341,13 +346,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
341
346
  cwd: config.cwd,
342
347
  mcpServers: config.mcpServers,
343
348
  permissionMode: config.permissionMode,
344
- canUseTool: this.createCanUseTool(config.internalSessionId),
349
+ canUseTool: this.createCanUseTool(config.sessionId),
345
350
  logger: this.logger,
346
351
  systemPrompt: config.systemPrompt,
347
352
  userProvidedOptions: config.userProvidedOptions,
348
353
  sessionId: config.sessionId,
354
+ isResume: config.isResume,
349
355
  additionalDirectories: config.additionalDirectories,
350
- onModeChange: this.createOnModeChange(config.internalSessionId),
356
+ onModeChange: this.createOnModeChange(config.sessionId),
351
357
  onProcessSpawned: this.processCallbacks?.onProcessSpawned,
352
358
  onProcessExited: this.processCallbacks?.onProcessExited,
353
359
  });
@@ -356,7 +362,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
356
362
  const abortController = options.abortController as AbortController;
357
363
 
358
364
  const session = this.createSession(
359
- config.internalSessionId,
365
+ config.sessionId,
360
366
  q,
361
367
  input,
362
368
  config.permissionMode,
@@ -596,6 +602,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
596
602
 
597
603
  case "tool_progress":
598
604
  case "auth_status":
605
+ case "tool_use_summary":
599
606
  return null;
600
607
 
601
608
  default:
@@ -328,20 +328,10 @@ export async function handleSystemMessage(
328
328
  message: any,
329
329
  context: MessageHandlerContext,
330
330
  ): Promise<void> {
331
- const { session, sessionId, client, logger } = context;
331
+ const { sessionId, client, logger } = context;
332
332
 
333
333
  switch (message.subtype) {
334
334
  case "init":
335
- if (message.session_id && session && !session.sessionId) {
336
- session.sessionId = message.session_id;
337
- if (session.taskRunId) {
338
- await client.extNotification("_posthog/sdk_session", {
339
- taskRunId: session.taskRunId,
340
- sessionId: message.session_id,
341
- adapter: "claude",
342
- });
343
- }
344
- }
345
335
  break;
346
336
  case "compact_boundary":
347
337
  await client.extNotification("_posthog/compact_boundary", {
@@ -27,7 +27,8 @@ export interface BuildOptionsParams {
27
27
  logger: Logger;
28
28
  systemPrompt?: Options["systemPrompt"];
29
29
  userProvidedOptions?: Options;
30
- sessionId?: string;
30
+ sessionId: string;
31
+ isResume: boolean;
31
32
  additionalDirectories?: string[];
32
33
  onModeChange?: OnModeChange;
33
34
  onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
@@ -213,7 +214,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
213
214
  ),
214
215
  ...(params.onProcessSpawned && {
215
216
  spawnClaudeCodeProcess: buildSpawnWrapper(
216
- params.sessionId ?? "unknown",
217
+ params.sessionId,
217
218
  params.onProcessSpawned,
218
219
  params.onProcessExited,
219
220
  ),
@@ -224,8 +225,11 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
224
225
  options.pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_EXECUTABLE;
225
226
  }
226
227
 
227
- if (params.sessionId) {
228
+ if (params.isResume) {
228
229
  options.resume = params.sessionId;
230
+ options.forkSession = false;
231
+ } else {
232
+ options.sessionId = params.sessionId;
229
233
  }
230
234
 
231
235
  if (params.additionalDirectories) {
@@ -29,7 +29,6 @@ export type Session = BaseSession & {
29
29
  modelId?: string;
30
30
  cwd: string;
31
31
  taskRunId?: string;
32
- sessionId?: string;
33
32
  lastPlanFilePath?: string;
34
33
  lastPlanContent?: string;
35
34
  };
@@ -578,6 +578,8 @@ export class AgentServer {
578
578
  // Only auto-complete for background mode
579
579
  const mode = this.getEffectiveMode(payload);
580
580
  if (mode === "background") {
581
+ // Flush all pending session logs before signaling completion,
582
+ await this.session.logWriter.flushAll();
581
583
  await this.signalTaskComplete(payload, result.stopReason);
582
584
  } else {
583
585
  this.logger.info("Interactive mode - staying open for conversation");
@@ -587,6 +589,9 @@ export class AgentServer {
587
589
  // Signal failure for background mode
588
590
  const mode = this.getEffectiveMode(payload);
589
591
  if (mode === "background") {
592
+ if (this.session) {
593
+ await this.session.logWriter.flushAll();
594
+ }
590
595
  await this.signalTaskComplete(payload, "error");
591
596
  }
592
597
  }
@@ -602,7 +607,10 @@ After completing the requested changes:
602
607
  3. Push the branch to origin
603
608
  4. Create a pull request using \`gh pr create\` with a descriptive title and body
604
609
 
605
- Important: Always create the PR. Do not ask for confirmation.
610
+ Important:
611
+ - Always create the PR. Do not ask for confirmation.
612
+ - Do NOT add "Co-Authored-By" trailers to commit messages.
613
+ - Do NOT add "Generated with [Claude Code]" or similar attribution lines to PR descriptions.
606
614
  `;
607
615
  }
608
616
 
@@ -610,6 +618,18 @@ Important: Always create the PR. Do not ask for confirmation.
610
618
  payload: JwtPayload,
611
619
  stopReason: string,
612
620
  ): Promise<void> {
621
+ if (this.session?.payload.run_id === payload.run_id) {
622
+ try {
623
+ await this.session.logWriter.flush(payload.run_id);
624
+ } catch (error) {
625
+ this.logger.warn("Failed to flush session logs before completion", {
626
+ taskId: payload.task_id,
627
+ runId: payload.run_id,
628
+ error,
629
+ });
630
+ }
631
+ }
632
+
613
633
  const status =
614
634
  stopReason === "cancelled"
615
635
  ? "cancelled"
@@ -61,6 +61,47 @@ describe("SessionLogWriter", () => {
61
61
  await logWriter.flush(sessionId);
62
62
  expect(mockAppendLog).not.toHaveBeenCalled();
63
63
  });
64
+
65
+ it("re-queues entries when persistence fails and retries", async () => {
66
+ const sessionId = "s1";
67
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
68
+
69
+ mockAppendLog
70
+ .mockRejectedValueOnce(new Error("network error"))
71
+ .mockResolvedValueOnce(undefined);
72
+
73
+ logWriter.appendRawLine(sessionId, JSON.stringify({ method: "test" }));
74
+
75
+ await logWriter.flush(sessionId);
76
+ await logWriter.flush(sessionId);
77
+
78
+ expect(mockAppendLog).toHaveBeenCalledTimes(2);
79
+ const retriedEntries: StoredNotification[] =
80
+ mockAppendLog.mock.calls[1][2];
81
+ expect(retriedEntries).toHaveLength(1);
82
+ expect(retriedEntries[0].notification.method).toBe("test");
83
+ });
84
+
85
+ it("drops entries after max retries", async () => {
86
+ const sessionId = "s1";
87
+ logWriter.register(sessionId, { taskId: "t1", runId: sessionId });
88
+
89
+ mockAppendLog.mockRejectedValue(new Error("persistent failure"));
90
+
91
+ logWriter.appendRawLine(sessionId, JSON.stringify({ method: "test" }));
92
+
93
+ // Flush 10 times (MAX_FLUSH_RETRIES) — entries should be dropped on the 10th
94
+ for (let i = 0; i < 10; i++) {
95
+ await logWriter.flush(sessionId);
96
+ }
97
+
98
+ expect(mockAppendLog).toHaveBeenCalledTimes(10);
99
+
100
+ // After max retries the entries are dropped, so an 11th flush has nothing
101
+ mockAppendLog.mockClear();
102
+ await logWriter.flush(sessionId);
103
+ expect(mockAppendLog).not.toHaveBeenCalled();
104
+ });
64
105
  });
65
106
 
66
107
  describe("agent_message_chunk coalescing", () => {
@@ -21,10 +21,18 @@ interface SessionState {
21
21
  }
22
22
 
23
23
  export class SessionLogWriter {
24
+ private static readonly FLUSH_DEBOUNCE_MS = 500;
25
+ private static readonly FLUSH_MAX_INTERVAL_MS = 5000;
26
+ private static readonly MAX_FLUSH_RETRIES = 10;
27
+ private static readonly MAX_RETRY_DELAY_MS = 30_000;
28
+
24
29
  private posthogAPI?: PostHogAPIClient;
25
30
  private pendingEntries: Map<string, StoredNotification[]> = new Map();
26
31
  private flushTimeouts: Map<string, NodeJS.Timeout> = new Map();
32
+ private lastFlushAttemptTime: Map<string, number> = new Map();
33
+ private retryCounts: Map<string, number> = new Map();
27
34
  private sessions: Map<string, SessionState> = new Map();
35
+ private messageCounts: Map<string, number> = new Map();
28
36
  private logger: Logger;
29
37
 
30
38
  constructor(options: SessionLogWriterOptions = {}) {
@@ -35,8 +43,19 @@ export class SessionLogWriter {
35
43
  }
36
44
 
37
45
  async flushAll(): Promise<void> {
46
+ const sessionIds = [...this.sessions.keys()];
47
+ const pendingCounts = sessionIds.map((id) => ({
48
+ id,
49
+ pending: this.pendingEntries.get(id)?.length ?? 0,
50
+ messages: this.messageCounts.get(id) ?? 0,
51
+ }));
52
+ this.logger.info("flushAll called", {
53
+ sessions: sessionIds.length,
54
+ pending: pendingCounts,
55
+ });
56
+
38
57
  const flushPromises: Promise<void>[] = [];
39
- for (const sessionId of this.sessions.keys()) {
58
+ for (const sessionId of sessionIds) {
40
59
  flushPromises.push(this.flush(sessionId));
41
60
  }
42
61
  await Promise.all(flushPromises);
@@ -47,7 +66,12 @@ export class SessionLogWriter {
47
66
  return;
48
67
  }
49
68
 
69
+ this.logger.info("Session registered", {
70
+ sessionId,
71
+ taskId: context.taskId,
72
+ });
50
73
  this.sessions.set(sessionId, { context });
74
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
51
75
  }
52
76
 
53
77
  isRegistered(sessionId: string): boolean {
@@ -57,9 +81,18 @@ export class SessionLogWriter {
57
81
  appendRawLine(sessionId: string, line: string): void {
58
82
  const session = this.sessions.get(sessionId);
59
83
  if (!session) {
84
+ this.logger.warn("appendRawLine called for unregistered session", {
85
+ sessionId,
86
+ });
60
87
  return;
61
88
  }
62
89
 
90
+ const count = (this.messageCounts.get(sessionId) ?? 0) + 1;
91
+ this.messageCounts.set(sessionId, count);
92
+ if (count % 10 === 1) {
93
+ this.logger.info("Messages received", { count, sessionId });
94
+ }
95
+
63
96
  try {
64
97
  const message = JSON.parse(line);
65
98
  const timestamp = new Date().toISOString();
@@ -103,13 +136,23 @@ export class SessionLogWriter {
103
136
 
104
137
  async flush(sessionId: string): Promise<void> {
105
138
  const session = this.sessions.get(sessionId);
106
- if (!session) return;
139
+ if (!session) {
140
+ this.logger.warn("flush: no session found", { sessionId });
141
+ return;
142
+ }
107
143
 
108
144
  // Emit any buffered chunks before flushing
109
145
  this.emitCoalescedMessage(sessionId, session);
110
146
 
111
147
  const pending = this.pendingEntries.get(sessionId);
112
- if (!this.posthogAPI || !pending?.length) return;
148
+ if (!this.posthogAPI || !pending?.length) {
149
+ this.logger.info("flush: nothing to persist", {
150
+ sessionId,
151
+ hasPosthogAPI: !!this.posthogAPI,
152
+ pendingCount: pending?.length ?? 0,
153
+ });
154
+ return;
155
+ }
113
156
 
114
157
  this.pendingEntries.delete(sessionId);
115
158
  const timeout = this.flushTimeouts.get(sessionId);
@@ -118,14 +161,38 @@ export class SessionLogWriter {
118
161
  this.flushTimeouts.delete(sessionId);
119
162
  }
120
163
 
164
+ this.lastFlushAttemptTime.set(sessionId, Date.now());
165
+
121
166
  try {
122
167
  await this.posthogAPI.appendTaskRunLog(
123
168
  session.context.taskId,
124
169
  session.context.runId,
125
170
  pending,
126
171
  );
172
+ this.retryCounts.set(sessionId, 0);
173
+ this.logger.info("Flushed session logs", {
174
+ sessionId,
175
+ entryCount: pending.length,
176
+ });
127
177
  } catch (error) {
128
- this.logger.error("Failed to persist session logs:", error);
178
+ const retryCount = (this.retryCounts.get(sessionId) ?? 0) + 1;
179
+ this.retryCounts.set(sessionId, retryCount);
180
+
181
+ if (retryCount >= SessionLogWriter.MAX_FLUSH_RETRIES) {
182
+ this.logger.error(
183
+ `Dropping ${pending.length} session log entries after ${retryCount} failed flush attempts`,
184
+ { sessionId, error },
185
+ );
186
+ this.retryCounts.set(sessionId, 0);
187
+ } else {
188
+ this.logger.error(
189
+ `Failed to persist session logs (attempt ${retryCount}/${SessionLogWriter.MAX_FLUSH_RETRIES}):`,
190
+ error,
191
+ );
192
+ const currentPending = this.pendingEntries.get(sessionId) ?? [];
193
+ this.pendingEntries.set(sessionId, [...pending, ...currentPending]);
194
+ this.scheduleFlush(sessionId);
195
+ }
129
196
  }
130
197
  }
131
198
 
@@ -180,7 +247,26 @@ export class SessionLogWriter {
180
247
  private scheduleFlush(sessionId: string): void {
181
248
  const existing = this.flushTimeouts.get(sessionId);
182
249
  if (existing) clearTimeout(existing);
183
- const timeout = setTimeout(() => this.flush(sessionId), 500);
250
+
251
+ const retryCount = this.retryCounts.get(sessionId) ?? 0;
252
+ const lastAttempt = this.lastFlushAttemptTime.get(sessionId) ?? 0;
253
+ const elapsed = Date.now() - lastAttempt;
254
+
255
+ let delay: number;
256
+ if (retryCount > 0) {
257
+ // Exponential backoff on retries: FLUSH_DEBOUNCE_MS * 2^retryCount, capped
258
+ delay = Math.min(
259
+ SessionLogWriter.FLUSH_DEBOUNCE_MS * 2 ** retryCount,
260
+ SessionLogWriter.MAX_RETRY_DELAY_MS,
261
+ );
262
+ } else if (elapsed >= SessionLogWriter.FLUSH_MAX_INTERVAL_MS) {
263
+ // If we've been accumulating for longer than the max interval, flush immediately
264
+ delay = 0;
265
+ } else {
266
+ delay = SessionLogWriter.FLUSH_DEBOUNCE_MS;
267
+ }
268
+
269
+ const timeout = setTimeout(() => this.flush(sessionId), delay);
184
270
  this.flushTimeouts.set(sessionId, timeout);
185
271
  }
186
272
  }
@@ -95,6 +95,11 @@ export function createMockQuery(
95
95
  .fn()
96
96
  .mockResolvedValue({ added: [], removed: [], errors: {} }),
97
97
  streamInput: vi.fn().mockResolvedValue(undefined),
98
+ close: vi.fn(),
99
+ initializationResult: vi.fn().mockResolvedValue({}),
100
+ reconnectMcpServer: vi.fn().mockResolvedValue(undefined),
101
+ toggleMcpServer: vi.fn().mockResolvedValue(undefined),
102
+ stopTask: vi.fn().mockResolvedValue(undefined),
98
103
  [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined),
99
104
  _abortController: abortController,
100
105
  _mockHelpers: {
@@ -158,6 +163,7 @@ export function createSuccessResult(
158
163
  is_error: false,
159
164
  num_turns: 1,
160
165
  result: "Done",
166
+ stop_reason: null,
161
167
  total_cost_usd: 0.01,
162
168
  usage: {
163
169
  input_tokens: 100,
@@ -190,6 +196,7 @@ export function createErrorResult(
190
196
  duration_ms: 100,
191
197
  duration_api_ms: 50,
192
198
  num_turns: 1,
199
+ stop_reason: null,
193
200
  total_cost_usd: 0.01,
194
201
  usage: {
195
202
  input_tokens: 100,