Skip to content
Merged
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
19 changes: 10 additions & 9 deletions .agents/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,13 +250,13 @@ and upstack bindings and returns `stack-coordinator-sync-required`; it never
rebases and never pushes another layer, though it does push its own layer branch
in Phase 0 and Phase 3b. Otherwise, skip needless rebases.

**Bot pings by iteration.** Never ping GitHub Copilot and never treat Copilot as
an expected review signal; quota exhaustion otherwise leaves the loop waiting
forever. Initial PR pushes do not need a direct review ping. Subsequent
fix-iteration re-pushes → `@codex review`. For a >250-file diff, also ping
`@greptile` and `@coderabbit` (separate comments). Phase 1 still waits for the
expected review signals to settle before fixing. This is the playbook's Phase 4
rule — defer to it for exact bodies.
**Bot pings by iteration.** Never ping GitHub Copilot and never ping `@codex` —
neither is an expected review signal here, and Copilot quota exhaustion
otherwise leaves the loop waiting forever. No push, initial or fix-iteration,
gets a direct review ping. For a >250-file diff only, ping `@greptile` and
`@coderabbit` (separate comments). Phase 1 still waits for the expected review
signals to settle before fixing. This is the playbook's Phase 4 rule — defer to
it for exact bodies.

**Merge needs admin.** `main` is ruleset-guarded —
`gh pr merge --squash --match-head-commit "$QUALITY_VALIDATED_SHA"` will show
Expand Down Expand Up @@ -328,8 +328,9 @@ self-resume signal. Either:
- **Phase 3a Rebase / 3b Fix / 3c Merge / 3d Force-finalize** — per the playbook.
Force-finalize runs once: ignore review comments (bookkeep their IDs), fix only
CI, never delete/skip tests or weaken lint/tsconfig, then merge on green.
- **Phase 4/5:** post the iteration's `@codex review` ping after a fix push,
update state, schedule the next wake (or stop per harness above).
- **Phase 4/5:** post only the >250-file bot pings (Phase 4 sends no ping for an
ordinary push), update state, schedule the next wake (or stop per harness
above).
- **Stack mode:** the same phases run, minus 3a, 3c.1–3c.5, and 3d. Phase 2
routes remaining fix work to 3b and terminal-green to 3c.0
(`ready-stacked`); the spent iteration budget escalates via
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ jobs:
src/main/services/externalSessions/discoveryUtils.test.ts
src/main/services/externalSessions/externalSessionsService.test.ts
src/main/services/lanes/laneStorageLifecycle.test.ts
src/main/services/chat/piSession.test.ts

# Covers the Windows runtime startup timing and `connectSpawnedRuntime`
# retry path. This suite spawns real `ade serve` daemons and connects to
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ ade --role cto actions list --domain attention --text # discover account-wide Ac
ade --role cto actions run attention.getSnapshot --input-json '{"since":0}' --json
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
ade actions run pty.resumeSession --arg sessionId=session-id
ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged
ade actions run external-sessions.list --input-json '{"scope":"project","limit":20}' --text # claude/codex/cursor/droid/opencode/pi sessions on this machine; discovery that cannot run — `opencode` is not installed, say — fails the call when that provider is the only one asked for, rather than reporting an empty list; in a multi-provider scan it is skipped and logged
ade actions run external-sessions.import --input-json '{"provider":"codex","sessionId":"thread-id","laneId":"lane-1","target":"cli","mode":"resume"}' --text
ade actions run ai.piLoginProviders --text # Pi providers that can be signed into, with the auth methods each accepts and whether it is already configured
ade --role cto actions run ai.piLoginStart --input-json '{"providerId":"anthropic"}' --json # blocks until the human finishes Pi's own OAuth/device-code flow
Expand Down
2 changes: 1 addition & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2939,7 +2939,7 @@ describe("ADE CLI", () => {
"--provider",
"mystery",
]),
).toThrow(/Provider must be claude, codex, cursor, droid, opencode, or shell/);
).toThrow(/Provider must be claude, codex, cursor, droid, opencode, pi, or shell/);
});

it("does not treat new --mode values as subcommands", () => {
Expand Down
12 changes: 6 additions & 6 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1582,7 +1582,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
--type <subagent|peer> Required for a parented agent spawn. Use subagent
whenever you will need, join, or review the result;
use peer only for fire-and-forget work.
--provider <name> claude | codex | cursor | droid | opencode. CLI mode also accepts shell.
--provider <name> claude | codex | cursor | droid | opencode | pi. CLI mode also accepts shell.
--model <id> Runtime model id.
--reasoning-effort <v> Reasoning tier. Alias: --effort.
--permissions <mode> default | auto | plan | edit | full-auto | config-toml.
Expand Down Expand Up @@ -1944,7 +1944,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
Start a tracked provider CLI session

Create flags:
--provider <name> claude | codex | cursor | droid | opencode.
--provider <name> claude | codex | cursor | droid | opencode | pi.
--model <id> Model id, also sent as modelId for runtime parity.
--reasoning-effort <v> Reasoning tier when the selected model supports it.
Common tiers: minimal, low, medium, high, xhigh, ultra, ultracode.
Expand Down Expand Up @@ -1995,7 +1995,7 @@ const HELP_BY_COMMAND: Record<string, string> = {
Flags:
--personal Use machine-owned chats instead of a project/lane chat.
--lane <lane> Lane/worktree for the chat.
--provider <name> claude | codex | cursor | droid | opencode.
--provider <name> claude | codex | cursor | droid | opencode | pi.
--model <id> Model id, also sent as modelId for runtime parity.
--reasoning-effort <v> Reasoning tier when supported by the model.
--effort <v> Alias for --reasoning-effort.
Expand Down Expand Up @@ -4721,10 +4721,10 @@ function buildNewChatPlan(args: string[], defaultMode: "chat" | "cli"): CliPlan
const printConfig = readFlag(args, ["--print-config", "--dry-run"]);

if (!isLaunchProfile(provider)) {
throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, or shell.");
throw new CliUsageError("Provider must be claude, codex, cursor, droid, opencode, pi, or shell.");
}
if (mode === "chat" && provider === "shell") {
throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, or opencode.");
throw new CliUsageError("Chat mode provider must be claude, codex, cursor, droid, opencode, or pi.");
}
if (mode === "cli") {
const effectivePermissionMode = permissionMode ?? "default";
Expand Down Expand Up @@ -6630,7 +6630,7 @@ function buildCliSessionStartPlan(
);
if (!isLaunchProfile(rawProvider)) {
throw new CliUsageError(
"provider must be one of claude, codex, cursor, droid, opencode, or shell.",
"provider must be one of claude, codex, cursor, droid, opencode, pi, or shell.",
);
}
const provider: LaunchProfile = rawProvider;
Expand Down
114 changes: 102 additions & 12 deletions apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { piModelDescriptorsFromInventory, probePiProfileInventory, resolvePiInstallation } from "../ai/piInstallation";
import { PI_APPROVAL_ALLOW } from "../chat/piSdkEventMapper";
import { classifyPiSessionFile, piSessionRootForEnvironment } from "../chat/piSessionStore";
import {
acquirePiSdkConnection,
releasePiSdkConnection,
Expand All @@ -25,7 +26,7 @@ type Fixture = {
root: string;
cwd: string;
agentDir: string;
sessionDir: string;
sessionRoot: string;
modelPath: string;
};

Expand Down Expand Up @@ -124,10 +125,14 @@ function createFixture(options?: { configured?: boolean; modelId?: string }): Fi
tempRoots.push(root);
const cwd = path.join(root, "worktree");
const agentDir = path.join(root, "agent");
const sessionDir = path.join(root, "sessions");
// Mirror a real Pi profile: the store root holds one directory per working
// directory, and every session file lives inside one of those. A flat
// sessionRoot beside agentDir is a layout Pi never produces, and testing
// against it hid the fact that ADE authorized the wrong root entirely.
const sessionRoot = path.join(agentDir, "sessions");
fs.mkdirSync(cwd, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
fs.mkdirSync(sessionRoot, { recursive: true });
const modelId = options?.modelId ?? "test-model";
const provider: Record<string, unknown> = {
baseUrl: `${serverBaseUrl}/v1`,
Expand All @@ -147,7 +152,7 @@ function createFixture(options?: { configured?: boolean; modelId?: string }): Fi
if (options?.configured !== false) provider.apiKey = "ade-local-test-key";
const modelPath = path.join(agentDir, "models.json");
fs.writeFileSync(modelPath, JSON.stringify({ providers: { "ade-local": provider } }));
return { root, cwd, agentDir, sessionDir, modelPath };
return { root, cwd, agentDir, sessionRoot, modelPath };
}

function installedPiArgs(fixture: Fixture, poolKey: string, options?: {
Expand All @@ -157,13 +162,14 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: {
askUserTool?: boolean;
approvalTools?: string[];
extensions?: boolean;
sessionStorageDir?: string;
}): {
poolKey: string;
packageRoot: string;
packageEntry: string;
cwd: string;
agentDir: string;
sessionDir: string;
sessionRoot: string;
modelRef: { provider: string; id: string };
thinkingLevel: string;
systemPrompt: string;
Expand All @@ -172,6 +178,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: {
askUserTool?: boolean;
approvalTools?: string[];
extensions?: boolean;
sessionStorageDir?: string;
baseEnv: NodeJS.ProcessEnv;
} {
const installation = resolvePiInstallation({
Expand All @@ -188,7 +195,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: {
packageEntry: installation.packageEntry,
cwd: fixture.cwd,
agentDir: fixture.agentDir,
sessionDir: fixture.sessionDir,
sessionRoot: fixture.sessionRoot,
modelRef: { provider: "ade-local", id: options?.modelId ?? "test-model" },
thinkingLevel: "off",
systemPrompt: "You are the isolated ADE Pi integration test model.",
Expand All @@ -197,6 +204,7 @@ function installedPiArgs(fixture: Fixture, poolKey: string, options?: {
...(options?.askUserTool ? { askUserTool: true } : {}),
...(options?.approvalTools ? { approvalTools: options.approvalTools } : {}),
...(options?.extensions ? { extensions: true } : {}),
...(options?.sessionStorageDir ? { sessionStorageDir: options.sessionStorageDir } : {}),
baseEnv: {
PATH: process.env.PATH ?? "",
HOME: fixture.root,
Expand All @@ -215,11 +223,27 @@ async function acquireTracked(fixture: Fixture, poolKey: string, options?: Param
return tracked;
}

/** Every native session under the store root, at whatever depth Pi nested it. */
function sessionFiles(fixture: Fixture): string[] {
return fs.readdirSync(fixture.sessionDir)
.filter((name) => name.endsWith(".jsonl"))
.map((name) => path.join(fixture.sessionDir, name))
.filter((filePath) => fs.lstatSync(filePath).isFile());
const found: string[] = [];
const pending = [fixture.sessionRoot];
while (pending.length) {
const directory = pending.pop()!;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) pending.push(filePath);
else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(filePath);
}
}
return found.sort();
}

async function waitFor(predicate: () => boolean, timeoutMs = 10_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for the installed Pi worker to flush its session.");
await new Promise((resolve) => setTimeout(resolve, 25));
}
}

async function disposeConnection(connection: { poolKey: string; generation: number; pooled: PiSdkPooled }): Promise<void> {
Expand Down Expand Up @@ -303,6 +327,72 @@ describeInstalledPi("installed Pi SDK worker", () => {
expect(sessionFiles(fixture)).toEqual([]);
});

// The shipped bug in one test: ADE authorized a session root that Pi never
// writes into, and validated the worker's session path before Pi had created
// the file. Both halves are asserted against the real installed Pi.
it("creates its session inside the authorized store and authorizes it before Pi writes the file", async () => {
const fixture = createFixture();
const connection = await acquireTracked(fixture, `store:${Date.now()}`);
const planned = connection.pooled.sessionFile!;

expect(path.isAbsolute(planned)).toBe(true);
// Pi nests one directory per working directory beneath the store root.
expect(path.relative(fixture.sessionRoot, planned).split(path.sep).length).toBe(2);
// Nothing on disk yet: Pi flushes on the first assistant message.
expect(fs.existsSync(planned)).toBe(false);
expect(sessionFiles(fixture)).toEqual([]);
expect(classifyPiSessionFile({
filePath: planned,
cwd: fixture.cwd,
sessionId: connection.pooled.sessionId,
sessionRoot: fixture.sessionRoot,
}).state).toBe("pending");

await connection.pooled.sendPrompt({ prompt: "write the session header" });
await nextRequest();
await waitFor(() => fs.existsSync(planned));

expect(sessionFiles(fixture).map((file) => fs.realpathSync(file))).toEqual([fs.realpathSync(planned)]);
expect(classifyPiSessionFile({
filePath: planned,
cwd: fixture.cwd,
sessionId: connection.pooled.sessionId,
sessionRoot: fixture.sessionRoot,
})).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) });
// The store root ADE authorizes is exactly the one Pi's own CLI resolves.
expect(piSessionRootForEnvironment({ PI_CODING_AGENT_DIR: fixture.agentDir })).toBe(fixture.sessionRoot);
});

// The other half of the store contract: when the user configured a session
// directory, Pi uses it flat and verbatim, and ADE must authorize that same
// directory rather than the profile default.
it("writes into a user-configured session directory when one is set", async () => {
const fixture = createFixture();
const configured = path.join(fixture.root, "configured-sessions");
fs.mkdirSync(configured, { recursive: true });
const connection = await acquireTracked(fixture, `configured:${Date.now()}`, {
sessionStorageDir: configured,
});
const planned = connection.pooled.sessionFile!;

expect(path.dirname(planned)).toBe(configured);
await connection.pooled.sendPrompt({ prompt: "write the session header" });
await nextRequest();
await waitFor(() => fs.existsSync(planned));

expect(classifyPiSessionFile({
filePath: planned,
cwd: fixture.cwd,
sessionId: connection.pooled.sessionId,
sessionRoot: configured,
})).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) });
expect(sessionFiles(fixture)).toEqual([]);
expect(piSessionRootForEnvironment({
PI_CODING_AGENT_DIR: fixture.agentDir,
PI_CODING_AGENT_SESSION_DIR: configured,
})).toBe(configured);
});
Comment on lines +369 to +394

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Pass a sessionRoot that matches the configured storage directory.

installedPiArgs always sets sessionRoot: fixture.sessionRoot (Line 198). This test sets sessionStorageDir to configured, which sits outside fixture.sessionRoot. In production piSessionStoreForEnvironment returns root === storageDir for the configured case (apps/desktop/src/main/services/chat/piSessionStore.ts Line 93), so this pair never occurs.

The test passes today because the create path does not run validatedSessionFile; the worker authorizes a session file only on resume. Line 383 then classifies against configured, not against the sessionRoot the worker actually received. The two values disagree.

Let the helper accept an overriding sessionRoot, and pass configured for both. A later resume-from-configured-storage test can then reuse the same fixture without failing for an unrelated reason.

💚 Proposed fixture alignment
   const connection = await acquireTracked(fixture, `configured:${Date.now()}`, {
+    sessionRoot: configured,
     sessionStorageDir: configured,
   });

In installedPiArgs, add the override:

   extensions?: boolean;
+  sessionRoot?: string;
   sessionStorageDir?: string;
 }): {
-    sessionRoot: fixture.sessionRoot,
+    sessionRoot: options?.sessionRoot ?? fixture.sessionRoot,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("writes into a user-configured session directory when one is set", async () => {
const fixture = createFixture();
const configured = path.join(fixture.root, "configured-sessions");
fs.mkdirSync(configured, { recursive: true });
const connection = await acquireTracked(fixture, `configured:${Date.now()}`, {
sessionStorageDir: configured,
});
const planned = connection.pooled.sessionFile!;
expect(path.dirname(planned)).toBe(configured);
await connection.pooled.sendPrompt({ prompt: "write the session header" });
await nextRequest();
await waitFor(() => fs.existsSync(planned));
expect(classifyPiSessionFile({
filePath: planned,
cwd: fixture.cwd,
sessionId: connection.pooled.sessionId,
sessionRoot: configured,
})).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) });
expect(sessionFiles(fixture)).toEqual([]);
expect(piSessionRootForEnvironment({
PI_CODING_AGENT_DIR: fixture.agentDir,
PI_CODING_AGENT_SESSION_DIR: configured,
})).toBe(configured);
});
it("writes into a user-configured session directory when one is set", async () => {
const fixture = createFixture();
const configured = path.join(fixture.root, "configured-sessions");
fs.mkdirSync(configured, { recursive: true });
const connection = await acquireTracked(fixture, `configured:${Date.now()}`, {
sessionRoot: configured,
sessionStorageDir: configured,
});
const planned = connection.pooled.sessionFile!;
expect(path.dirname(planned)).toBe(configured);
await connection.pooled.sendPrompt({ prompt: "write the session header" });
await nextRequest();
await waitFor(() => fs.existsSync(planned));
expect(classifyPiSessionFile({
filePath: planned,
cwd: fixture.cwd,
sessionId: connection.pooled.sessionId,
sessionRoot: configured,
})).toEqual({ state: "authorized", filePath: fs.realpathSync(planned) });
expect(sessionFiles(fixture)).toEqual([]);
expect(piSessionRootForEnvironment({
PI_CODING_AGENT_DIR: fixture.agentDir,
PI_CODING_AGENT_SESSION_DIR: configured,
})).toBe(configured);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/services/__tests__/piSdk.integration.test.ts` around
lines 369 - 394, Update the installedPiArgs test helper to accept an optional
sessionRoot override, defaulting to fixture.sessionRoot, and pass configured for
this test’s connection setup. Ensure the worker receives configured as
sessionRoot and keep classifyPiSessionFile using the same configured value so
both paths reflect the production configuration.


it("initializes an isolated profile, prompts with an image, reports auth, and preserves thinking levels", async () => {
const fixture = createFixture();
const connection = await acquireTracked(fixture, `integration:${Date.now()}`);
Expand Down Expand Up @@ -355,7 +445,7 @@ describeInstalledPi("installed Pi SDK worker", () => {
await disposeConnection(byFile);

const stalePointer = await acquireTracked(fixture, `resume:stale:${Date.now()}`, {
session: { sessionFile: path.join(fixture.sessionDir, "missing.jsonl"), sessionId: original.sessionId },
session: { sessionFile: path.join(fixture.sessionRoot, "missing.jsonl"), sessionId: original.sessionId },
});
expect(fs.realpathSync(stalePointer.pooled.sessionFile!)).toBe(fs.realpathSync(original.sessionFile));
expect(stalePointer.pooled.sessionId).toBe(original.sessionId);
Expand All @@ -375,7 +465,7 @@ describeInstalledPi("installed Pi SDK worker", () => {
expect(sessionFiles(fixture)).toEqual(before);

if (process.platform !== "win32") {
const symlink = path.join(fixture.sessionDir, "linked.jsonl");
const symlink = path.join(fixture.sessionRoot, "linked.jsonl");
fs.symlinkSync(outside, symlink);
await expect(acquirePiSdkConnection(installedPiArgs(fixture, `invalid:symlink:${Date.now()}`, {
session: { sessionFile: symlink },
Expand Down
Loading
Loading