Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b506721
feat: persist queued messages and add manual crash recovery
windli2018 Aug 1, 2026
e15f3bc
feat: queue ops, streaming model/compact, bottom panel
windli2018 Aug 1, 2026
192e816
fix: switching back to the current run's model cancels pending model …
windli2018 Aug 1, 2026
b02d378
feat: import pops recovery dialog, fix run-queued execution
windli2018 Aug 1, 2026
d0c2fc3
fix: clear model-switch badge when the new model actually produces a …
windli2018 Aug 2, 2026
61114b8
fix: recall-edited queue entries requeue in steer/followUp send modes…
windli2018 Aug 2, 2026
b355073
fix: steer/followUp buttons dispatch as chosen mode instead of requeu…
windli2018 Aug 2, 2026
299c4fb
feat: steer-send button on collapsed queue bar when first message is …
windli2018 Aug 3, 2026
d6dacca
feat: steer button usable without typed text — dispatches first queue…
windli2018 Aug 3, 2026
e7df894
fix: recall/prepend places caret at end of inserted text, not end of …
windli2018 Aug 3, 2026
7045701
fix: bottom panel swipe no longer wraps — full swipe-up stays full, m…
windli2018 Aug 3, 2026
a3fa6ed
fix: skip queueHidden level when queue is empty (swipe and click-cycles)
windli2018 Aug 3, 2026
89401e8
fix: restore queued-count label in queue header (all screen sizes)
windli2018 Aug 3, 2026
e0271e9
fix: pass onQueueSteerSend so collapsed-bar steer and empty-input ste…
windli2018 Aug 3, 2026
231e849
Merge queue persistence & recovery work onto latest main
windli2018 Aug 3, 2026
f1eacf3
fix: rework queue continue to use pi's own drain loop; sync UI after …
windli2018 Aug 3, 2026
06ee98c
fix: new-session import pops recovery dialog; show dialog for new ses…
windli2018 Aug 3, 2026
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