@nothumanwork/pi-canary 0.1.0 → 0.1.1

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 (3) hide show
  1. package/README.md +8 -3
  2. package/canary.ts +32 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -41,7 +41,12 @@ Thinking/reasoning blocks, structured tool calls and their arguments, and tool r
41
41
 
42
42
  ## Recovery
43
43
 
44
- At the end of the agent's response to a user message, a missing prefix triggers this sequence:
44
+ The prefix is checked after each assistant message. A handover is only useful while the agent is still working, so recovery depends on whether that message ends the turn:
45
+
46
+ - **Mid-run message** (it has tool calls, so the agent will continue): recovery starts immediately, as below. Tool calls in the offending message have already run; tool calls after that are blocked.
47
+ - **Final message** (no tool calls, so the agent is ending its turn): a warning is shown, and nothing else happens. No handover request, tool blocking, compaction, or continuation.
48
+
49
+ Recovery sequence:
45
50
 
46
51
  1. Flag the failure with a visible instruction and require the agent's next response to be a self-contained handover prompt. Tool execution is blocked during this phase.
47
52
  2. Accept a complete, correctly prefixed, tool-free response beginning with `chirp: # Canary handover`, followed by a newline and handover content.
@@ -50,7 +55,7 @@ At the end of the agent's response to a user message, a missing prefix triggers
50
55
 
51
56
  “Restart” means **fresh model context within the same session file**, not a new Pi process or new session file. The restarted model receives the system/tool checkpoint, compaction summary, and handover. Older conversation entries remain in the session history but are not replayed into the restarted context. Successful summary usage is attached to the compaction entry. Known limitation: usage from rejected or interrupted summary requests is not included in Pi's session totals; the current extension API does not expose an independent usage-recording operation.
52
57
 
53
- Recovery allows at most two handover responses and one automatic restart per user message. The handover-attempt budget also applies when an earlier extension requests continuation. A deferred handover pauses recovery immediately instead of submitting more requests while a provider job may still be running; this does not explicitly cancel or retrieve that provider job. Further violations pause recovery rather than creating a loop. Provider errors, missing/empty/truncated summaries, and user cancellation do not discard history or automatically restart. Queued user input takes priority; a new user message resets the recovery state. If recovery pauses, inspect the preserved handover/history and send a new instruction, or disable canary mode.
58
+ Recovery allows at most two handover responses and one automatic restart per user message. If the restarted agent misses the prefix mid-run, the run is stopped and recovery pauses. A miss on its final message only shows a warning. The handover-attempt budget also applies when an earlier extension requests continuation. A deferred handover pauses recovery immediately instead of submitting more requests while a provider job may still be running; this does not explicitly cancel or retrieve that provider job. Further violations pause recovery rather than creating a loop. Provider errors, missing/empty/truncated summaries, and user cancellation do not discard history or automatically restart. Queued user input takes priority; a new user message resets the recovery state. If recovery pauses, inspect the preserved handover/history and send a new instruction, or disable canary mode.
54
59
 
55
60
  ## Tests
56
61
 
@@ -66,6 +71,6 @@ For a standalone extension, point the tests at your installed coding-agent entry
66
71
  PI_CANARY_SDK=/absolute/path/to/pi/dist/index.js bun test ./canary.test.ts
67
72
  ```
68
73
 
69
- The tests cover keyword persistence and automatic enabling/reload, settings precedence, case preservation, malformed/locked/BOM-prefixed settings, Unicode separators, symlinks, cancelled command changes, exact-prefix checks, reasoning/tool exclusions, branch restoration, bounded/deferred handover attempts, compaction failures, cancellation, queued input, preservation of other extensions' boundary drafts, headless operation, and Pi's real file loader/lifecycle with a scripted, offline provider.
74
+ The tests cover keyword persistence and automatic enabling/reload, settings precedence, case preservation, malformed/locked/BOM-prefixed settings, Unicode separators, symlinks, cancelled command changes, exact-prefix checks, per-message checks (mid-run misses recover, final-message misses only warn), reasoning/tool exclusions, branch restoration, bounded/deferred handover attempts, compaction failures, cancellation, queued input, preservation of other extensions' boundary drafts, headless operation, and Pi's real file loader/lifecycle with a scripted, offline provider.
70
75
 
71
76
  See [Measured changes](canary.value.md) for before/after results and proposals deliberately not retained. To run the same tests against an older implementation, set `PI_CANARY_EXTENSION` to its absolute file path; both the unit harness and real loader use that implementation.
package/canary.ts CHANGED
@@ -279,8 +279,9 @@ export default function (pi: ExtensionAPI) {
279
279
  text.slice(`${recovery.keyword}: ${HANDOVER_HEADING}\n`.length).trim()
280
280
  ? text
281
281
  : undefined;
282
- } else if (message.stopReason !== "deferred" && text.trim() && !text.startsWith(`${keywordForSpan}: `)) {
283
- violated = true;
282
+ } else {
283
+ // Judged at this message's turn_end, once it is known whether the agent continues.
284
+ violated = message.stopReason !== "deferred" && !!text.trim() && !text.startsWith(`${keywordForSpan}: `);
284
285
  }
285
286
  });
286
287
 
@@ -290,17 +291,28 @@ export default function (pi: ExtensionAPI) {
290
291
  }
291
292
  });
292
293
 
294
+ const requestHandover = (current: Recovery, entries: AgentBeforeSettleEvent["entries"]) => ({
295
+ entries: [...entries, {
296
+ type: "custom_message" as const,
297
+ customType: "canary-handover-request",
298
+ content: handoverRequest(current.keyword),
299
+ display: true,
300
+ }],
301
+ continue: true,
302
+ });
303
+
293
304
  const handleBoundary = async (
294
305
  event: AgentBeforeSettleEvent | TurnEndEvent,
295
306
  ctx: ExtensionContext,
296
307
  ) => {
297
- if (event.type === "turn_end" && !recovery) return;
308
+ const missed = event.type === "turn_end" && violated;
309
+ if (event.type === "turn_end") violated = false;
310
+ if (!recovery && !missed) return;
298
311
  if (!keywordForSpan || activeKeyword() !== keywordForSpan || paused) return;
299
312
  // Never override Escape, provider errors, or work already queued by the user.
300
313
  // In particular, do not compact a stale snapshot.
301
314
  if (event.outcome !== "completed" || ctx.signal?.aborted) {
302
315
  recovery = undefined;
303
- violated = false;
304
316
  return;
305
317
  }
306
318
  if (event.context.pendingMessages.length || ctx.hasPendingMessages()) return;
@@ -316,26 +328,28 @@ export default function (pi: ExtensionAPI) {
316
328
  : "The agent did not produce a complete, correctly prefixed handover after two attempts";
317
329
  return { ...pauseRecovery(ctx, reason, event.entries), continue: false };
318
330
  }
319
- if (event.continue) return;
320
-
321
- if (!recovery) {
322
- if (!violated) return;
323
- if (restarted) return pauseRecovery(ctx, "The restarted agent missed the prefix again; automatic recovery is limited to once per user message", event.entries);
331
+ if (!recovery && event.type === "turn_end") {
332
+ // A message without tool calls ends the agent's turn. A handover there is
333
+ // too late to be useful, so report the miss without blocking or compacting.
334
+ if (event.message.role !== "assistant" || !event.message.content.some((block) => block.type === "toolCall")) {
335
+ notify(ctx, "Canary prefix missing on the final message. No handover requested because the turn has ended.", "warning");
336
+ return;
337
+ }
338
+ if (restarted) {
339
+ ctx.abort();
340
+ return { ...pauseRecovery(ctx, "The restarted agent missed the prefix again; automatic recovery is limited to once per user message", event.entries), continue: false };
341
+ }
324
342
  recovery = { keyword: keywordForSpan, attempts: 0 };
325
343
  notify(ctx, "Canary prefix missing. Requesting a handover before compaction.", "warning");
344
+ return requestHandover(recovery, event.entries);
326
345
  }
346
+ if (event.continue || !recovery) return;
347
+
327
348
  const current = recovery;
328
349
  if (!current.handover) {
350
+ // Re-request only once the agent has stopped without a usable handover.
329
351
  if (event.type === "turn_end") return;
330
- return {
331
- entries: [...event.entries, {
332
- type: "custom_message" as const,
333
- customType: "canary-handover-request",
334
- content: handoverRequest(current.keyword),
335
- display: true,
336
- }],
337
- continue: true,
338
- };
352
+ return requestHandover(current, event.entries);
339
353
  }
340
354
 
341
355
  // Summarize at turn_end, while ctx.signal still belongs to the live run.
@@ -363,7 +377,6 @@ export default function (pi: ExtensionAPI) {
363
377
  }
364
378
  const handover = current.handover;
365
379
  recovery = undefined;
366
- violated = false;
367
380
  restarted = true;
368
381
  notify(ctx, "Canary recovery complete. Restarting with the summary and handover.");
369
382
  // A boundary compaction is atomic with continuation. Retain-none gives a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nothumanwork/pi-canary",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Context canary and recovery for Pi",
6
6
  "keywords": [