pi-remote-control/docs/EXTENSION-API-AUDIT.md

27 KiB

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:

// 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:

// 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):

// 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:

// Quelle: dist/core/extensions/types.d.ts, Zeile 772
pi.getCommands(): SlashCommandInfo[];

Return-Type:

// Quelle: dist/core/slash-commands.d.ts, Zeile 1-9
export interface SlashCommandInfo {
    name: string;
    description?: string;
    source: "extension" | "prompt" | "skill";
    sourceInfo: SourceInfo;
}

Beispiel:

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/<id>/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:

// Quelle: dist/core/extensions/types.d.ts, Zeile 87
ctx.sessionManager: ReadonlySessionManager;

Relevante Methoden (dist/core/session-manager.d.ts):

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:

// 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):

// 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:

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:

// 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):

// 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:

// Quelle: types.d.ts, Zeile 758-763
pi.sendUserMessage(
  content: string | (TextContent | ImageContent)[], 
  options?: { deliverAs?: "steer" | "followUp" }
): void;

Was NICHT existiert:

// ❌ 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)

// 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

// 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

// Gewünschte API:
pi.prompt(text: string): Promise<void>;
// oder
pi.executeCommand(commandName: string, args: string): Promise<void>;

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:

// 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):

// 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

// Quelle: types.d.ts, Zeile 90
ctx.model: Model<any> | 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

// Quelle: types.d.ts, Zeile 85
ctx.cwd: string;

Use-Case: Session-Metadaten (S-09) könnte CWD pro Session exposen.

Abort-Signal

// 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

// 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:

pi.prompt(text: string, options?: { 
  expandPromptTemplates?: boolean 
}): Promise<void>;

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):

// 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:

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):

// 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:

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):

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):

// 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):

// 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)

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:

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.