diff --git a/docs/EXTENSION-API-AUDIT.md b/docs/EXTENSION-API-AUDIT.md new file mode 100644 index 0000000..71c91fb --- /dev/null +++ b/docs/EXTENSION-API-AUDIT.md @@ -0,0 +1,936 @@ +# pi ExtensionAPI Audit (für iOS-App-Spec v2) + +**Datum:** 2026-05-15 +**pi Version:** 0.74.0 +**Auditor:** Subagent (Claude Sonnet 4.6) + +--- + +## TL;DR + +- ✅ **S-07 State Side-Channel** — machbar mit Einschränkungen (kein explizites `awaiting-input` Event, muss abgeleitet werden) +- ✅ **S-08 Slash-Command-Registry** — direkt verfügbar via `pi.getCommands()` +- ⚠️ **S-13 Tree-State Side-Channel** — Tree-Read funktioniert, aber keine Subscribe-Mechanik für Live-Updates (Polling oder Event-basiert möglich) +- ❌ **Gruppe T Tree-Navigation** — Slash-Command-Injection funktioniert NICHT (Commands werden als Text an LLM gesendet, nicht dispatched). Hack via `(pi as any)` nötig oder Upstream-Change. + +--- + +## 1. Methodik + +**Untersuchte Quellen:** +- pi-Installation: `/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/` (v0.74.0) +- TypeScript-Definitionen: `dist/core/extensions/types.d.ts` (1173 Zeilen) +- Session-Manager: `dist/core/session-manager.d.ts` (308 Zeilen) +- Slash-Commands: `dist/core/slash-commands.d.ts` (15 Zeilen) +- Offizielle Doku: `docs/extensions.md` (97KB) +- Bestehende Extension: `/Users/jay/.pi/agent/git/git.vpsj.de/jay/pi-remote-control/extensions/remote-control/` + +**Verifikationsmethode:** Statische Analyse der TypeScript-Definitionen + Cross-Check gegen bestehenden Code der `pi-remote-control` Extension. + +--- + +## 2. Capability-Matrix + +| Capability | Status | Spec-Feature | Workaround | Aufwand | +|---|---|---|---|---| +| **Lifecycle Events** | ✅ Vorhanden | S-07 | `agent_end` statt `awaiting-input` | Trivial | +| **Tool Execution Events** | ✅ Vorhanden | S-07 | — | Trivial | +| **Slash-Command-Registry** | ✅ Vorhanden | S-08 | — | Trivial | +| **Tree-Read** | ✅ Vorhanden | S-13 | — | Trivial | +| **Tree-Subscribe (Live-Updates)** | ⚠️ Event-basiert | S-13 | Events tracken statt Subscribe | Einfach | +| **Slash-Command-Dispatch** | ❌ Nicht exposed | Gruppe T | `(pi as any)` Hack | Fragil | +| **Tool-Call-Details** | ✅ Vorhanden | S-07 | — | Trivial | + +**Legende:** +- ✅ = Out-of-the-box verfügbar +- ⚠️ = Machbar mit Einschränkungen +- ❌ = Nicht ohne Hack oder Upstream-Change + +--- + +## 3. Capabilities im Detail + +### 3.1 Lifecycle Events + +**Was existiert:** + +```typescript +// Quelle: dist/core/extensions/types.d.ts, Zeile 458-477 +pi.on("agent_start", async (event, ctx) => { ... }); +pi.on("agent_end", async (event, ctx) => { ... }); +pi.on("tool_execution_start", async (event, ctx) => { ... }); +pi.on("tool_execution_end", async (event, ctx) => { ... }); +pi.on("message_start", async (event, ctx) => { ... }); +pi.on("message_update", async (event, ctx) => { ... }); +pi.on("message_end", async (event, ctx) => { ... }); +``` + +**Event-Payloads (relevante Felder):** + +| Event | Payload | Code-Beleg | +|---|---|---| +| `agent_start` | `{ type: "agent_start" }` | types.d.ts:223 | +| `agent_end` | `{ type: "agent_end", messages: AgentMessage[] }` | types.d.ts:228 | +| `tool_execution_start` | `{ toolCallId, toolName, args }` | types.d.ts:261 | +| `tool_execution_end` | `{ toolCallId, toolName, result, isError }` | types.d.ts:275 | +| `message_update` | `{ message, assistantMessageEvent }` | types.d.ts:250 | + +**Lücken:** + +- ❌ **Kein explizites `awaiting-input` Event.** Das nächstbeste ist `agent_end`, welches signalisiert dass der Agent-Loop beendet ist. Kombiniert mit `ctx.isIdle()` kann man ableiten, ob Pi auf User-Input wartet. +- ❌ **Kein `thinking` vs. `idle` State-Enum.** Muss selbst aus Events abgeleitet werden. + +**Ableitung des Pi-States für S-07:** + +```typescript +// Pseudo-Code für die Sidecar-Logik +let state: "thinking" | "tool" | "idle" | "awaiting-input" = "idle"; + +pi.on("agent_start", () => { + state = "thinking"; + broadcast({ type: "state", value: "thinking" }); +}); + +pi.on("tool_execution_start", (event) => { + state = "tool"; + broadcast({ type: "state", value: "tool", tool: event.toolName }); +}); + +pi.on("tool_execution_end", () => { + state = "thinking"; // zurück zu thinking nach Tool +}); + +pi.on("agent_end", (event, ctx) => { + if (ctx.isIdle()) { + state = "awaiting-input"; + broadcast({ type: "state", value: "awaiting-input" }); + } else { + state = "thinking"; // Follow-up-Messages pending + } +}); +``` + +**Code-Beleg (bestehende Extension nutzt diese Events):** + +```typescript +// pi-remote-control/extensions/remote-control/index.ts, Zeile 89-109 +pi.on("agent_start", async (_event, ctx) => { + server?.broadcast({ type: "agent_start" }); + updateStatus(ctx); +}); + +pi.on("agent_end", async (_event, ctx) => { + server?.broadcast({ type: "agent_end" }); + updateStatus(ctx); +}); + +pi.on("tool_execution_start", async (event) => { + server?.broadcast({ + type: "tool_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); +}); +``` + +**Fazit:** S-07 State-Side-Channel ist **machbar**. Die Granularität ist ausreichend für die Spec-Anforderungen (thinking, tool, idle, awaiting-input). Kein Upstream-Change nötig. + +--- + +### 3.2 Slash-Command-Registry + +**Was existiert:** + +```typescript +// Quelle: dist/core/extensions/types.d.ts, Zeile 772 +pi.getCommands(): SlashCommandInfo[]; +``` + +**Return-Type:** + +```typescript +// Quelle: dist/core/slash-commands.d.ts, Zeile 1-9 +export interface SlashCommandInfo { + name: string; + description?: string; + source: "extension" | "prompt" | "skill"; + sourceInfo: SourceInfo; +} +``` + +**Beispiel:** + +```typescript +pi.on("session_start", async (event, ctx) => { + const commands = pi.getCommands(); + // [ + // { name: "new", description: "Start a new session", source: "extension", ... }, + // { name: "fork", description: "Fork from an entry", source: "extension", ... }, + // { name: "tree", description: "Show conversation tree", source: "extension", ... }, + // { name: "my-custom", description: "...", source: "skill", ... } + // ] + + // Für S-08 Endpoint /sessions//commands: + const payload = commands.map(c => ({ + name: c.name, + description: c.description, + source: c.source + })); + // Send via WebSocket or REST +}); +``` + +**Code-Beleg:** Die bestehende Extension nutzt `pi.registerCommand()` aber `pi.getCommands()` ist bisher nicht verwendet. Die Funktion ist aber in `types.d.ts:772` klar definiert. + +**Fazit:** S-08 Slash-Command-Registry ist **direkt machbar**, out-of-the-box. Kein Hack, kein Upstream-Change nötig. + +--- + +### 3.3 Conversation Tree + +**Was existiert:** + +```typescript +// Quelle: dist/core/extensions/types.d.ts, Zeile 87 +ctx.sessionManager: ReadonlySessionManager; +``` + +**Relevante Methoden (dist/core/session-manager.d.ts):** + +```typescript +interface ReadonlySessionManager { + getBranch(fromId?: string): SessionEntry[]; + getTree(): SessionTreeNode[]; + getLeafId(): string | null; + getEntry(id: string): SessionEntry | undefined; + getChildren(parentId: string): SessionEntry[]; + getSessionName(): string | undefined; + // ... +} +``` + +**Tree-Structure:** + +```typescript +// Quelle: session-manager.d.ts, Zeile 74-81 +export interface SessionTreeNode { + entry: SessionEntry; + children: SessionTreeNode[]; + /** Resolved label for this entry, if any */ + label?: string; + /** Timestamp of the latest label change for this entry, if any */ + labelTimestamp?: string; +} +``` + +**SessionEntry (relevant für Tree-State):** + +```typescript +// session-manager.d.ts, Zeile 16-27 +export interface SessionEntryBase { + type: string; + id: string; + parentId: string | null; + timestamp: string; +} + +export interface SessionMessageEntry extends SessionEntryBase { + type: "message"; + message: AgentMessage; +} +``` + +**Beispiel für S-13 Tree-State-Endpoint:** + +```typescript +pi.on("session_start", async (event, ctx) => { + const tree = ctx.sessionManager.getTree(); + const currentLeafId = ctx.sessionManager.getLeafId(); + + // Tree als JSON für iOS-App serialisieren: + function serializeTree(node: SessionTreeNode) { + const entry = node.entry; + let summary = ""; + let msgCount = 0; + + if (entry.type === "message") { + const msg = (entry as SessionMessageEntry).message; + if (msg.role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : msg.content.filter(c => c.type === "text").map(c => c.text).join(""); + summary = text.slice(0, 50); + } + } + + // Count messages in subtree + const countMessages = (n: SessionTreeNode): number => { + let count = n.entry.type === "message" ? 1 : 0; + for (const child of n.children) count += countMessages(child); + return count; + }; + msgCount = countMessages(node); + + return { + id: entry.id, + parent: entry.parentId, + summary, + messageCount: msgCount, + createdAt: entry.timestamp, + children: node.children.map(serializeTree) + }; + } + + const treeData = { + type: "tree", + nodes: tree.map(serializeTree), + current: currentLeafId + }; + + // Broadcast oder per REST-Endpoint abrufbar machen + broadcast(treeData); +}); +``` + +**Live-Updates (Subscribe-Problem):** + +Es gibt **kein** `on("tree_update")` Event. Tree-Mutationen passieren nur via: +- `/fork` → triggert `session_before_fork` + `session_tree` +- `/new` → triggert `session_before_switch` +- `/compact` → triggert `session_compact` +- Checkout via `/tree` → triggert `session_tree` + +**Events für Tree-Tracking:** + +```typescript +// Quelle: types.d.ts, Zeile 177-189 +pi.on("session_tree", async (event, ctx) => { + // event = { + // newLeafId: string | null, + // oldLeafId: string | null, + // summaryEntry?: BranchSummaryEntry, + // fromExtension?: boolean + // } + + // Tree hat sich geändert → re-serialize und broadcasten + const updatedTree = serializeTree(ctx.sessionManager.getTree()); + broadcast({ type: "tree", ...updatedTree }); +}); + +pi.on("session_compact", async (event, ctx) => { + // Compaction hat Tree verändert → Update broadcasten + const updatedTree = serializeTree(ctx.sessionManager.getTree()); + broadcast({ type: "tree", ...updatedTree }); +}); +``` + +**Code-Beleg (bestehende Extension nutzt getBranch):** + +```typescript +// pi-remote-control/extensions/remote-control/messages.ts, Zeile 66 +const branch = ctx.sessionManager.getBranch(); +``` + +**Fazit:** S-13 Tree-State ist **machbar mit Event-basiertem Push**. Kein echtes Subscribe-Pattern, aber ausreichend für die Spec. Tree-Read ist trivial, Live-Updates erfordern Event-Handling (`session_tree`, `session_compact`). Kein Upstream-Change nötig. + +--- + +### 3.4 Programmatic Prompt-Dispatch + +**Was existiert:** + +```typescript +// Quelle: types.d.ts, Zeile 758-763 +pi.sendUserMessage( + content: string | (TextContent | ImageContent)[], + options?: { deliverAs?: "steer" | "followUp" } +): void; +``` + +**Was NICHT existiert:** + +```typescript +// ❌ NICHT in der ExtensionAPI exposed: +pi.prompt(text: string): void; // würde Slash-Commands dispatchen +``` + +**Problem (aus früherer Recherche bestätigt):** + +`pi.sendUserMessage()` ruft intern `session.prompt()` mit `expandPromptTemplates: false` auf, was Slash-Command-Processing überspringt. Slash-Commands wie `/fork`, `/new`, `/compact` werden als **literaler Text an den LLM gesendet**, nicht als Befehle ausgeführt. + +**Code-Beleg (aus vorheriger Session):** + +> Wir hatten in der vorherigen Session festgestellt: `pi.sendUserMessage()` explicitly sets `expandPromptTemplates: false`, bypassing slash command processing. `session.prompt()` (which handles slash commands) is not exposed in `ExtensionAPI`. + +**Workaround-Optionen:** + +#### Option A: Hack via `(pi as any)` + +```typescript +// FRAGIL: greift auf interne APIs zu, kann bei Updates brechen +pi.on("some_event", async (event, ctx) => { + // @ts-ignore + const runtime = (pi as any).runtime; + if (runtime && runtime.session) { + await runtime.session.prompt("/fork abc123"); + } +}); +``` + +**Risiken:** +- ❌ Keine TypeScript-Typen +- ❌ Bricht bei internen Refactorings +- ❌ `runtime` könnte `undefined` sein je nach Timing +- ❌ Nicht dokumentiert, keine Garantie + +#### Option B: Re-Implement Commands lokal + +```typescript +// iOS-App sendet Command-Request +// Sidecar implementiert /fork, /new, /compact direkt via ExtensionCommandContext +pi.registerCommand("ios-fork", { + description: "Fork from iOS app", + handler: async (args, ctx) => { + // args = entryId + await ctx.fork(args, { position: "at" }); + } +}); + +// iOS schickt dann via WebSocket: +// { type: "command", name: "ios-fork", args: "abc123" } +// Sidecar ruft pi-internen Command-Handler auf +``` + +**Problem:** Geht nur in interactive mode, nicht in RPC/print mode. Außerdem muss die Extension selbst die Commands implementieren, was dupliziert Code. + +#### Option C: Upstream-Feature-Request + +```typescript +// Gewünschte API: +pi.prompt(text: string): Promise; +// oder +pi.executeCommand(commandName: string, args: string): Promise; +``` + +**Fazit:** Gruppe T Tree-Navigation via **Slash-Command-Injection ist NICHT machbar** ohne Hack. Option A (`(pi as any)`) funktioniert vermutlich, ist aber fragil. Option B (Re-Implement) ist sauberer, aber aufwändiger. Option C (Upstream) ist langfristig richtig, aber blockiert die iOS-App. + +**Empfehlung:** Für MVP: **Option B** (Re-Implement Commands in Extension). Für v2: **Upstream-Feature-Request für `pi.prompt()`**. + +--- + +### 3.5 Tool-Call-Daten + +**Was existiert:** + +```typescript +// Quelle: types.d.ts, Zeile 261-278 +pi.on("tool_execution_start", async (event, ctx) => { + // event = { + // type: "tool_execution_start", + // toolCallId: string, + // toolName: string, + // args: any + // } +}); + +pi.on("tool_execution_end", async (event, ctx) => { + // event = { + // type: "tool_execution_end", + // toolCallId: string, + // toolName: string, + // result: any, // NICHT truncated! + // isError: boolean + // } +}); +``` + +**Code-Beleg (bestehende Extension nutzt das bereits):** + +```typescript +// pi-remote-control/extensions/remote-control/index.ts, Zeile 103-127 +pi.on("tool_execution_start", async (event) => { + server?.broadcast({ + type: "tool_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); +}); + +pi.on("tool_execution_end", async (event) => { + type TextContent = { type: string; text: string }; + type ToolResult = { content?: TextContent[] } | string; + const result = event.result as ToolResult; + const content = typeof result === "object" ? result.content : undefined; + const resultText = Array.isArray(content) + ? content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("") + : typeof result === "string" + ? result + : ""; + server?.broadcast({ + type: "tool_end", + toolCallId: event.toolCallId, + result: resultText, + isError: event.isError, + }); +}); +``` + +**Fazit:** Tool-Call-Daten sind **vollständig verfügbar** für S-07. Name, Args, Result (ungekürzt), Error-Status — alles da. Trivial implementierbar. + +--- + +### 3.6 Sonstiges (Bonus) + +**Weitere nützliche Capabilities, die wir ggf. übersehen haben:** + +#### Model-Info + +```typescript +// Quelle: types.d.ts, Zeile 90 +ctx.model: Model | undefined; + +// Beispiel: +pi.on("model_select", async (event, ctx) => { + broadcast({ + type: "model", + model: { + id: event.model.id, + name: event.model.name, + provider: event.model.provider + } + }); +}); +``` + +**Use-Case für iOS-App:** Session-Switcher (iOS-D-01) könnte pro Session anzeigen, welches Modell verwendet wurde. + +#### CWD-Tracking + +```typescript +// Quelle: types.d.ts, Zeile 85 +ctx.cwd: string; +``` + +**Use-Case:** Session-Metadaten (S-09) könnte CWD pro Session exposen. + +#### Abort-Signal + +```typescript +// Quelle: types.d.ts, Zeile 95 +ctx.abort(): void; +ctx.signal: AbortSignal | undefined; +``` + +**Use-Case:** iOS-App könnte einen "Stop"-Button haben, der `abort()` triggert via WebSocket-Control-Frame. + +#### Context-Usage + +```typescript +// Quelle: types.d.ts, Zeile 102 +ctx.getContextUsage(): ContextUsage | undefined; + +interface ContextUsage { + tokens: number | null; + contextWindow: number; + percent: number | null; +} +``` + +**Use-Case:** Status-Bar (iOS-C-01) könnte "Context: 45%" anzeigen. + +--- + +## 4. Empfehlung pro Spec-Feature + +| Spec-Feature | Status | Empfehlung | Begründung | +|---|---|---|---| +| **S-07 State Side-Channel** | ✅ Machbar | Implementieren | Events ausreichend granular, `isIdle()` + `agent_end` = awaiting-input | +| **S-08 Slash-Command-Registry** | ✅ Machbar | Implementieren | `pi.getCommands()` out-of-the-box | +| **S-13 Tree-State Side-Channel** | ⚠️ Machbar | Implementieren mit Events | Tree-Read trivial, Live-Updates via `session_tree` + `session_compact` | +| **Gruppe T Tree-Navigation** | ❌ Hack nötig | **Nicht empfohlen für MVP** | Slash-Command-Injection geht nicht. Option B (Re-Implement) aufwändig, Option A (Hack) fragil. Upstream-Request für v2. | + +--- + +## 5. Vorgeschlagene Upstream-Änderungen + +### 5.1 `pi.prompt()` für Slash-Command-Dispatch + +**Problem:** Extensions können User-Messages senden (`pi.sendUserMessage()`), aber keine Slash-Commands programmatisch dispatchen. Das limitiert iOS-App-Features wie Tree-Navigation (Gruppe T), weil `/fork`, `/new`, `/compact` nur als Text an den LLM gehen, nicht ausgeführt werden. + +**Vorschlag:** + +```typescript +pi.prompt(text: string, options?: { + expandPromptTemplates?: boolean +}): Promise; +``` + +**Begründung:** +- Extensions die als "Remote-Control" fungieren (RPC, iOS-App, Web-UI) brauchen die Möglichkeit, Slash-Commands im Namen des Users auszuführen. +- Bestehende `pi.sendUserMessage()` umgeht absichtlich Template-Expansion (vermutlich für Safety). Ein explizites `pi.prompt()` signalisiert Intent. +- Alternative: `pi.executeCommand(name, args)` wäre noch expliziter, aber weniger flexibel. + +**Implementierung (Skizze):** + +```typescript +// In extensions/wrapper.ts +api.prompt = async (text: string, options = {}) => { + runtime.assertActive(); + const { expandPromptTemplates = true } = options; + // Ruft die interne session.prompt() auf, die Slash-Commands dispatched + await runtime.session.prompt(text, { expandPromptTemplates }); +}; +``` + +**Impact:** Niedrig. Nur eine neue Methode in der `ExtensionAPI`, delegiert an existierende `session.prompt()`. + +--- + +### 5.2 `on("awaiting_input")` Event + +**Problem:** Um iOS-C-02 (Push-Notification "Pi ist fertig") sauber zu implementieren, brauchen wir ein explizites Event statt Ableitung via `agent_end` + `ctx.isIdle()`. + +**Vorschlag:** + +```typescript +pi.on("awaiting_input", async (event, ctx) => { + // event = { type: "awaiting_input" } +}); +``` + +**Begründung:** +- Explizit ist besser als implizit. `agent_end` + `isIdle()` funktioniert, ist aber umständlich und fehleranfällig (z.B. wenn Follow-Up-Messages pending sind). +- Semantisch klarer: "Pi wartet auf User-Input" ist ein State, kein abgeleitetes Konstrukt. + +**Implementierung (Skizze):** + +```typescript +// In core/agent-session.ts, nach dem agent loop: +if (this.isIdle() && !this.hasPendingMessages()) { + await this.eventBus.emit("awaiting_input", {}, this.buildContext()); +} +``` + +**Impact:** Sehr niedrig. Ein neues Event, keine Breaking-Changes. + +--- + +### 5.3 Tree-Subscribe-Pattern (Optional) + +**Problem:** `session_tree` + `session_compact` Events decken die meisten Tree-Mutations ab, aber nicht alle (z.B. neue Messages werden per `message_end` gemeldet, nicht als Tree-Mutation). + +**Vorschlag:** + +```typescript +pi.on("tree_change", async (event, ctx) => { + // event = { + // type: "tree_change", + // operation: "append" | "branch" | "compact" | "fork", + // entryId: string, + // parentId: string | null + // } +}); +``` + +**Begründung:** +- Vereinheitlicht alle Tree-Mutations in einem Event. +- Extensions müssen nicht mehrere Events tracken (`message_end`, `session_tree`, `session_compact`). + +**Implementierung:** Moderater Aufwand, weil es alle Append-Operationen tracken muss. + +**Impact:** Niedrig, optional. Die bestehenden Events reichen aus, aber das wäre cleaner. + +**Status:** **NICE-TO-HAVE**, nicht kritisch für die iOS-App. + +--- + +## 6. Zusammenfassung: Implementierungs-Roadmap für Sidecar + +### Phase 1: S-07 State Side-Channel (MUST) + +**Code-Änderungen (pi-remote-control/extensions/remote-control/server.ts):** + +```typescript +interface StateMessage { + type: "state"; + value: "thinking" | "tool" | "idle" | "awaiting-input"; + tool?: string; + ts: number; +} + +let currentState: StateMessage["value"] = "idle"; + +pi.on("agent_start", async () => { + currentState = "thinking"; + server?.broadcast({ + type: "state", + value: "thinking", + ts: Date.now() + }); +}); + +pi.on("tool_execution_start", async (event) => { + currentState = "tool"; + server?.broadcast({ + type: "state", + value: "tool", + tool: event.toolName, + ts: Date.now() + }); +}); + +pi.on("tool_execution_end", async () => { + currentState = "thinking"; + server?.broadcast({ + type: "state", + value: "thinking", + ts: Date.now() + }); +}); + +pi.on("agent_end", async (event, ctx) => { + if (ctx.isIdle()) { + currentState = "awaiting-input"; + server?.broadcast({ + type: "state", + value: "awaiting-input", + ts: Date.now() + }); + } +}); +``` + +**Aufwand:** 1-2 Stunden + +--- + +### Phase 2: S-08 Slash-Command-Registry (SHOULD) + +**Code-Änderungen (neuer Endpoint):** + +```typescript +// In server.ts, HTTP-Handler erweitern: +if (pathname === "/commands") { + const commands = pi.getCommands(); + const payload = commands.map(c => ({ + name: c.name, + description: c.description, + source: c.source + })); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(payload)); + return; +} +``` + +**Aufwand:** 30 Minuten + +--- + +### Phase 3: S-13 Tree-State Side-Channel (SHOULD) + +**Code-Änderungen (neuer Endpoint + Events):** + +```typescript +// Helper für Tree-Serialisierung +function serializeTree(ctx: ExtensionContext) { + const tree = ctx.sessionManager.getTree(); + const leafId = ctx.sessionManager.getLeafId(); + + function mapNode(node: SessionTreeNode) { + let summary = ""; + let msgCount = 0; + + // Entry-Type-spezifische Logik + if (node.entry.type === "message") { + const msg = (node.entry as any).message; + if (msg.role === "user") { + const text = typeof msg.content === "string" + ? msg.content + : msg.content.filter(c => c.type === "text").map(c => c.text).join(""); + summary = text.slice(0, 50); + } + } + + // Count messages recursive + const count = (n: SessionTreeNode): number => { + let c = n.entry.type === "message" ? 1 : 0; + for (const child of n.children) c += count(child); + return c; + }; + msgCount = count(node); + + return { + id: node.entry.id, + parent: node.entry.parentId, + summary, + messageCount: msgCount, + createdAt: node.entry.timestamp, + children: node.children.map(mapNode) + }; + } + + return { + type: "tree", + nodes: tree.map(mapNode), + current: leafId + }; +} + +// REST-Endpoint +if (pathname === "/tree") { + const treeData = serializeTree(ctx); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(treeData)); + return; +} + +// Live-Updates via Events +pi.on("session_tree", async (event, ctx) => { + const treeData = serializeTree(ctx); + server?.broadcast(treeData); +}); + +pi.on("session_compact", async (event, ctx) => { + const treeData = serializeTree(ctx); + server?.broadcast(treeData); +}); + +pi.on("message_end", async (event, ctx) => { + // Neue Message = Tree-Mutation + const treeData = serializeTree(ctx); + server?.broadcast(treeData); +}); +``` + +**Aufwand:** 2-3 Stunden + +--- + +### Phase 4: Gruppe T — Tree-Navigation (CONDITIONAL) + +**Ohne Upstream-Change: Option B (Re-Implement)** + +```typescript +pi.registerCommand("ios-fork", { + description: "Fork from iOS app (internal)", + handler: async (args, ctx) => { + const entryId = args.trim(); + await ctx.fork(entryId, { position: "at" }); + } +}); + +pi.registerCommand("ios-new", { + description: "New session from iOS app (internal)", + handler: async (args, ctx) => { + await ctx.newSession(); + } +}); + +pi.registerCommand("ios-compact", { + description: "Compact from iOS app (internal)", + handler: async (args, ctx) => { + ctx.compact(); + } +}); + +// WebSocket-Handler für iOS-Command-Requests +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "execute_command") { + // iOS schickt: { type: "execute_command", name: "ios-fork", args: "abc123" } + const command = registeredCommands.get(msg.name); + if (command) { + command.handler(msg.args, ctx); + } + } +}); +``` + +**Aufwand:** 3-4 Stunden (inkl. Testing) + +**Alternative mit Upstream-Change:** + +Sobald `pi.prompt()` verfügbar: + +```typescript +ws.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.type === "slash_command") { + // iOS schickt: { type: "slash_command", text: "/fork abc123" } + await pi.prompt(msg.text); + } +}); +``` + +**Aufwand:** 30 Minuten + +--- + +## 7. Risiken & Offene Punkte + +### R-1: Timing-Race-Condition bei State-Ableitung + +**Problem:** `agent_end` + `ctx.isIdle()` könnte in Randfällen falsch sein (z.B. wenn zwischen Event-Emit und `isIdle()`-Check ein Follow-Up-Message queued wurde). + +**Mitigation:** Upstream-Event `awaiting_input` (siehe 5.2) wäre sauberer. Für MVP akzeptabel, für Production ggf. nachbessern. + +--- + +### R-2: Tree-Serialisierung bei großen Sessions + +**Problem:** Bei sehr langen Sessions (> 1000 Entries) kann `getTree()` + Serialisierung langsam werden. + +**Mitigation:** +- Lazy-Loading: iOS-App ruft nur Subtrees ab (via `ctx.sessionManager.getChildren(parentId)`). +- Caching: Sidecar cached serialisiertes Tree und invalidiert nur bei Tree-Events. + +--- + +### R-3: Slash-Command-Hack (`(pi as any)`) bricht bei Updates + +**Problem:** Wenn wir Option A (Hack) für Gruppe T nutzen, kann das bei pi-Updates brechen. + +**Mitigation:** +- Tests einbauen die warnen wenn der Hack bricht. +- Upstream-Feature-Request parallel verfolgen. +- Für MVP Option B (Re-Implement) bevorzugen. + +--- + +### R-4: ExtensionAPI-Version-Lock + +**Problem:** Wir auditieren gegen pi v0.74.0. Künftige Versionen könnten API-Changes haben. + +**Mitigation:** +- In `package.json` der Extension `peerDependencies` festlegen: `"@earendil-works/pi-coding-agent": "^0.74.0"`. +- Bei pi-Updates: Re-Audit durchführen. + +--- + +## 8. Fazit & Next Steps + +**Für MVP (Phase 0):** +- ✅ S-07, S-08, S-13 sind **implementierbar ohne Upstream-Changes** +- ⚠️ Gruppe T (Tree-Navigation) **nicht empfohlen für MVP** (Hack fragil, Re-Implement aufwändig) +- → **Empfehlung:** MVP ohne Gruppe T. Tree-View als read-only (S-13), Navigation bleibt über SSH-Terminal. + +**Für v2 (nach Upstream-Discussion):** +- Feature-Request für `pi.prompt()` stellen (siehe 5.1) +- Optional: `awaiting_input` Event (siehe 5.2) +- Gruppe T dann sauber implementieren + +**Nächste Schritte:** +1. Spec v3 schreiben mit finalen Feature-Entscheidungen +2. Phase 0 (Stream-PoC) starten — unabhängig von diesem Audit +3. Phase 1 (Sidecar mit S-07, S-08, S-13) parallel entwickeln +4. iOS-App-Entwicklung kann starten sobald Phase 1 läuft + +--- + +**Audit abgeschlossen.** diff --git a/docs/SPEC-ios-app.md b/docs/SPEC-ios-app.md index 1794a92..8f9e3ad 100644 --- a/docs/SPEC-ios-app.md +++ b/docs/SPEC-ios-app.md @@ -1,8 +1,13 @@ -# pi-remote — iOS Native App Spec (v2) +# pi-remote — iOS Native App Spec (v3) -> **Status:** v2 nach Review-Runde 1. -> Review-Verlauf mit allen Diskussionen: [`SPEC-ios-app-review-v1.md`](./SPEC-ios-app-review-v1.md). -> Vor Phase 0 ist ein API-Audit nötig — siehe [Spike-0a](#spike-0a--extensionapi-audit). +> **Status:** v3 nach ExtensionAPI-Audit. +> Audit-Ergebnis: [`EXTENSION-API-AUDIT.md`](./EXTENSION-API-AUDIT.md). +> Review-Verlauf v1: [`SPEC-ios-app-review-v1.md`](./SPEC-ios-app-review-v1.md). +> +> **Changelog v2 → v3:** +> - Audit hat S-07, S-08 und Tree-Read als out-of-the-box machbar bestätigt → von PENDING auf firm SHOULD. +> - **Tree-Navigation komplett aus iOS entfernt.** Gruppe T gestrichen. Begründung: Slash-Command-Dispatch ist in der ExtensionAPI blockiert, Workarounds (Hack oder Re-Implement) sind nicht den Aufwand wert. Tree-Navigation bleibt nativ in pi. +> - Spike-0a abgeschlossen, ist nun referenziertes Audit-Dokument. --- @@ -170,24 +175,25 @@ einzelne Datei `/var/lib/pi-remote/buffer/.log`. *Dependencies:* S-02 ### S-07 — State Side-Channel -**SHOULD.** Sidecar abonniert pi-ExtensionAPI-Events und publiziert als -JSON-Control-Frames: +**SHOULD.** Sidecar abonniert pi-ExtensionAPI-Events (`agent_start`, +`agent_end`, `tool_execution_start`, `tool_execution_end`, +`session_tree`, `session_compact`) und publiziert als JSON-Control-Frames: ```json {"type":"state","value":"thinking"|"tool"|"idle"|"awaiting-input", "tool":"Edit"|"Read"|...,"ts":1234567890} ``` -Realisierbarkeit hängt vom Outcome von [Spike-0a](#spike-0a--extensionapi-audit) ab. +`awaiting-input` wird abgeleitet aus `agent_end` + `ctx.isIdle()`, +da kein explizites Event existiert (siehe Audit §3.1). -*Dependencies:* S-01, Spike-0a +*Dependencies:* S-01 ### S-08 — Slash-Command-Registry **SHOULD.** Endpoint `/sessions//commands` liefert JSON-Liste der -verfügbaren Slash-Commands inkl. Beschreibung und Argument-Schema, aus -pi's Registry abgefragt. Dynamisch — Extensions die Commands -hinzufügen erscheinen automatisch. Realisierbarkeit hängt von Spike-0a -ab. +verfügbaren Slash-Commands inkl. Beschreibung und Argument-Schema via +`pi.getCommands()` (laut Audit out-of-the-box verfügbar). Dynamisch — +Extensions die Commands hinzufügen erscheinen automatisch. -*Dependencies:* S-01, Spike-0a +*Dependencies:* S-01 ### S-09 — Multi-Session-Management **MUST.** Sidecar verwaltet mehrere tmux-Sessions parallel, alle robust @@ -263,6 +269,9 @@ Selbst-Debugging. *Dependencies:* — +> S-13 (Tree-State Side-Channel) wurde in v3 gestrichen. +> Begründung: siehe Out-of-Scope und Changelog. + --- ## 5. iOS Client Features @@ -509,43 +518,6 @@ künftige Connection verifiziert dagegen. Re-Pairing bei Cert-Rotation. *Dependencies:* S-10, S-11 -### Gruppe T — Tree Navigation (PENDING) - -> **Status:** Pending — abhängig vom Outcome von -> [Spike-0a](#spike-0a--extensionapi-audit). Wenn pi's Tree über die -> ExtensionAPI nicht zugänglich ist, fällt diese Gruppe weg oder wird auf -> reine Slash-Command-Injection reduziert (`/fork`, `/new`, `/compact`). - -#### S-13 (NEU) — Tree-State Side-Channel -**TBD.** Sidecar liefert pi's Conversation-Tree als JSON über -Side-Channel + Live-Updates bei Branch/Fork/Compact/Checkout: - -```json -{ - "type": "tree", - "nodes": [ - {"id":"abc","parent":null,"summary":"main","msgCount":42,"createdAt":"..."}, - {"id":"def","parent":"abc","summary":"explored iOS","msgCount":18} - ], - "current": "def" -} -``` - -*Dependencies:* Spike-0a - -#### iOS-T-01 — Native Tree-View -**TBD.** SwiftUI-Tree-Sheet mit allen Knoten. Tap auf Knoten → -Checkout via Slash-Command-Injection. Aktueller Knoten visuell -hervorgehoben. - -*Dependencies:* S-13 - -#### iOS-T-02 — Branch / Fork Actions -**TBD.** Aus dem Tree-Sheet heraus `/fork`, `/new`, `/compact` als -native Buttons. Werden via Slash-Command-Injection an pi gesendet. - -*Dependencies:* S-13 - --- ## 6. Out of Scope (locked) @@ -568,38 +540,26 @@ native Buttons. Werden via Slash-Command-Injection an pi gesendet. Snapshot funktioniert weiter. - **Bookmarks / manuelle Marker.** Pi-interne Tools sollen das übernehmen. - **Snippet-Library** (Prompts mit Variablen). Pi-interne Tools. +- **Tree-View / Tree-Navigation in iOS** (jeglicher Form, read-only + oder interaktiv). Tree wird ausschließlich nativ in pi bedient. Audit + hat gezeigt: Slash-Command-Dispatch ist in der ExtensionAPI nicht sauber + zugänglich; Workarounds sind die Komplexität nicht wert. --- -## 7. Spike-0a — ExtensionAPI-Audit +## 7. ExtensionAPI-Audit (abgeschlossen) -**Status:** Muss vor Phase 0 abgeschlossen sein. +Das Audit wurde durchgeführt; Ergebnis liegt in +[`EXTENSION-API-AUDIT.md`](./EXTENSION-API-AUDIT.md). -**Ziel:** Klären, welche der Features S-07, S-08, S-13 sowie Gruppe T -mit der heutigen pi-ExtensionAPI realisierbar sind und welche Upstream- -Erweiterungen wir brauchen. +**Kernergebnisse:** +- S-07, S-08, sowie Tree-**Read** sind out-of-the-box machbar. +- Tree-**Write** (Slash-Command-Dispatch wie `/fork`, `/checkout`, + `/new`) ist nicht sauber zugänglich → Gruppe T gestrichen (siehe + Out-of-Scope). +- Tool-Call-Daten sind vollständig verfügbar (Name, Args, Result). -**Output:** Markdown-Dokument `docs/EXTENSION-API-AUDIT.md`, das für -jede ExtensionAPI-Capability dokumentiert: -- API-Signatur -- Welche Events / Daten exposed sind -- Welches Spec-Feature darauf basiert -- Workaround falls API fehlt (`(pi as any)`, Slash-Injection, etc.) -- Falls Upstream-Change nötig: konkreter Vorschlag - -**Konkrete Fragen:** -- Welche Lifecycle-Events liefert die ExtensionAPI? - (`thinking-start/end`, `tool-start/end`, `awaiting-input`, ...) -- Ist die Slash-Command-Registry abrufbar? In welcher Form? -- Wie ist der Conversation-Tree intern repräsentiert? Read-Access? - Subscribe? Mutations? -- Gibt es einen `pi.prompt(text)` oder Äquivalent, das Slash-Commands - korrekt dispatched? -- Welche Tool-Call-Daten sind sichtbar (Tool-Name, Argumente, - Ergebnis)? - -**Nicht-Blockend:** Stream-Path (S-01–S-06) und Input (S-03) sind vom -Audit unabhängig — die können parallel oder vorab beginnen. +Kein weiterer Spike vor Phase 1 nötig. --- @@ -610,25 +570,20 @@ Audit unabhängig — die können parallel oder vorab beginnen. - **Spike-0 — Stream-PoC** — `pi-remote-control` um tmux pipe-pane + WS-Stream erweitern. Verifizieren dass pi sauber in tmux läuft (kein Crash, Alternate-Screen sauber, kein Latency-Problem). -- **Spike-0a — ExtensionAPI-Audit** — siehe oben. -- **Phase 1 — Sidecar production-ready** — S-01 bis S-06, S-09, S-10, - S-11. Optional je nach Audit: S-07, S-08, S-13. +- **Phase 1 — Sidecar production-ready** — S-01 bis S-12 (alle), S-09a + optional. - **Phase 2 — iOS-App MVP** — Renderer (Gruppe A), Input (Gruppe B ohne Hardware-KB), Status-Bar (iOS-C-01), Session-Switcher (iOS-D-01 + a/b), Reconnect-Lifecycle (Gruppe E), Auth (Gruppe F). - **Phase 3 — iOS-App Augmentation** — Slash-Palette (iOS-C-04), Voice (iOS-C-05), Thumbnails (iOS-D-01c), Scrollback-Search (iOS-D-02), Hardware-KB (iOS-B-06). -- **Phase 4 — Tree (conditional)** — Gruppe T, sofern Spike-0a - positiv. --- -## 9. Offene Punkte für v3 +## 9. Offene Punkte für v4 - **Q-A** — pi-CLI one-shot mode für S-09a: CLI-Flags müssen verifiziert werden. Existiert das in der heutigen pi-CLI? -- **Q-B** — Wenn Spike-0a zeigt dass S-07/S-08 nicht ohne Hack möglich: - Hack akzeptieren (`(pi as any)`) oder Upstream-Change pushen? - **Q-C** — APNs-Setup Details (Auth-Key-Provisioning, Token-Lifecycle).