@cognisphere-sh/cognisphere-harness 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +66 -11
  2. package/LICENSE +21 -0
  3. package/dist-web/assets/{file-editor-CQSmlsy_.js → file-editor-CqGYKmT3.js} +1 -1
  4. package/dist-web/assets/{index-1Hg12QW0.js → index-CDE-jsH8.js} +3 -3
  5. package/dist-web/index.html +1 -1
  6. package/home-template/config.example +0 -6
  7. package/home-template/docs/base-harness/README.md +11 -4
  8. package/home-template/scripts/build.sh +0 -5
  9. package/home-template/scripts/lib/remote-bootstrap.sh +2 -6
  10. package/home-template/scripts/setup-server.sh +7 -39
  11. package/package.json +7 -5
  12. package/skills/cognisphere-upgrade/SKILL.md +1 -1
  13. package/skills/create-plugin/SKILL.md +2 -2
  14. package/src/agents/base-agent/bootstrap/bootstrap.sh +2 -18
  15. package/src/agents/base-agent/extensions/context-meta.ts +4 -1
  16. package/src/agents/base-agent/system_prompts/0-base_prompt.md +3 -0
  17. package/src/api/agents.ts +3 -13
  18. package/src/api/credentials.ts +0 -15
  19. package/src/api/models.ts +2 -8
  20. package/src/api/secrets.ts +3 -3
  21. package/src/cli/index.ts +1 -1
  22. package/src/cli/init.ts +0 -12
  23. package/src/core/agent-manager.ts +12 -47
  24. package/src/core/config.ts +6 -0
  25. package/src/core/main.ts +10 -2
  26. package/src/core/model-access.ts +55 -0
  27. package/src/core/models-catalog.ts +72 -40
  28. package/src/core/models-store.ts +3 -4
  29. package/src/core/oauth-logins.ts +7 -3
  30. package/src/core/pi-delivery-receipts.ts +69 -0
  31. package/src/core/pi-models-sync.ts +2 -2
  32. package/src/core/queue.ts +7 -25
  33. package/src/core/rpc.ts +55 -49
  34. package/src/core/runner.ts +137 -131
  35. package/src/core/secrets.ts +2 -3
  36. package/src/core/types.ts +1 -1
  37. package/src/plugins/artifacts/index.ts +1 -1
  38. package/src/agents/base-agent/extensions/harness-bridge.ts +0 -63
@@ -10,21 +10,21 @@ import type { CredField, ProviderCatalogEntry } from "./types.js";
10
10
  * can be appended via the UI.
11
11
  *
12
12
  * OAuth subscription auth: entries with `oauth: true` (Anthropic
13
- * Claude Pro/Max, OpenAI Codex) support browser sign-in from the Models
13
+ * Claude Pro/Max, Sign in with ChatGPT, OpenAI Codex) support browser sign-in from the Models
14
14
  * page in addition to (or instead of) API keys — see `oauth-logins.ts`.
15
15
  * GitHub Copilot OAuth exists in pi-ai but has no catalog entry yet.
16
16
  * Bedrock / Vertex / Azure / Cloudflare are in scope as multi-cred
17
17
  * providers.
18
18
  *
19
19
  * SOURCE OF TRUTH for provider IDs and env-var names:
20
- * temp/pi-mono/packages/ai/src/types.ts (KnownProvider union)
21
- * temp/pi-mono/packages/ai/src/env-api-keys.ts (envMap)
20
+ * temp/pi/packages/ai/src/types.ts (KnownProvider union)
21
+ * temp/pi/packages/ai/src/env-api-keys.ts (envMap)
22
22
  * Keep this file in sync on each pi-coding-agent upgrade — the
23
23
  * `KnownProvider` union below is a copy of pi-ai's; entries declared
24
24
  * here are typechecked against it.
25
25
  */
26
26
 
27
- // Mirrored from pi-ai's `KnownProvider` (pi-mono/packages/ai/src/types.ts).
27
+ // Mirrored from pi-ai's `KnownProvider` (temp/pi/packages/ai/src/types.ts).
28
28
  // Only ids declared in the catalog below are typechecked against it, so a
29
29
  // provider added upstream stays invisible here until it gets an entry.
30
30
  export type KnownProvider =
@@ -37,6 +37,7 @@ export type KnownProvider =
37
37
  | "azure-openai-responses"
38
38
  | "openai-codex"
39
39
  | "radius"
40
+ | "typesafe"
40
41
  | "nvidia"
41
42
  | "deepseek"
42
43
  | "github-copilot"
@@ -59,6 +60,7 @@ export type KnownProvider =
59
60
  | "opencode"
60
61
  | "opencode-go"
61
62
  | "kimi-coding"
63
+ | "meta"
62
64
  | "cloudflare-workers-ai"
63
65
  | "cloudflare-ai-gateway"
64
66
  | "qwen-token-plan"
@@ -97,46 +99,52 @@ export const PROVIDER_CATALOG: CatalogEntry[] = [
97
99
  notes:
98
100
  "API key or Claude Pro/Max subscription OAuth. When both are set, pi prefers the OAuth credential.",
99
101
  models: [
102
+ "claude-opus-5-5",
103
+ "claude-sonnet-5-5",
104
+ "claude-fable-5-1",
105
+ "claude-opus-5",
106
+ "claude-sonnet-5",
107
+ "claude-opus-4-8",
100
108
  "claude-opus-4-7",
101
- "claude-opus-4-6",
102
- "claude-opus-4-5",
103
109
  "claude-sonnet-4-6",
104
- "claude-sonnet-4-5",
105
110
  "claude-haiku-4-5",
106
- "claude-3-7-sonnet-20250219",
107
- "claude-3-5-sonnet-20241022",
108
- "claude-3-5-haiku-20241022",
109
111
  ],
110
112
  },
111
113
  {
112
114
  id: "openai",
113
115
  displayName: "OpenAI",
114
116
  credentials: apiKeyOnly("OPENAI_API_KEY"),
117
+ oauth: true,
118
+ notes:
119
+ "API key or Sign in with ChatGPT (uses a ChatGPT subscription with the OpenAI API).",
115
120
  models: [
116
- "gpt-5.1",
117
- "gpt-5",
121
+ "gpt-6.1-sol",
122
+ "gpt-6-sol",
123
+ "gpt-6-luna",
124
+ "gpt-6-astra",
125
+ "gpt-5.5",
126
+ "gpt-5.4-mini",
118
127
  "gpt-4.1",
119
- "gpt-4o",
120
- "gpt-4o-mini",
121
- "o4-mini",
122
128
  "o3",
123
- "o3-mini",
124
129
  ],
125
130
  },
126
131
  {
127
132
  id: "openai-codex",
128
- displayName: "OpenAI Codex",
133
+ displayName: "OpenAI Codex (legacy)",
129
134
  credentials: [],
130
135
  oauth: true,
131
136
  notes:
132
- "ChatGPT Plus/Pro subscription via OAuth — no API key. Sign in below.",
137
+ "ChatGPT Plus/Pro subscription via OAuth — no API key. Superseded by Sign in with ChatGPT on OpenAI.",
133
138
  models: [
139
+ "gpt-6.1-sol",
140
+ "gpt-6-sol",
141
+ "gpt-6-luna",
142
+ "gpt-6-astra",
143
+ "gpt-5.6-sol",
144
+ "gpt-5.6-luna",
145
+ "gpt-5.6-terra",
134
146
  "gpt-5.5",
135
- "gpt-5.4",
136
- "gpt-5.4-mini",
137
- "gpt-5.3-codex",
138
147
  "gpt-5.3-codex-spark",
139
- "gpt-5.2",
140
148
  ],
141
149
  },
142
150
  {
@@ -144,17 +152,21 @@ export const PROVIDER_CATALOG: CatalogEntry[] = [
144
152
  displayName: "Google Gemini",
145
153
  credentials: apiKeyOnly("GEMINI_API_KEY"),
146
154
  models: [
155
+ "gemini-3.8-flash",
156
+ "gemini-3.1-pro-preview",
157
+ "gemini-3.5-flash-lite",
147
158
  "gemini-2.5-pro",
148
159
  "gemini-2.5-flash",
149
- "gemini-2.5-flash-lite",
150
- "gemini-2.0-flash",
151
160
  ],
152
161
  },
153
162
  {
154
163
  id: "deepseek",
155
164
  displayName: "DeepSeek",
156
165
  credentials: apiKeyOnly("DEEPSEEK_API_KEY"),
157
- models: ["deepseek-chat", "deepseek-reasoner"],
166
+ models: [
167
+ "deepseek-v4-pro",
168
+ "deepseek-flash",
169
+ ],
158
170
  },
159
171
  {
160
172
  id: "mistral",
@@ -174,30 +186,39 @@ export const PROVIDER_CATALOG: CatalogEntry[] = [
174
186
  models: [
175
187
  "llama-3.3-70b-versatile",
176
188
  "llama-3.1-8b-instant",
177
- "moonshotai/kimi-k2-instruct",
189
+ "openai/gpt-oss-120b",
190
+ "qwen/qwen3.8-27b",
178
191
  ],
179
192
  },
180
193
  {
181
194
  id: "cerebras",
182
195
  displayName: "Cerebras",
183
196
  credentials: apiKeyOnly("CEREBRAS_API_KEY"),
184
- models: ["llama3.3-70b", "llama-4-scout-17b-16e-instruct"],
197
+ models: [
198
+ "gpt-oss-120b",
199
+ "qwen-3.8-27b",
200
+ ],
185
201
  },
186
202
  {
187
203
  id: "xai",
188
204
  displayName: "xAI",
189
205
  credentials: apiKeyOnly("XAI_API_KEY"),
190
- models: ["grok-4", "grok-3", "grok-3-mini"],
206
+ models: [
207
+ "grok-4.7",
208
+ "grok-4.6",
209
+ "grok-4.5",
210
+ ],
191
211
  },
192
212
  {
193
213
  id: "openrouter",
194
214
  displayName: "OpenRouter",
195
215
  credentials: apiKeyOnly("OPENROUTER_API_KEY"),
196
216
  models: [
197
- "anthropic/claude-sonnet-4.5",
198
- "openai/gpt-5",
199
- "google/gemini-2.5-pro",
200
- "deepseek/deepseek-chat",
217
+ "anthropic/claude-sonnet-5.5",
218
+ "anthropic/claude-opus-5.5",
219
+ "openai/gpt-6-astra",
220
+ "google/gemini-3.8-flash",
221
+ "deepseek/deepseek-v4-pro",
201
222
  ],
202
223
  },
203
224
  {
@@ -205,37 +226,48 @@ export const PROVIDER_CATALOG: CatalogEntry[] = [
205
226
  displayName: "Vercel AI Gateway",
206
227
  credentials: apiKeyOnly("AI_GATEWAY_API_KEY"),
207
228
  models: [
208
- "anthropic/claude-sonnet-4.5",
209
- "openai/gpt-5",
210
- "google/gemini-2.5-pro",
229
+ "anthropic/claude-sonnet-5.5",
230
+ "openai/gpt-6-sol",
231
+ "google/gemini-3.8-flash",
211
232
  ],
212
233
  },
213
234
  {
214
235
  id: "zai",
215
236
  displayName: "ZAI",
216
237
  credentials: apiKeyOnly("ZAI_API_KEY"),
217
- models: ["glm-4.6", "glm-4.5"],
238
+ models: [
239
+ "glm-5.3",
240
+ "glm-5.3-flash",
241
+ "glm-5.2",
242
+ ],
218
243
  },
219
244
  {
220
245
  id: "fireworks",
221
246
  displayName: "Fireworks",
222
247
  credentials: apiKeyOnly("FIREWORKS_API_KEY"),
223
248
  models: [
224
- "accounts/fireworks/models/deepseek-v3",
225
- "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
249
+ "accounts/fireworks/models/kimi-k3",
250
+ "accounts/fireworks/models/glm-5p3",
251
+ "accounts/fireworks/models/deepseek-v4p1-flash",
226
252
  ],
227
253
  },
228
254
  {
229
255
  id: "kimi-coding",
230
256
  displayName: "Kimi For Coding",
231
257
  credentials: apiKeyOnly("KIMI_API_KEY"),
232
- models: ["kimi-k2-coding"],
258
+ models: [
259
+ "kimi-for-coding",
260
+ "k3",
261
+ ],
233
262
  },
234
263
  {
235
264
  id: "minimax",
236
265
  displayName: "MiniMax",
237
266
  credentials: apiKeyOnly("MINIMAX_API_KEY"),
238
- models: ["minimax-m2"],
267
+ models: [
268
+ "MiniMax-M3",
269
+ "MiniMax-M2.7",
270
+ ],
239
271
  },
240
272
  {
241
273
  id: "minimax-cn",
@@ -12,10 +12,9 @@
12
12
  * }
13
13
  * }
14
14
  *
15
- * v0: file-based, plaintext (HLD §15). Read on every `load()` so PUT
16
- * writes are immediately visible to subsequent agent starts. Edits to
17
- * the in-memory snapshot don't reach already-running agents — matches
18
- * the secrets store's restart-required model.
15
+ * File-based, plaintext. Read on every `load()` so PUT
16
+ * writes are visible to subsequent agent starts. The settings API requests
17
+ * a soft reload for affected running agents to refresh their environment.
19
18
  */
20
19
 
21
20
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Reuses pi-coding-agent's `ModelRuntime` — the exact machinery behind
6
6
  * pi's `/login` command. Tokens persist to pi's own
7
- * `<agentDir>/auth.json` (default `~/.pi/agent/auth.json`, 0600,
7
+ * `<agentDir>/auth.json` (this harness's `.secrets/pi/auth.json`, 0600,
8
8
  * file-locked), NOT to the harness's models.json. Rationale:
9
9
  * - refresh tokens rotate on every refresh; spawned pi children
10
10
  * already refresh + persist under a file lock, so the harness must
@@ -29,7 +29,7 @@
29
29
  * login per provider — the callback server binds a fixed port.
30
30
  */
31
31
 
32
- import { ModelRuntime } from "@earendil-works/pi-coding-agent";
32
+ import { ModelRuntime, SettingsManager } from "@earendil-works/pi-coding-agent";
33
33
  import type { AuthPrompt } from "@earendil-works/pi-ai";
34
34
  import type { Logger } from "./logger.js";
35
35
 
@@ -96,7 +96,7 @@ export class OAuthLoginManager {
96
96
 
97
97
  /**
98
98
  * Shared pi model runtime, created on first use. Owns login/logout
99
- * orchestration; credentials go to pi's default auth.json.
99
+ * orchestration; credentials go to the harness's pi auth.json.
100
100
  */
101
101
  private runtime(): Promise<ModelRuntime> {
102
102
  this.runtimePromise ??= ModelRuntime.create();
@@ -172,6 +172,10 @@ export class OAuthLoginManager {
172
172
  }
173
173
  },
174
174
  prompt: (prompt) => this.handlePrompt(entry, prompt, surfaced),
175
+ }, {
176
+ // Sign in with ChatGPT identifies the installation by a stable UUID.
177
+ // Use pi's own (global settings `deviceId`), as pi's `/login` does.
178
+ getDeviceId: () => SettingsManager.create(process.cwd()).getOrCreateDeviceId(),
175
179
  })
176
180
  .then(() => {
177
181
  this.results.set(providerId, { state: "success" });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * delivery-receipts — a pi extension the runner loads into every spawned
3
+ * `pi --mode rpc` child from the package (see `runner.ts:spawnPi`), never
4
+ * from the agent folder, so an agent can't lose or edit it.
5
+ *
6
+ * Every input the runner sends carries `EventId: <row id>` in its
7
+ * `<harness-metadata>` block. For each user message pi saves, this reports
8
+ * `{ entryId, eventIds }`: the saved session entry and the queue rows it
9
+ * contains. That one receipt answers both "did the row reach pi?" and "which
10
+ * saved message is it?", so the two can't disagree.
11
+ *
12
+ * Transport: `pi.appendEntry(RECEIPT_TYPE, { entryId, eventIds })`. Pi saves
13
+ * it as a custom session entry (kept out of the model's context) and reports
14
+ * it on stdout as an `entry_appended` event, which `PiRpcClient` routes to the
15
+ * runner. Saved receipts also let recovery rebuild row-to-entry links from
16
+ * the session file.
17
+ *
18
+ * Pi emits `message_end` before it appends the message to the session, so the
19
+ * entry id doesn't exist yet there. Instead we re-scan the entries on the
20
+ * events that follow and report each entry once. Entries already in the file
21
+ * when pi starts are history from earlier batches (their receipts were sent
22
+ * then, or the session predates saved receipts), so they're never reported
23
+ * again — otherwise every spawn would append duplicate receipts.
24
+ */
25
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
26
+
27
+ export const RECEIPT_TYPE = "cognisphere.delivery";
28
+
29
+ /** Row ids named by `EventId:` lines inside `<harness-metadata>` blocks. */
30
+ export function eventIdsIn(text: string): number[] {
31
+ const ids: number[] = [];
32
+ for (const block of text.matchAll(/<harness-metadata>([\s\S]*?)<\/harness-metadata>/g)) {
33
+ const m = /^EventId: (\d+)$/m.exec(block[1]!);
34
+ if (m) ids.push(Number(m[1]));
35
+ }
36
+ return ids;
37
+ }
38
+
39
+ function messageText(entry: SessionEntry): string | null {
40
+ if (entry.type !== "message" || entry.message.role !== "user") return null;
41
+ const { content } = entry.message;
42
+ if (typeof content === "string") return content;
43
+ return content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
44
+ }
45
+
46
+ export default function deliveryReceipts(pi: ExtensionAPI): void {
47
+ const reported = new Set<string>();
48
+
49
+ pi.on("session_start", (_event, ctx) => {
50
+ for (const entry of ctx.sessionManager.getEntries()) reported.add(entry.id);
51
+ });
52
+
53
+ const sweep = (_event: unknown, ctx: ExtensionContext): void => {
54
+ for (const entry of ctx.sessionManager.getEntries()) {
55
+ if (reported.has(entry.id)) continue;
56
+ const text = messageText(entry);
57
+ if (text === null) continue;
58
+ reported.add(entry.id);
59
+ const eventIds = eventIdsIn(text);
60
+ if (eventIds.length === 0) continue;
61
+ pi.appendEntry(RECEIPT_TYPE, { entryId: entry.id, eventIds });
62
+ }
63
+ };
64
+
65
+ pi.on("message_start", sweep);
66
+ pi.on("turn_start", sweep);
67
+ pi.on("turn_end", sweep);
68
+ pi.on("agent_end", sweep);
69
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Mirror `.secrets/models.json` `modelOverrides` into pi's own models.json
3
- * (`<getAgentDir()>/models.json`, i.e. `~/.pi/agent/models.json` unless
4
- * `PI_CODING_AGENT_DIR` overrides it). Pi children re-read that file on
3
+ * (`<getAgentDir()>/models.json`; the harness points `PI_CODING_AGENT_DIR`
4
+ * at its own `.secrets/pi/`, see `main.ts`). Pi children re-read that file on
5
5
  * every spawn, so overrides configured in the harness Models settings take
6
6
  * effect natively inside pi — model registry, ContextUsage reporting, and
7
7
  * pi's own compaction thresholds all see the overridden contextWindow /
package/src/core/queue.ts CHANGED
@@ -68,8 +68,8 @@ export interface EventRow {
68
68
  /**
69
69
  * Derive a re-dispatch {@link RetryMode} from a row's persisted state. No
70
70
  * dedicated column: a row that was delivered to the model has its
71
- * `pi_entry_id` written in real time (by the runner, from the harness-bridge
72
- * extension), so its presence on a retried row means "already in pi's
71
+ * `pi_entry_id` written in real time (by the runner, from a delivery receipt),
72
+ * so its presence on a retried row means "already in pi's
73
73
  * history → continue, don't resend".
74
74
  */
75
75
  function deriveRetryMode(row: EventRow): RetryMode | undefined {
@@ -278,17 +278,13 @@ export class AgentDb {
278
278
 
279
279
  /**
280
280
  * Bind a row to its position in pi's session JSONL. Called by the runner in
281
- * real time as the harness-bridge extension reports each user-message entry
282
- * id (so the link exists even for rows whose batch later fails, and without
283
- * waiting for pi to exit). COALESCE on the session id preserves a value from
284
- * a prior attempt; the entry id is written outright (the latest delivery
285
- * wins).
281
+ * real time as delivery receipts name the row (so the link exists even for
282
+ * rows whose batch later fails, and without waiting for pi to exit).
283
+ * COALESCE on the session id preserves a value from a prior attempt.
286
284
  */
287
285
  setRowEntryId(rowId: number, sessionId: string, entryId: string): void {
288
- // Defensive: never rebind a row whose pi_entry_id is already set. The
289
- // runner already dedups entryIds per batch, but this guards against a row
290
- // being bound twice (e.g. a stale absolute-index report or a logic error)
291
- // — once a row carries an entry id it stays put.
286
+ // Never rebind: receipts re-report history on every batch, so the first
287
+ // (earliest) saved copy of a row keeps the link.
292
288
  this.db
293
289
  .prepare(
294
290
  `UPDATE events
@@ -300,20 +296,6 @@ export class AgentDb {
300
296
  .run(sessionId, entryId, Date.now(), rowId);
301
297
  }
302
298
 
303
- /** All distinct `pi_entry_id`s already bound to rows on this thread. The
304
- * runner pre-seeds its per-batch seen-set with these so the harness-bridge's
305
- * re-report of historical entries (it sweeps the whole reused session JSONL)
306
- * is ignored — only entryIds new to this thread bind to this batch's rows. */
307
- entryIdsForThread(threadId: string): string[] {
308
- const rows = this.db
309
- .prepare<unknown[], { pi_entry_id: string }>(
310
- `SELECT DISTINCT pi_entry_id FROM events
311
- WHERE thread_id = ? AND pi_entry_id IS NOT NULL`,
312
- )
313
- .all(threadId);
314
- return rows.map((r) => r.pi_entry_id);
315
- }
316
-
317
299
  /**
318
300
  * Mark every id done. `sessionId` (when non-null) is written to all rows.
319
301
  * Per-row `pi_entry_id`s are no longer threaded through here — they are
package/src/core/rpc.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
  import { StringDecoder } from "node:string_decoder";
3
3
  import type { Logger } from "./logger.js";
4
+ import { RECEIPT_TYPE } from "./pi-delivery-receipts.js";
4
5
 
5
6
  interface RpcResponse {
6
7
  type: "response";
@@ -16,14 +17,14 @@ interface ExtensionUiRequest {
16
17
  id: string;
17
18
  method: string;
18
19
  title?: string;
19
- /** Present on fire-and-forget `setStatus` requests (the harness-bridge
20
- * extension uses these to report session entry ids — see {@link onHarnessEntry}). */
21
- statusKey?: string;
22
- statusText?: string;
23
20
  }
24
21
 
22
+ /** What pi did with a `prompt`/`steer` frame. `handled` = an extension
23
+ * `input` handler consumed it: no session entry, so no receipt will come. */
24
+ export type InputDisposition = "started" | "queued" | "handled";
25
+
25
26
  /**
26
- * Minimal shape of an entry in `agent_end.messages` / `message_start.message`.
27
+ * Minimal shape of an entry in `agent_end.messages`.
27
28
  * The harness only inspects the final message's `role` and (for assistants)
28
29
  * `stopReason` / `errorMessage` to decide turn completion; the full pi message
29
30
  * type isn't needed here.
@@ -34,11 +35,12 @@ export interface PiMessage {
34
35
  errorMessage?: string;
35
36
  }
36
37
 
37
- /** Reported by the harness-bridge extension via `setStatus("cognisphere", …)`. */
38
- export interface HarnessEntryReport {
39
- kind: "user_entry";
40
- index: number;
38
+ /** Reported by the delivery-receipts extension (`pi-delivery-receipts.ts`)
39
+ * as a custom session entry: pi saved the queue rows `eventIds` as session
40
+ * entry `entryId`. */
41
+ export interface DeliveryReceipt {
41
42
  entryId: string;
43
+ eventIds: number[];
42
44
  }
43
45
 
44
46
  type RpcMessage =
@@ -46,7 +48,8 @@ type RpcMessage =
46
48
  | ExtensionUiRequest
47
49
  | { type: "agent_start" }
48
50
  | { type: "agent_end"; messages?: PiMessage[] }
49
- | { type: "message_start"; message?: PiMessage }
51
+ | { type: "agent_settled" }
52
+ | { type: "entry_appended"; entry?: { type?: string; customType?: string; data?: unknown } }
50
53
  | { type: string; [k: string]: unknown };
51
54
 
52
55
  /**
@@ -70,8 +73,8 @@ export class PiRpcClient {
70
73
  >();
71
74
  private nextId = 1;
72
75
  private agentEndHandler: ((messages: PiMessage[]) => void) | undefined;
73
- private userMessageStartHandler: (() => void) | undefined;
74
- private harnessEntryHandler: ((report: HarnessEntryReport) => void) | undefined;
76
+ private agentSettledHandler: (() => void) | undefined;
77
+ private deliveryReceiptHandler: ((receipt: DeliveryReceipt) => void) | undefined;
75
78
  private exitPromise: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
76
79
 
77
80
  constructor(child: ChildProcess, log: Logger) {
@@ -111,24 +114,24 @@ export class PiRpcClient {
111
114
  });
112
115
  }
113
116
 
114
- /** Fires once per run when pi's agent loop ends. Receives the run's
115
- * messages so the caller can inspect the final message's shape (role +
116
- * stopReason) to decide whether the turn completed. */
117
+ /** Fires each time one low-level pi run ends. Pi may still retry, recover
118
+ * from overflow or run a late steer afterwards — wait for
119
+ * {@link onAgentSettled} — so the caller keeps the latest run's messages
120
+ * and judges completion from the final message's role + stopReason. */
117
121
  onAgentEnd(h: (messages: PiMessage[]) => void): void {
118
122
  this.agentEndHandler = h;
119
123
  }
120
124
 
121
- /** Fires each time pi appends a *user* message (the initial prompt and each
122
- * steer). The caller counts these in dispatch order to know which rows
123
- * actually reached the model. */
124
- onUserMessageStart(h: () => void): void {
125
- this.userMessageStartHandler = h;
125
+ /** Fires when pi is fully settled: no retry, compaction or queued
126
+ * continuation remains. */
127
+ onAgentSettled(h: () => void): void {
128
+ this.agentSettledHandler = h;
126
129
  }
127
130
 
128
- /** Fires when the harness-bridge extension reports a user-message session
129
- * entry id over the `setStatus("cognisphere", …)` channel. */
130
- onHarnessEntry(h: (report: HarnessEntryReport) => void): void {
131
- this.harnessEntryHandler = h;
131
+ /** Fires when the delivery-receipts extension reports that pi saved a user
132
+ * message containing these queue rows. */
133
+ onDeliveryReceipt(h: (receipt: DeliveryReceipt) => void): void {
134
+ this.deliveryReceiptHandler = h;
132
135
  }
133
136
 
134
137
  waitExit() {
@@ -139,7 +142,7 @@ export class PiRpcClient {
139
142
  return this.stderrBuf;
140
143
  }
141
144
 
142
- async sendPrompt(text: string): Promise<void> {
145
+ async sendPrompt(text: string): Promise<InputDisposition> {
143
146
  const id = `p${this.nextId++}`;
144
147
  // Pi acks a prompt frame immediately (the agent loop runs after), so a
145
148
  // missing ack means the child is wedged. Without the timeout, the runner
@@ -151,11 +154,15 @@ export class PiRpcClient {
151
154
  60_000,
152
155
  );
153
156
  if (!r.success) throw new Error(`pi rejected prompt: ${r.error ?? "unknown"}`);
157
+ return dispositionOf(r);
154
158
  }
155
159
 
156
- sendSteer(text: string): void {
160
+ /** Rejects if the frame can't be written, pi rejects it, or no ack comes. */
161
+ async sendSteer(text: string): Promise<InputDisposition> {
157
162
  const id = `s${this.nextId++}`;
158
- this.writeFrame({ id, type: "steer", message: text });
163
+ const r = await this.writeAndAwait(id, { id, type: "steer", message: text }, 60_000);
164
+ if (!r.success) throw new Error(`pi rejected steer: ${r.error ?? "unknown"}`);
165
+ return dispositionOf(r);
159
166
  }
160
167
 
161
168
  sendAbort(): void {
@@ -295,9 +302,20 @@ export class PiRpcClient {
295
302
  this.agentEndHandler?.(messages);
296
303
  return;
297
304
  }
298
- if (p.type === "message_start") {
299
- const role = (p as { message?: PiMessage }).message?.role;
300
- if (role === "user") this.userMessageStartHandler?.();
305
+ if (p.type === "agent_settled") {
306
+ this.agentSettledHandler?.();
307
+ return;
308
+ }
309
+ if (p.type === "entry_appended") {
310
+ const entry = (p as { entry?: { type?: string; customType?: string; data?: unknown } }).entry;
311
+ if (entry?.type === "custom" && entry.customType === RECEIPT_TYPE) {
312
+ const r = entry.data as Partial<DeliveryReceipt> | undefined;
313
+ if (typeof r?.entryId === "string" && Array.isArray(r.eventIds)) {
314
+ this.deliveryReceiptHandler?.({ entryId: r.entryId, eventIds: r.eventIds });
315
+ } else {
316
+ this.log.debug({ data: entry.data }, "bad delivery receipt payload");
317
+ }
318
+ }
301
319
  return;
302
320
  }
303
321
  if (p.type === "extension_ui_request") {
@@ -336,25 +354,6 @@ export class PiRpcClient {
336
354
  }
337
355
 
338
356
  private handleExtensionUi(req: ExtensionUiRequest): void {
339
- // The harness-bridge extension reports session entry ids as a
340
- // fire-and-forget `setStatus` keyed "cognisphere". Intercept those and
341
- // route to the harness-entry handler instead of treating them as UI.
342
- if (req.method === "setStatus" && req.statusKey === "cognisphere") {
343
- if (req.statusText == null) return;
344
- try {
345
- const report = JSON.parse(req.statusText) as HarnessEntryReport;
346
- if (
347
- report.kind === "user_entry" &&
348
- typeof report.index === "number" &&
349
- typeof report.entryId === "string"
350
- ) {
351
- this.harnessEntryHandler?.(report);
352
- }
353
- } catch {
354
- this.log.debug({ statusText: req.statusText }, "bad cognisphere setStatus payload");
355
- }
356
- return;
357
- }
358
357
  const dialog = new Set(["select", "confirm", "input", "editor"]);
359
358
  if (dialog.has(req.method)) {
360
359
  this.log.warn({ method: req.method }, "auto-cancel extension_ui_request");
@@ -372,3 +371,10 @@ export class PiRpcClient {
372
371
  this.pending.clear();
373
372
  }
374
373
  }
374
+
375
+ /** Pi (0.99+) reports `data.disposition` on successful prompt/steer acks.
376
+ * Missing → treat as a normal accepted input. */
377
+ function dispositionOf(r: RpcResponse): InputDisposition {
378
+ const d = (r.data as { disposition?: unknown } | undefined)?.disposition;
379
+ return d === "handled" || d === "queued" ? d : "started";
380
+ }