Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# next.js
/.next/
/.next-dev/
/out/

# production
Expand Down
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,17 @@ On `ChatWindow` mount, `GET /api/agent/[id]` is called. If `state.isStreaming ==
### Compaction SSE events
Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `auto_compaction_start` / `auto_compaction_end`. `handleAgentEvent` accepts both sets to keep `isCompacting` in sync. Manual compact is a blocking POST — the button stays disabled until the response returns.

### Queue persistence & crash recovery (`lib/queue-store.ts`)
pi keeps steer/follow-up queues in memory only — a server restart would lose them. The wrapper mirrors every queue mutation to a per-session sidecar `<session>.jsonl.queue.json` (atomic tmp+rename). **The sidecar only saves — it never auto-delivers.**

- Wrapper state: `queueMirror` (live queue w/ images), `queueRecovery` (orphans awaiting user decision), `pendingQueueHints` (FIFO image hints — pi reports queue items by text only and template expansion can rewrite text, so hints are consumed FIFO per kind).
- Mirror sync: `queue_update` events fire on enqueue / drain (message_start) / clear — `reconcileQueue()` rebuilds the mirror from pi's text arrays.
- Enqueue points: `send("prompt")` with `streamingBehavior` while streaming, `send("steer")`, `send("follow_up")`, `clear_queue`.
- After a restart (or wrapper idle-destroy), every sidecar entry becomes a `pendingRecovery` item exposed via `get_state.pendingRecovery` and `GET /api/sessions/[id]/queue-recovery` (lightweight file read — does NOT create an AgentSession; when a wrapper is alive its in-memory recovery list wins).
- `POST /api/agent/[id]` commands: `resolve_recovery {keep, discard, continueRun}` (keep re-queues via `inner.steer()/followUp()`; `continueRun` invokes `this.inner.agent.continue()` directly — AgentSession has no public continue; events/persistence still flow via its subscriptions), `export_queue`, `import_queue`.
- UI: `QueueRecoveryDialog` (checklist + re-queue / re-queue & continue / discard / export .md/.json / import / dismiss), export/import buttons in the ChatInput queued banner. `lib/queue-export.ts` is client-safe (blob download + `parseQueueImport` for the round-trip).
- Deleting a session removes the sidecar (`removeQueue`). Image-only queued messages never leave pi's `_steeringMessages` (contentText("") is falsy) — they may surface as already-delivered in recovery; user discards manually.

### Running state polling + reconciliation
- The sidebar polls `/api/agent/running` every 2.5 seconds while the tab is visible and pauses polling in background tabs. The session-list response remains the initial fallback.
- `useAgentSession` treats per-session SSE as primary for chat events and opens it before each prompt. `prompt_done` completes the current UI stage and notification immediately, but the idle SSE stays open for a 30-second grace window and is reused by the next prompt. `agent_start` cancels that close timer; `agent_settled` finishes extension-injected runs that have no wrapper-level `prompt_done` and starts a fresh grace window. Do not close on the first `agent_end`: retries, compaction, and extension-queued messages can continue the same logical prompt.
Expand Down
52 changes: 52 additions & 0 deletions app/api/sessions/[id]/queue-recovery/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { getRpcSession } from "@/lib/rpc-manager";
import { resolveSessionPath } from "@/lib/session-reader";
import {
loadQueue,
type PendingRecoveryItem,
type QueueEntry,
} from "@/lib/queue-store";

function toPublicView(entry: QueueEntry): PendingRecoveryItem {
return {
id: entry.id,
kind: entry.kind,
text: entry.text,
hasImages: Boolean(entry.images?.length),
queuedAt: entry.queuedAt,
};
}

/**
* GET /api/sessions/[id]/queue-recovery
*
* Lists queued messages that survived a server restart and were never
* processed (the "pending recovery" list). Deliberately lightweight: it reads
* the sidecar file directly and does NOT create an AgentSession.
*
* When a live wrapper exists, its in-memory recovery list is authoritative
* (it excludes messages currently in the live queue).
*/
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const filePath = await resolveSessionPath(id);
if (!filePath) {
return NextResponse.json({ error: "Session not found" }, { status: 404 });
}

const rpc = getRpcSession(id);
if (rpc?.isAlive()) {
const state = await rpc.send({ type: "get_state" }) as { pendingRecovery?: PendingRecoveryItem[] };
return NextResponse.json({ items: state.pendingRecovery ?? [] });
}

const items = loadQueue(filePath).map(toPublicView);
return NextResponse.json({ items });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
2 changes: 2 additions & 0 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@/lib/session-reader";
import { sessionPathKey } from "@/lib/session-path";
import { getRpcSession } from "@/lib/rpc-manager";
import { removeQueue } from "@/lib/queue-store";

// BranchNavigator still traverses recursively, so keep the response tree shallow.
const MAX_PROJECTED_TREE_DEPTH = 200;
Expand Down Expand Up @@ -239,6 +240,7 @@ export async function DELETE(

await getRpcSession(id)?.shutdown();
unlinkSync(filePath);
removeQueue(filePath);
invalidateSessionPathCache(id);
invalidateSessionListCache();
return NextResponse.json({ ok: true });
Expand Down
2 changes: 1 addition & 1 deletion components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1223,7 +1223,7 @@ export function AppShell() {
{costStr}
</span>
)}
{ctxStr && (
{!isMobile && ctxStr && (
<span style={{ display: "flex", alignItems: "center", gap: 4, color: ctxColor }}>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 9 L1 5 Q1 1 5 1 Q9 1 9 5 L9 9" /><line x1="1" y1="9" x2="9" y2="9" />
Expand Down
Loading