Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions .claude/skills/quill-code/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
---
name: quill-code
description: Edit the @posthog/quill design system locally and consume the change in this repo (posthog-code) before it is published to npm. Use when changing quill components/primitives/tokens, when a quill change must be tested inside the Code app, or when the user mentions quill, the design system, the .local-quill tarball, or the @posthog/quill pnpm override.
---

# quill-code

`@posthog/quill` is **not** in this repo. It is a published catalog dependency whose
source lives in the main PostHog monorepo at `../posthog/packages/quill`. To test an
unpublished quill change inside this repo (posthog-code), you build quill, pack it to a
tarball, and point a pnpm `overrides` entry at that tarball. This is a **temporary
local-dev** state — revert before merging (see below).

## Quill layout (where to edit)

`../posthog/packages/quill` is the **workspace** (`@posthog/quill-workspace`). It
contains sub-packages, each a layer of the design system:

- `packages/primitives/src` — base components (Card, Badge, Button, Progress, …)
- `packages/components/src` — composed components (DataTable, DateTimePicker, Metric)
- `packages/blocks/src` — **product-level blocks** (e.g. `ExperimentCard`). Add the
file here and export it from `packages/blocks/src/index.ts`.
- `packages/quill/src` — the **aggregate** that re-exports all layers as `@posthog/quill`.

A new export in any sub-package flows to `@posthog/quill` automatically on build.

## The loop (every quill change)

1. Edit/add the component in the right sub-package (above) and export it from that
package's `src/index.ts`.
2. Re-sync into this repo manually — build → pack → point the override → reinstall:

```bash
QUILL_DIR="${QUILL_DIR:-../posthog/packages/quill/packages/quill}"

# a. Build the WHOLE quill workspace (two levels up from the aggregate), so the
# sub-packages rebuild BEFORE the aggregate bundles them.
( cd "$QUILL_DIR/../.." && pnpm build )

# b. Pack into .local-quill/ under a UNIQUE filename. pnpm pins a tarball by
# integrity, so a stable name caches stale across re-syncs — drop old local
# tarballs first, then rename the packed file to a unique local name.
rm -f .local-quill/posthog-quill-local-*.tgz
( cd "$QUILL_DIR" && npm pack --pack-destination "$(git rev-parse --show-toplevel)/.local-quill" )
mv .local-quill/posthog-quill-[0-9]*.tgz ".local-quill/posthog-quill-local-$(git rev-parse --short HEAD)-$$.tgz"

# c. Point the override at the new tarball, then reinstall.
# Edit pnpm-workspace.yaml so overrides['@posthog/quill'] = file:./.local-quill/<new file>
pnpm install
```

> Building only the aggregate (`packages/quill/packages/quill`) re-bundles the
> sub-packages' **stale** `dist/`, so edits to primitives/components/blocks are
> silently dropped. Always build at the workspace root.
3. Verify in the Code app (`pnpm dev`, or the `test-electron-app` skill). Repeat from 1.

After every quill edit you **must** re-run the sync — the app consumes the tarball, not
the quill source, so unsynced edits are invisible here.

If quill lives elsewhere, set `QUILL_DIR=/abs/path/to/posthog/packages/quill/packages/quill`.

## Why a tarball, not `link:`

`link:` symlinks into the mono's `node_modules` and drags in its **React 18** types,
colliding with this repo's **React 19** (dual-React → broken typecheck +
invalid-hook-call at runtime). The tarball is copied into this repo's store and deduped
against React 19. The filename is **content-hashed** because pnpm pins a tarball by
integrity, so a stable filename gets cached stale across re-syncs.

## The override (what the script rewrites)

In `pnpm-workspace.yaml`, under `overrides:`:

```yaml
'@posthog/quill': file:./.local-quill/posthog-quill-local-<hash>.tgz
```

There is also a permanent pin you should leave alone:

```yaml
'@posthog/quill>@base-ui/react': ^1.3.0 # quill ships a broken catalog: dep; do not remove
```

## Reverting (before merge)

The override is local-dev only. Once the quill change is published to npm:

1. Bump the catalog version in `pnpm-workspace.yaml` (`'@posthog/quill': 0.3.0-beta.x`)
to the published version.
2. Restore the override line to point back at the catalog, or remove the `file:` override.
3. `pnpm install`.

Do not commit a `file:./.local-quill/...` override or the `.local-quill/` tarballs.
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { agentChatCoreModule } from "@posthog/core/agent-chat/agentChat.module";
import { autoresearchCoreModule } from "@posthog/core/autoresearch/autoresearch.module";
import { billingCoreModule } from "@posthog/core/billing/billing.module";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import { inboxCoreModule } from "@posthog/core/inbox/inbox.module";
import { githubConnectModule } from "@posthog/core/integrations/githubConnect.module";
import { localMcpCoreModule } from "@posthog/core/local-mcp/local-mcp.module";
Expand Down Expand Up @@ -37,6 +38,7 @@ export function registerDesktopContributions(): void {
autoresearchCoreModule,
billingUiModule,
billingCoreModule,
taskThreadCoreModule,
browserTabsUiModule,
cloneUiModule,
connectivityUiModule,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "reflect-metadata";
import { TypedContainer } from "@inversifyjs/strongly-typed";
import { taskThreadCoreModule } from "@posthog/core/canvas/taskThread.module";
import { setRootContainer } from "@posthog/di/container";
import { ROOT_LOGGER, type RootLogger } from "@posthog/di/logger";
import {
Expand Down Expand Up @@ -96,4 +97,6 @@ container.bind(MCP_SANDBOX_PROXY_URL).toConstantValue(() => {
return sandboxProxyUrl;
});

container.load(taskThreadCoreModule);

setRootContainer(container);
Original file line number Diff line number Diff line change
Expand Up @@ -1961,9 +1961,23 @@ describe("CodexAppServerAgent", () => {
} as unknown as PromptRequest);

// The single turn/completed resolves both the original and the folded prompt.
stub.emit("thread/tokenUsage/updated", {
tokenUsage: {
last: {
totalTokens: 45,
inputTokens: 30,
cachedInputTokens: 5,
outputTokens: 10,
},
},
});
stub.emit("turn/completed", { turn: { status: "completed" } });
expect((await first).stopReason).toBe("end_turn");
expect((await second).stopReason).toBe("end_turn");
const [firstResult, secondResult] = await Promise.all([first, second]);
expect(firstResult).toMatchObject({
stopReason: "end_turn",
usage: { totalTokens: 45 },
});
expect(secondResult).toEqual({ stopReason: "end_turn" });

const steer = stub.requests.find((r) => r.method === "turn/steer");
expect(steer?.params).toMatchObject({
Expand Down Expand Up @@ -2126,7 +2140,19 @@ describe("CodexAppServerAgent", () => {
},
});
stub.emit("turn/completed", { turn: { status: "completed" } });
await done;
const result = await done;

expect(result).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 60,
outputTokens: 30,
cachedReadTokens: 10,
cachedWriteTokens: 0,
thoughtTokens: 5,
totalTokens: 100,
},
});

const turnComplete = extNotifications.find(
(n) => n.method === "_posthog/turn_complete",
Expand Down Expand Up @@ -2818,6 +2844,16 @@ describe("CodexAppServerAgent", () => {
text: "The implementation plan is ready.",
},
});
stub.emit("thread/tokenUsage/updated", {
tokenUsage: {
last: {
totalTokens: 30,
inputTokens: 20,
outputTokens: 10,
reasoningOutputTokens: 2,
},
},
});
stub.emit("turn/completed", {
turn: { id: "turn_1", status: "completed" },
});
Expand All @@ -2826,10 +2862,31 @@ describe("CodexAppServerAgent", () => {
await waitUntil(
() => stub.requests.filter((r) => r.method === "turn/start").length >= 2,
);
stub.emit("thread/tokenUsage/updated", {
tokenUsage: {
last: {
totalTokens: 50,
inputTokens: 35,
cachedInputTokens: 5,
outputTokens: 10,
reasoningOutputTokens: 3,
},
},
});
stub.emit("turn/completed", {
turn: { id: "turn_2", status: "completed" },
});
expect((await done).stopReason).toBe("end_turn");
expect(await done).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 55,
outputTokens: 20,
cachedReadTokens: 5,
cachedWriteTokens: 0,
thoughtTokens: 5,
totalTokens: 80,
},
});

// The approval renders as the plan-approval UI (switch_mode + the plan text).
expect(permissionRequests).toHaveLength(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ import {
} from "./app-server-client";
import { handleServerRequest } from "./approvals";
import {
type AccumulatedUsage,
buildSdkSessionParams,
buildTurnCompleteParams,
buildUsageBreakdownParams,
Expand Down Expand Up @@ -162,6 +161,31 @@ function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null {
}
}

function mergePromptUsage(
left: PromptResponse["usage"],
right: PromptResponse["usage"],
): PromptResponse["usage"] {
if (!left) return right;
if (!right) return left;
return {
inputTokens: left.inputTokens + right.inputTokens,
outputTokens: left.outputTokens + right.outputTokens,
cachedReadTokens:
(left.cachedReadTokens ?? 0) + (right.cachedReadTokens ?? 0),
cachedWriteTokens:
(left.cachedWriteTokens ?? 0) + (right.cachedWriteTokens ?? 0),
thoughtTokens: (left.thoughtTokens ?? 0) + (right.thoughtTokens ?? 0),
totalTokens: left.totalTokens + right.totalTokens,
};
}

function mergePromptResponses(
left: PromptResponse,
right: PromptResponse,
): PromptResponse {
return { ...right, usage: mergePromptUsage(left.usage, right.usage) };
}

// The native app-server owns its config; BaseAcpAgent only calls dispose() on this.
class NoopSettingsManager implements BaseSettingsManager {
constructor(private cwd: string) {}
Expand Down Expand Up @@ -219,7 +243,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
/** The in-flight turn's <proposed_plan>, streamed or completed (drives the implement handoff). */
private planProposal?: { itemId: string; text: string };
/** Idle signal deferred while the plan handoff keeps this prompt busy. */
private deferredTurnComplete?: { usage: AccumulatedUsage };
private deferredTurnComplete?: { usage: PromptResponse["usage"] };
/** Settles the pending plan-approval race on cancel/close/preempting prompt. */
private planHandoffCancel?: () => void;
private readonly mcp = new McpManager();
Expand Down Expand Up @@ -727,15 +751,16 @@ export class CodexAppServerAgent extends BaseAcpAgent {
return undefined;
});
this.turns.onSteered(steerRes?.turnId);
return { stopReason: await this.turns.awaitCompletion() };
const response = await this.turns.awaitCompletion();
return { stopReason: response.stopReason };
}
if (this.turns.isPending) {
// A turn is pending but has no turnId yet, so we can't steer; fail fast.
throw new Error("prompt() called while a turn is already in progress");
}

const stopReason = await this.runTurn(input);
return { stopReason: await this.maybeOfferPlanImplementation(stopReason) };
const response = await this.runTurn(input);
return this.maybeOfferPlanImplementation(response);
}

private async handleGoalCommand(command: GoalCommand): Promise<void> {
Expand Down Expand Up @@ -850,7 +875,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
}

/** Start one codex turn and await its completion. */
private async runTurn(input: CodexUserInput[]): Promise<StopReason> {
private async runTurn(input: CodexUserInput[]): Promise<PromptResponse> {
this.lastAgentMessage = "";
this.resetUsage();
this.planProposal = undefined;
Expand Down Expand Up @@ -895,12 +920,12 @@ export class CodexAppServerAgent extends BaseAcpAgent {
* back into another plan turn, whose revised plan prompts again.
*/
private async maybeOfferPlanImplementation(
stopReason: StopReason,
): Promise<StopReason> {
let reason = stopReason;
response: PromptResponse,
): Promise<PromptResponse> {
let result = response;
try {
while (
reason === "end_turn" &&
result.stopReason === "end_turn" &&
this.config.mode === "plan" &&
this.planProposal &&
!this.session.cancelled
Expand All @@ -911,7 +936,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
// Re-check after the await: a cancel that raced the response wins, so a
// late accept can never start implementation on a cancelled prompt.
if (this.session.cancelled) {
reason = "cancelled";
result = { ...result, stopReason: "cancelled" };
break;
}
// A picker change while approval was open owns the mode. Never let a
Expand All @@ -921,19 +946,25 @@ export class CodexAppServerAgent extends BaseAcpAgent {
this.config.setOption("mode", outcome.mode);
this.emitCurrentMode(outcome.mode);
this.emitConfigOptions();
reason = await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE);
result = mergePromptResponses(
result,
await this.runFollowUpTurn(IMPLEMENT_PLAN_MESSAGE),
);
break;
}
if (outcome.kind === "feedback") {
reason = await this.runFollowUpTurn(outcome.feedback);
result = mergePromptResponses(
result,
await this.runFollowUpTurn(outcome.feedback),
);
continue;
}
break;
}
} finally {
await this.flushDeferredTurnComplete(reason);
await this.flushDeferredTurnComplete(result.stopReason);
}
return reason;
return result;
}

/**
Expand All @@ -949,7 +980,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
}

/** Run an adapter-initiated turn, echoed as a user message like a host prompt. */
private async runFollowUpTurn(text: string): Promise<StopReason> {
private async runFollowUpTurn(text: string): Promise<PromptResponse> {
this.broadcastUserInput([{ type: "text", text }]);
return this.runTurn(toCodexInput([{ type: "text", text }]));
}
Expand Down Expand Up @@ -1448,7 +1479,10 @@ export class CodexAppServerAgent extends BaseAcpAgent {
await this.emitTurnCompleteSignal(reason, usage);
await this.emitUsageBreakdown(contextUsed);
}
pending.resolve(reason);
pending.resolve({
stopReason: reason,
...(usage ? { usage } : {}),
});
}

/** Whether maybeOfferPlanImplementation will run for a turn that ended this way. */
Expand All @@ -1464,7 +1498,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
/** Emit the cloud idle signal `_posthog/turn_complete` (only with a taskRunId). */
private async emitTurnCompleteSignal(
reason: StopReason,
usage: AccumulatedUsage,
usage: PromptResponse["usage"],
): Promise<void> {
if (!this.sessionId || !this.taskRunId) return;
await this.client
Expand Down
Loading
Loading