UNPKG

@posthog/agent

Version:

TypeScript agent framework wrapping Claude Agent SDK with Git-based task execution for PostHog

268 lines (239 loc) 7.04 kB
// A pushable async iterable: allows you to push items and consume them with for-await. import { readFileSync } from "node:fs"; import { platform } from "node:os"; import type { Readable, Writable } from "node:stream"; import { ReadableStream, WritableStream } from "node:stream/web"; import type { Logger } from "@/utils/logger.js"; // Useful for bridging push-based and async-iterator-based code. export class Pushable<T> implements AsyncIterable<T> { private queue: T[] = []; private resolvers: ((value: IteratorResult<T>) => void)[] = []; private done = false; push(item: T) { const resolve = this.resolvers.shift(); if (resolve) { resolve({ value: item, done: false }); } else { this.queue.push(item); } } end() { this.done = true; for (const resolve of this.resolvers) { resolve({ value: undefined as unknown as T, done: true }); } this.resolvers = []; } [Symbol.asyncIterator](): AsyncIterator<T> { return { next: (): Promise<IteratorResult<T>> => { if (this.queue.length > 0) { const value = this.queue.shift() as T; return Promise.resolve({ value, done: false }); } if (this.done) { return Promise.resolve({ value: undefined as unknown as T, done: true, }); } return new Promise<IteratorResult<T>>((resolve) => { this.resolvers.push(resolve); }); }, }; } } // Helper to convert Node.js streams to Web Streams export function nodeToWebWritable( nodeStream: Writable, ): WritableStream<Uint8Array> { return new WritableStream<Uint8Array>({ write(chunk) { return new Promise<void>((resolve, reject) => { nodeStream.write(Buffer.from(chunk), (err) => { if (err) { reject(err); } else { resolve(); } }); }); }, }); } export function nodeToWebReadable( nodeStream: Readable, ): ReadableStream<Uint8Array> { return new ReadableStream<Uint8Array>({ start(controller) { nodeStream.on("data", (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)); }); nodeStream.on("end", () => controller.close()); nodeStream.on("error", (err) => controller.error(err)); }, }); } export function unreachable(value: never, logger: Logger) { let valueAsString: string; try { valueAsString = JSON.stringify(value); } catch { valueAsString = value; } logger.error(`Unexpected case: ${valueAsString}`); } export function sleep(time: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, time)); } interface ManagedSettings { permissions?: { allow?: string[]; deny?: string[]; }; env?: Record<string, string>; } // Following the rules in https://docs.anthropic.com/en/docs/claude-code/settings#settings-files // This can be removed once the SDK supports it natively. function getManagedSettingsPath(): string { const os = platform(); switch (os) { case "darwin": return "/Library/Application Support/ClaudeCode/managed-settings.json"; case "linux": // including WSL return "/etc/claude-code/managed-settings.json"; case "win32": return "C:\\ProgramData\\ClaudeCode\\managed-settings.json"; default: return "/etc/claude-code/managed-settings.json"; } } export function loadManagedSettings(): ManagedSettings | null { try { return JSON.parse( readFileSync(getManagedSettingsPath(), "utf8"), ) as ManagedSettings; } catch { return null; } } export function applyEnvironmentSettings(settings: ManagedSettings): void { if (settings.env) { for (const [key, value] of Object.entries(settings.env)) { process.env[key] = value; } } } export type StreamPair = { readable: globalThis.ReadableStream<Uint8Array>; writable: globalThis.WritableStream<Uint8Array>; }; export type BidirectionalStreamPair = { client: StreamPair; agent: StreamPair; }; function pushableToReadableStream( pushable: Pushable<Uint8Array>, ): globalThis.ReadableStream<Uint8Array> { const iterator = pushable[Symbol.asyncIterator](); return new ReadableStream<Uint8Array>({ async pull(controller) { const { value, done } = await iterator.next(); if (done) { controller.close(); } else { controller.enqueue(value); } }, }) as unknown as globalThis.ReadableStream<Uint8Array>; } export function createBidirectionalStreams(): BidirectionalStreamPair { const clientToAgentPushable = new Pushable<Uint8Array>(); const agentToClientPushable = new Pushable<Uint8Array>(); const clientToAgentReadable = pushableToReadableStream(clientToAgentPushable); const agentToClientReadable = pushableToReadableStream(agentToClientPushable); const clientToAgentWritable = new WritableStream<Uint8Array>({ write(chunk) { clientToAgentPushable.push(chunk); }, close() { clientToAgentPushable.end(); }, }) as globalThis.WritableStream<Uint8Array>; const agentToClientWritable = new WritableStream<Uint8Array>({ write(chunk) { agentToClientPushable.push(chunk); }, close() { agentToClientPushable.end(); }, }) as globalThis.WritableStream<Uint8Array>; return { client: { readable: agentToClientReadable, writable: clientToAgentWritable, }, agent: { readable: clientToAgentReadable, writable: agentToClientWritable, }, }; } export interface ExtractLinesResult { content: string; wasLimited: boolean; linesRead: number; } /** * Extracts lines from file content with byte limit enforcement. * * @param fullContent - The complete file content * @param maxContentLength - Maximum number of UTF-16 Code Units to return * @returns Object containing extracted content and metadata */ export function extractLinesWithByteLimit( fullContent: string, maxContentLength: number, ): ExtractLinesResult { if (fullContent === "") { return { content: "", wasLimited: false, linesRead: 1, }; } let linesSeen = 0; let index = 0; linesSeen = 0; let contentLength = 0; let wasLimited = false; while (true) { const nextIndex = fullContent.indexOf("\n", index); if (nextIndex < 0) { // Last line in file (no trailing newline) if (linesSeen > 0 && fullContent.length > maxContentLength) { wasLimited = true; break; } linesSeen += 1; contentLength = fullContent.length; break; } else { // Line with newline - include up to the newline const newContentLength = nextIndex + 1; if (linesSeen > 0 && newContentLength > maxContentLength) { wasLimited = true; break; } linesSeen += 1; contentLength = newContentLength; index = newContentLength; } } return { content: fullContent.slice(0, contentLength), wasLimited, linesRead: linesSeen, }; }