937 lines
27 KiB
Markdown
937 lines
27 KiB
Markdown
# 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/<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:**
|
|
|
|
```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<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:**
|
|
|
|
```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<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
|
|
|
|
```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<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):**
|
|
|
|
```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.**
|