one-code-extension 0.5.0 → 0.6.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 (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
package/README.md CHANGED
@@ -83,7 +83,7 @@ The [`@one-ai/one-code`](https://www.npmjs.com/package/@one-ai/one-code)
83
83
  app package bundles a pinned version of pi and provides the `onecode` command.
84
84
  The [`one-code-extension`](https://www.npmjs.com/package/one-code-extension)
85
85
  package uses your existing pi installation and is tested against pi versions
86
- 0.84.3 through 1.3. For the extension package, use `pi` in place of `onecode`.
86
+ 1.0.0 through 1.4. For the extension package, use `pi` in place of `onecode`.
87
87
 
88
88
  The app opens in a full-screen terminal interface by default. See the
89
89
  [installation guide](docs/guide/installation.md) for display settings and more
@@ -102,12 +102,15 @@ from user and managed scope. `includeCoAuthoredBy`, `apiKeyHelper`, `forceLoginM
102
102
  `/doctor report` to see, per file, which keys were used, ignored, or
103
103
  refused.
104
104
 
105
- ## Prompt layout on Anthropic models
106
-
107
- Claude Code delivers part of its reminder stack to Opus and Sonnet as a
108
- system-role message after the first user message. pi's message model can't
109
- express that, so One Code uses the layout Claude Code uses for Haiku on
110
- every model. The content is the same; only the placement differs.
105
+ ## Prompt layout
106
+
107
+ Claude Code delivers part of its reminder stack (the environment, the tool
108
+ and agent listings, the date) to Opus and Sonnet as a system-role message
109
+ after the first user message. One Code does the same on every model that
110
+ pi's model catalog marks as taking a system message at that spot, on
111
+ Anthropic and on OpenAI. Every other model, Haiku 4.5 included, gets the
112
+ same text inside the first user message, the layout Claude Code uses for
113
+ Haiku. The content is the same either way; only the placement differs.
111
114
 
112
115
  ## Platform support
113
116
 
@@ -102,10 +102,9 @@ pi install npm:one-code-extension
102
102
  ```
103
103
 
104
104
  Use `pi` in place of `onecode` for every command in this guide. The
105
- extension is tested against pi 0.84.3 through 1.3 and warns at startup when
106
- your pi is outside that range. It doesn't load on a pi older than 0.84.3;
107
- update pi first with `pi update`. pi 1.0 and later start in full-screen mode;
108
- on an older pi it's opt-in. See
105
+ extension is tested against pi 1.0.0 through 1.4 and warns at startup when
106
+ your pi is outside that range. It doesn't load on a pi older than 1.0.0;
107
+ update pi first with `pi update`. pi starts in full-screen mode; see
109
108
  [Full-screen mode and themes](configuration.md#full-screen-mode-and-themes).
110
109
 
111
110
  pi 0.99 and later ship their own tool search and MCP as built-in
@@ -39,7 +39,7 @@ pins pi itself.
39
39
  **`Failed to load extension` on startup.** The extension package and the
40
40
  pi it runs on are out of step. On the bundled app, reinstall it. On your own
41
41
  pi, run `pi update npm:one-code-extension`, and update pi itself with
42
- `pi update` if it's older than 0.84.3.
42
+ `pi update` if it's older than 1.0.0.
43
43
 
44
44
  **The stock pi first-run wizard didn't appear.** The bundled app seeds
45
45
  pi's settings with the One Code theme, quiet startup, and full-screen mode
@@ -15,8 +15,9 @@
15
15
  * floor are checked before any grant (working-docs/decisions/auto-mode.md,
16
16
  * "Approving a denied call").
17
17
  *
18
- * The store lives in the permissions extension's memory for one session. It is
19
- * never persisted, and a new session starts empty.
18
+ * The store lives in the permissions extension's process state for one
19
+ * session, which a /reload keeps. It is never persisted, and a new session
20
+ * starts empty.
20
21
  */
21
22
 
22
23
  /** Claude Code's `MAX_DENIALS`. */
@@ -377,6 +377,133 @@ export class CronStore {
377
377
  clear(): void {
378
378
  this.jobs.clear();
379
379
  }
380
+
381
+ /**
382
+ * Put back a job the branch recorded (`scheduledJobsOnBranch`), keeping its
383
+ * id: a /reload re-arms the session's jobs. A recurring job past its age is
384
+ * not restored; a fire missed during the reload comes at once.
385
+ */
386
+ restore(job: RecordedJob, now: number): boolean {
387
+ const fields = parseCron(job.cron);
388
+ if (!fields || this.jobs.has(job.id)) return false;
389
+ if (job.recurring && now - job.createdAt >= this.maxAgeMs) return false;
390
+ const base = { id: job.id, cron: job.cron, recurring: job.recurring, fields };
391
+ // Due after the job's last fire on the branch (or its creation), so one missed during the reload is due now.
392
+ const nextFireAt = job.fireAt ?? this.fireAfter(base, Math.max(job.lastFiredAt ?? 0, job.createdAt));
393
+ if (nextFireAt === null) return false;
394
+ this.jobs.set(job.id, { ...base, prompt: job.prompt, source: job.source, createdAt: job.createdAt, nextFireAt });
395
+ return true;
396
+ }
397
+ }
398
+
399
+ /** A job as the branch records it: what `CronStore.restore` takes. */
400
+ export interface RecordedJob {
401
+ id: string;
402
+ cron: string;
403
+ prompt: string;
404
+ recurring: boolean;
405
+ source: CronSource;
406
+ createdAt: number;
407
+ /** A wakeup's exact fire time. */
408
+ fireAt?: number;
409
+ /** A recurring job's last fire on the branch: the next one is due after it. */
410
+ lastFiredAt?: number;
411
+ /** A wakeup's loop start, and whether the keepalive armed it (DynamicLoop.restoreChain). */
412
+ chainStartedAt?: number;
413
+ keepalive?: boolean;
414
+ }
415
+
416
+ type BranchEntry = { type?: string; timestamp?: string; customType?: string; details?: unknown; data?: unknown; message?: unknown };
417
+
418
+ /**
419
+ * The custom entry a main-session job's fire writes (`{ jobId, final, source }`),
420
+ * whatever way its notification is then delivered: a fire sent as a plain user
421
+ * message (no turn prompted since a /reload) carries no details.
422
+ */
423
+ export const JOB_FIRED_ENTRY = "one-code:job-fired";
424
+
425
+ /**
426
+ * The custom entry the loop's keepalive writes when it arms a wakeup itself
427
+ * (`{ id, prompt, fireAt, startedAt }`): no `schedule_wakeup` result records that one.
428
+ */
429
+ export const WAKEUP_ARMED_ENTRY = "one-code:wakeup-armed";
430
+ type Message = { role?: string; toolName?: string; toolCallId?: string; isError?: boolean; details?: unknown; content?: unknown; timestamp?: number };
431
+
432
+ /**
433
+ * The main session's scheduled jobs a branch still holds: cron_create and
434
+ * schedule_wakeup results (the prompt read from their calls), minus
435
+ * cron_delete results, a wakeup stop, and final fires. A wakeup supersedes the
436
+ * one before it, as `DynamicLoop.schedule` does. Pure.
437
+ */
438
+ export function scheduledJobsOnBranch(branch: readonly BranchEntry[]): RecordedJob[] {
439
+ const calls = new Map<string, Record<string, unknown>>();
440
+ const jobs = new Map<string, RecordedJob>();
441
+ const dropWakeups = () => {
442
+ for (const [id, job] of jobs) if (job.source === "wakeup") jobs.delete(id);
443
+ };
444
+ for (const entry of branch) {
445
+ if (entry.type === "custom" && entry.customType === WAKEUP_ARMED_ENTRY) {
446
+ const armed = entry.data as { id?: unknown; prompt?: unknown; fireAt?: unknown; startedAt?: unknown } | undefined;
447
+ if (typeof armed?.id !== "string" || typeof armed.prompt !== "string" || typeof armed.fireAt !== "number") continue;
448
+ dropWakeups();
449
+ const target = new Date(armed.fireAt);
450
+ const createdAt = Date.parse(entry.timestamp ?? "") || 0;
451
+ jobs.set(armed.id, {
452
+ id: armed.id,
453
+ cron: `${target.getMinutes()} ${target.getHours()} * * *`,
454
+ prompt: armed.prompt,
455
+ recurring: false,
456
+ source: "wakeup",
457
+ createdAt,
458
+ fireAt: armed.fireAt,
459
+ chainStartedAt: typeof armed.startedAt === "number" ? armed.startedAt : createdAt,
460
+ keepalive: true,
461
+ });
462
+ continue;
463
+ }
464
+ const fired = entry.type === "custom" && entry.customType === JOB_FIRED_ENTRY;
465
+ if (fired || (entry.type === "custom_message" && (entry.customType === "wakeup" || entry.customType === "cron"))) {
466
+ const details = (fired ? entry.data : entry.details) as { jobId?: unknown; final?: unknown; source?: unknown } | undefined;
467
+ const wakeup = fired ? details?.source === "wakeup" : entry.customType === "wakeup";
468
+ if (details?.final !== true) {
469
+ const job = typeof details?.jobId === "string" ? jobs.get(details.jobId) : undefined;
470
+ const at = Date.parse(entry.timestamp ?? "");
471
+ if (job && !Number.isNaN(at)) job.lastFiredAt = at;
472
+ continue;
473
+ }
474
+ if (wakeup) dropWakeups();
475
+ else if (typeof details.jobId === "string") jobs.delete(details.jobId);
476
+ continue;
477
+ }
478
+ if (entry.type !== "message") continue;
479
+ const message = entry.message as Message | undefined;
480
+ if (message?.role === "assistant" && Array.isArray(message.content)) {
481
+ for (const block of message.content as Array<{ type?: string; id?: string; arguments?: unknown }>) {
482
+ if (block?.type === "toolCall" && typeof block.id === "string" && block.arguments && typeof block.arguments === "object") calls.set(block.id, block.arguments as Record<string, unknown>);
483
+ }
484
+ continue;
485
+ }
486
+ if (message?.role !== "toolResult" || message.isError || !message.toolCallId) continue;
487
+ const args = calls.get(message.toolCallId) ?? {};
488
+ const details = (message.details ?? {}) as Record<string, unknown>;
489
+ const at = message.timestamp ?? (Date.parse(entry.timestamp ?? "") || 0);
490
+ if (message.toolName === "cron_create" && typeof details.jobId === "string" && typeof details.cron === "string" && typeof args.prompt === "string") {
491
+ jobs.set(details.jobId, { id: details.jobId, cron: details.cron, prompt: args.prompt, recurring: details.recurring !== false, source: "model", createdAt: at });
492
+ } else if (message.toolName === "cron_delete" && typeof details.jobId === "string") {
493
+ jobs.delete(details.jobId);
494
+ } else if (message.toolName === "schedule_wakeup") {
495
+ // A schedule replaces the pending wakeup; one past the loop's age (scheduledFor 0) cancels it.
496
+ if (details.stopped === true || typeof details.scheduledFor === "number") dropWakeups();
497
+ if (typeof details.scheduledFor === "number" && details.scheduledFor > 0 && typeof args.prompt === "string") {
498
+ const target = new Date(details.scheduledFor);
499
+ // The store's own id, so a cron_delete of the wakeup removes it here too.
500
+ const id = typeof details.jobId === "string" ? details.jobId : `w-${message.toolCallId}`;
501
+ const chainStartedAt = typeof details.startedAt === "number" ? details.startedAt : at;
502
+ jobs.set(id, { id, cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt: args.prompt, recurring: false, source: "wakeup", createdAt: at, fireAt: details.scheduledFor, chainStartedAt });
503
+ }
504
+ }
505
+ }
506
+ return [...jobs.values()];
380
507
  }
381
508
 
382
509
  function publicJob(job: CronJob & { fields?: CronFields }): CronJob {
@@ -44,7 +44,16 @@ import {
44
44
  SCHEDULE_WAKEUP_PARAMS,
45
45
  WAKEUP_ERRORS,
46
46
  } from "./wakeup.ts";
47
- import { freshDeliveryState, type LoopDeliveryState, readLoopFile, resolveLoopFire } from "./loop-fire.ts";
47
+ import {
48
+ deliveryFromState,
49
+ isAutonomousSentinel,
50
+ isLoopFileSentinel,
51
+ LOOP_DELIVERY_DETAILS_KEY,
52
+ LOOP_DELIVERY_STATE,
53
+ type LoopDeliveryState,
54
+ readLoopFile,
55
+ resolveLoopFire,
56
+ } from "./loop-fire.ts";
48
57
  import { autonomousPreamble, loopSkillPrompt } from "./loop-skill.ts";
49
58
  import {
50
59
  CRON_CREATE_DESCRIPTION,
@@ -62,6 +71,9 @@ import {
62
71
  formatDeleteResult,
63
72
  formatJobList,
64
73
  formatUnknownJob,
74
+ JOB_FIRED_ENTRY,
75
+ scheduledJobsOnBranch,
76
+ WAKEUP_ARMED_ENTRY,
65
77
  } from "./cron.ts";
66
78
  import {
67
79
  createTaskNotifier,
@@ -88,7 +100,7 @@ import {
88
100
  import { registerLocalCommand } from "../lib/local-command.ts";
89
101
  import { applyNoopFolds, decideFold, type FoldDetails, type FoldEntry, foldSuffix } from "./noop-fold.ts";
90
102
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
91
- import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, formatNotOwner } from "../lib/agent-cron.ts";
103
+ import { AGENT_CRON_CHANNEL, AGENT_CRON_FIRE_CHANNEL, type AgentCronFire, type AgentCronRequest, type FireConversation, formatNotOwner } from "../lib/agent-cron.ts";
92
104
  import { SKILL_BODY_CHANNEL, type SkillBodyQuery, SLASH_EXPAND_CHANNEL, type SlashExpandQuery } from "../lib/skill-body.ts";
93
105
  import { BUNDLED_SKILLS_DIR } from "../lib/skill-scan.ts";
94
106
  import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
@@ -97,6 +109,10 @@ import { StringDecoder } from "node:string_decoder";
97
109
  import { SESSION_WORK_CHANNEL, sessionBackgroundTask, sessionCron, type SessionWorkQuery } from "../lib/session-work.ts";
98
110
  import { WORKTREE_CHANNEL, type WorktreeLocation } from "../lib/worktree-channel.ts";
99
111
  import { queueNotice } from "../lib/notices.ts";
112
+ import { makeRecord, RECORD } from "../lib/ledger/records.ts";
113
+ import { recordState, stateSinceCompaction } from "../lib/ledger/state.ts";
114
+ import { processState } from "../lib/process-state.ts";
115
+ import { extensionVersion } from "../lib/package-version.ts";
100
116
 
101
117
  /** Claude Code's `task_output` limit (`TASK_MAX_OUTPUT_DEFAULT`). */
102
118
  const TASK_OUTPUT_MAX_CHARS = 32_000;
@@ -115,15 +131,16 @@ function tail(text: string, cap: number): string {
115
131
  }
116
132
 
117
133
  /**
118
- * `/clear`, `/new` and `/resume` stop every background task with the session
119
- * they belong to (the notifier that would report them is inert by then, and
120
- * the new session has no handle on them). Say so, once, in the NEW session:
121
- * the old session's UI is torn down right after `session_shutdown`, so a
122
- * notice printed there is lost. Module scope on purpose — jiti keeps this
123
- * module across the factory re-run that builds the new instance, which is the
124
- * only thing that survives the swap (LIFECYCLE-REVIEW-2026-09-06 L3).
134
+ * `/clear`, `/new`, `/resume` and `/reload` stop every background task with
135
+ * the session they belong to (the notifier that would report them is inert by
136
+ * then, and the new instance has no handle on them). Say so, once, in the NEW
137
+ * session: the old session's UI is torn down right after `session_shutdown`,
138
+ * so a notice printed there is lost (LIFECYCLE-REVIEW-2026-09-06 L3). The
139
+ * notice is process state (lib/process-state.ts): module scope would lose it
140
+ * wherever pi evaluates this module again, at `/reload` and at a `/resume`
141
+ * into another directory (lifecycle grid R3-L13).
125
142
  */
126
- let pendingShutdownNotice: string | undefined;
143
+ const shutdownNotice = () => processState<{ text?: string }>("background:shutdown-notice", () => ({}));
127
144
 
128
145
  /**
129
146
  * `<sessionDir>/monitor/<taskId>/output.log`, beside background shells'
@@ -188,16 +205,6 @@ export default function backgroundExtension(pi: ExtensionAPI) {
188
205
  let lastCtx: ExtensionContext | undefined;
189
206
  const cron = new CronStore();
190
207
  const dynamicLoop = new DynamicLoop(cron);
191
- // What the no-prompt /loop sentinels already delivered, per recipient (the
192
- // main session, or a subagent by task id): each conversation has its own
193
- // record. The main session's compaction resets its own (loop-fire.ts).
194
- const MAIN_OWNER = "main";
195
- const loopDelivery = new Map<string, LoopDeliveryState>();
196
- const deliveryFor = (owner: string): LoopDeliveryState => {
197
- let state = loopDelivery.get(owner);
198
- if (!state) loopDelivery.set(owner, (state = freshDeliveryState()));
199
- return state;
200
- };
201
208
  let sessionCwd = process.cwd();
202
209
  // The main session's worktree while it is in one (enter_worktree), where its fires resolve.
203
210
  let worktreeCwd: string | undefined;
@@ -255,7 +262,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
255
262
  const live = liveUiCtx(lastCtx);
256
263
  if (!live) return;
257
264
  // Every running task and scheduled job, panel-owned ones included: the
258
- // line is the warning that /clear, /reload or quitting stops them (switch-guard.ts).
265
+ // line is the warning of what /clear, /reload or quitting stops (switch-guard.ts).
259
266
  const line = workWidgetLine(registry.running().length, cron.list().length);
260
267
  live.ui.setWidget("cc-background", line ? [line] : undefined);
261
268
  };
@@ -286,7 +293,52 @@ export default function backgroundExtension(pi: ExtensionAPI) {
286
293
 
287
294
  // A monitor's end the model already read through task_output is withdrawn
288
295
  // like a shell's (lib/notifications.ts).
289
- const notify = createTaskNotifier(pi, { withdrawOnDelivery: true });
296
+ const notify = createTaskNotifier(pi, {
297
+ withdrawOnDelivery: true,
298
+ // A fire's message reached the conversation: what it told the model is on the branch from here.
299
+ onDelivered: (customType, details) => {
300
+ if (customType !== "cron" && customType !== "wakeup") return;
301
+ const stored = details[LOOP_DELIVERY_DETAILS_KEY];
302
+ if (stored !== undefined) recordDelivery(pi, deliveryFromState(stored));
303
+ },
304
+ });
305
+
306
+ /**
307
+ * A no-prompt /loop sentinel's text (loop-fire.ts), against what the
308
+ * conversation it lands in was already sent. That record is branch state
309
+ * of that conversation: the main session's, or a subagent's own session,
310
+ * since each has its own history and compacts on its own. Read since the
311
+ * last compaction, so the first fire after one resends the instructions,
312
+ * as Claude Code does. `delivery` is the record this text makes true, set
313
+ * only when it changes (lifecycle grid R3-L12); the caller writes it when
314
+ * the text reaches the conversation.
315
+ */
316
+ const expandSentinel = (prompt: string, cwd: string, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
317
+ let branch: readonly unknown[] = [];
318
+ try {
319
+ branch = conversation?.getBranch() ?? [];
320
+ } catch {
321
+ // A torn-down session reads as one that was sent nothing.
322
+ }
323
+ const before = deliveryFromState(stateSinceCompaction(branch, LOOP_DELIVERY_STATE));
324
+ const state = { ...before };
325
+ const text = resolveLoopFire(state, prompt, cwd, autonomousPreamble());
326
+ const changed = state.autonomousPreambleDelivered !== before.autonomousPreambleDelivered || state.lastLoopFileDelivered !== before.lastLoopFileDelivered;
327
+ return changed ? { text, delivery: state } : { text };
328
+ };
329
+ /** Record what a delivered fire told its conversation; a torn-down one has nothing to record into. */
330
+ const recordDelivery = (conversation: Pick<FireConversation, "appendEntry">, delivery: LoopDeliveryState) => {
331
+ try {
332
+ recordState(conversation, LOOP_DELIVERY_STATE, delivery);
333
+ } catch {
334
+ // Same: nothing to record into.
335
+ }
336
+ };
337
+ /** The main session as a fire's conversation, while it is live. */
338
+ const mainConversation = (): FireConversation | undefined => {
339
+ const ctx = lastCtx;
340
+ return ctx ? { getBranch: () => ctx.sessionManager.getBranch(), appendEntry: (customType, data) => pi.appendEntry(customType, data) } : undefined;
341
+ };
290
342
 
291
343
  /**
292
344
  * The text a fired prompt delivers, as Claude Code's queue would run it: a
@@ -295,14 +347,14 @@ export default function backgroundExtension(pi: ExtensionAPI) {
295
347
  * prompt verbatim. Both resolve in the job owner's directory: a subagent's
296
348
  * own, or the main session's worktree while it is in one.
297
349
  */
298
- const fireText = (job: CronJob): string => {
350
+ const fireText = (job: CronJob, conversation: FireConversation | undefined): { text: string; delivery?: LoopDeliveryState } => {
299
351
  const { prompt } = job;
300
352
  const cwd = job.cwd ?? worktreeCwd ?? sessionCwd;
301
- const resolved = resolveLoopFire(deliveryFor(job.agentId ?? MAIN_OWNER), prompt, cwd, autonomousPreamble());
302
- if (resolved !== prompt || !prompt.startsWith("/")) return resolved;
353
+ if (isAutonomousSentinel(prompt) || isLoopFileSentinel(prompt)) return expandSentinel(prompt, cwd, conversation);
354
+ if (!prompt.startsWith("/")) return { text: prompt };
303
355
  const query: SlashExpandQuery = { text: prompt, cwd };
304
356
  pi.events.emit(SLASH_EXPAND_CHANNEL, query);
305
- return query.expanded ?? prompt;
357
+ return { text: query.expanded ?? prompt };
306
358
  };
307
359
  /** Claude Code's no-op fold for the wakeup firing now (noop-fold.ts), read off the session branch. */
308
360
  const foldDetails = (): FoldDetails => {
@@ -319,18 +371,32 @@ export default function backgroundExtension(pi: ExtensionAPI) {
319
371
  const { job, final } = fire;
320
372
  if (job.agentId !== undefined) {
321
373
  // A subagent's job goes to that agent, or is dropped once it has ended.
322
- const delivery: AgentCronFire = { agentId: job.agentId, jobId: job.id, prompt: fireText(job) };
374
+ // The agent's resident sends the text as soon as it asks for it, so the record goes with the text.
375
+ const text = (conversation: FireConversation | undefined) => {
376
+ const fired = fireText(job, conversation);
377
+ if (fired.delivery && conversation) recordDelivery(conversation, fired.delivery);
378
+ return fired.text;
379
+ };
380
+ const delivery: AgentCronFire = { agentId: job.agentId, jobId: job.id, text };
323
381
  pi.events.emit(AGENT_CRON_FIRE_CHANNEL, delivery);
324
- if (!delivery.delivered) {
325
- // The agent has ended: its job and what it was sent go with it.
326
- cron.delete(job.id);
327
- loopDelivery.delete(job.agentId);
328
- }
382
+ // The agent has ended: its job goes with it.
383
+ if (!delivery.delivered) cron.delete(job.id);
329
384
  return;
330
385
  }
331
386
  const fold = job.source === "wakeup" ? foldDetails() : {};
387
+ // A fold removes the tick's messages from the model's context: above the
388
+ // append point, so the next request opens an epoch (extensions/ledger)
389
+ // and renders the folded context whole, once.
390
+ if (fold.noOpStreak !== undefined) pi.appendEntry(RECORD.epoch, makeRecord("epoch", extensionVersion(), { cause: "fold" }));
332
391
  if (job.source === "wakeup") dynamicLoop.inFlight = job.prompt;
333
- notify(job.source === "wakeup" ? "wakeup" : "cron", fireText(job), { jobId: job.id, cron: job.cron, source: job.source, final, ...fold });
392
+ // Recorded whatever way the notification goes out, so a /reload knows the fire happened.
393
+ pi.appendEntry(JOB_FIRED_ENTRY, { jobId: job.id, final, source: job.source });
394
+ // The notifier may hold the message (a coalescing window, an interrupt) and
395
+ // a /reload drops what it holds, so the loop's record rides the message and
396
+ // is written when it is delivered (the notifier's onDelivered above).
397
+ const { text, delivery } = fireText(job, mainConversation());
398
+ const details = { jobId: job.id, cron: job.cron, source: job.source, final, ...fold, ...(delivery ? { [LOOP_DELIVERY_DETAILS_KEY]: delivery } : {}) };
399
+ notify(job.source === "wakeup" ? "wakeup" : "cron", text, details);
334
400
  };
335
401
  /** Fire every due job (only while idle), then arm the one timer to the next fire. */
336
402
  const runCron = () => {
@@ -869,12 +935,34 @@ export default function backgroundExtension(pi: ExtensionAPI) {
869
935
  query.body = loopSkillPrompt(query.args, readLoopFile(query.cwd));
870
936
  });
871
937
 
872
- pi.on("session_start", (_event, ctx) => {
938
+ /** Arm the branch's scheduled jobs, with each wakeup's loop age and keepalive spend. */
939
+ const armFromBranch = (ctx: ExtensionContext) => {
940
+ const now = Date.now();
941
+ for (const job of scheduledJobsOnBranch(ctx.sessionManager.getBranch() as never)) {
942
+ if (!cron.restore(job, now) || job.source !== "wakeup") continue;
943
+ dynamicLoop.restoreChain(job.prompt, job.chainStartedAt ?? job.createdAt, job.fireAt ?? now, job.keepalive === true);
944
+ }
945
+ runCron();
946
+ };
947
+ // The jobs follow the branch: a /tree switch ends the abandoned branch's and
948
+ // arms the selected one's (a subagent's own jobs stay with the agent).
949
+ pi.on("session_tree", (_event, ctx) => {
950
+ lastCtx = ctx;
951
+ cron.deleteWhere((job) => job.agentId === undefined);
952
+ dynamicLoop.clear();
953
+ armFromBranch(ctx);
954
+ });
955
+ pi.on("session_start", (event, ctx) => {
873
956
  lastCtx = ctx;
874
957
  sessionCwd = ctx.cwd;
875
- if (pendingShutdownNotice) {
876
- const notice = pendingShutdownNotice;
877
- pendingShutdownNotice = undefined;
958
+ // A /reload keeps the process and the conversation, so the session's
959
+ // scheduled jobs come back from the branch (design.md section 13); a new
960
+ // process ends them, as Claude Code's session-only jobs end.
961
+ if (event.reason === "reload") armFromBranch(ctx);
962
+ const pending = shutdownNotice();
963
+ if (pending.text) {
964
+ const notice = pending.text;
965
+ pending.text = undefined;
878
966
  if (ctx.hasUI) queueNotice(pi.events, ctx, "info", notice);
879
967
  }
880
968
  });
@@ -883,8 +971,12 @@ export default function backgroundExtension(pi: ExtensionAPI) {
883
971
  });
884
972
  pi.on("agent_settled", () => {
885
973
  agentBusy = false;
886
- // Claude Code's keepalive: a wakeup's turn that scheduled no next one gets one fallback.
887
- dynamicLoop.settle(Date.now());
974
+ // Claude Code's keepalive: a wakeup's turn that scheduled no next one gets
975
+ // one fallback, recorded so a /reload re-arms it (no tool result does).
976
+ if (dynamicLoop.settle(Date.now()) === "armed") {
977
+ const armed = cron.list().find((job) => job.source === "wakeup");
978
+ if (armed) pi.appendEntry(WAKEUP_ARMED_ENTRY, { id: armed.id, prompt: armed.prompt, fireAt: armed.nextFireAt, startedAt: dynamicLoop.chainStart(armed.prompt) });
979
+ }
888
980
  runCron();
889
981
  });
890
982
  // Folded no-op ticks leave the model's context (noop-fold.ts); the note stands in for them.
@@ -892,11 +984,6 @@ export default function backgroundExtension(pi: ExtensionAPI) {
892
984
  const messages = applyNoopFolds(event.messages as Array<AgentMessage & { role: string }>, (text, timestamp) => ({ role: "user", content: [{ type: "text", text }], timestamp }) as AgentMessage & { role: string });
893
985
  return messages ? { messages } : undefined;
894
986
  });
895
- // The first fire after a compaction resends the full loop instructions, as in Claude Code.
896
- pi.on("session_compact", () => {
897
- loopDelivery.delete(MAIN_OWNER);
898
- });
899
-
900
987
  // A switch stops everything below, so ask first. pi can cancel a switch but
901
988
  // not a quit; the widget line covers quitting (switch-guard.ts).
902
989
  const confirmSwitch = async (ctx: ExtensionContext, action: string): Promise<{ cancel: true } | undefined> => {
@@ -918,10 +1005,11 @@ export default function backgroundExtension(pi: ExtensionAPI) {
918
1005
  pi.on("session_shutdown", (event) => {
919
1006
  // Fires on /clear, /new, /resume and /reload too (findings §8). Everything
920
1007
  // running dies with this instance — the replacement instance has no handle
921
- // on it — and the replacement is told (see pendingShutdownNotice); a quit
1008
+ // on it — and the replacement is told (see shutdownNotice); a quit
922
1009
  // needs no note. A reload keeps the conversation, so its note says so.
923
1010
  const running = registry.running();
924
- const jobs = cron.list();
1011
+ // A reload re-arms the jobs from the branch: only the tasks stop.
1012
+ const jobs = event.reason === "reload" ? [] : cron.list();
925
1013
  if ((running.length > 0 || jobs.length > 0) && event.reason !== "quit") {
926
1014
  const when = event.reason === "reload" ? "on reload" : "with the previous session";
927
1015
  const notices: string[] = [];
@@ -933,7 +1021,7 @@ export default function backgroundExtension(pi: ExtensionAPI) {
933
1021
  const names = jobs.map((j) => `${j.id} (${describeCadence(j.cron)})`).join(", ");
934
1022
  notices.push(`Cancelled ${jobs.length} scheduled job${jobs.length === 1 ? "" : "s"} ${when}: ${names}.`);
935
1023
  }
936
- pendingShutdownNotice = notices.join(" ");
1024
+ shutdownNotice().text = notices.join(" ");
937
1025
  }
938
1026
  registry.stopAll();
939
1027
  cron.clear();
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { projectConfigDir, userConfigDir } from "../lib/config-mode.ts";
15
+ import { createHash } from "node:crypto";
15
16
  import { readFileSync } from "node:fs";
16
17
  import { homedir } from "node:os";
17
18
  import { join } from "node:path";
@@ -35,7 +36,12 @@ export interface LoopFile {
35
36
  content: string;
36
37
  }
37
38
 
38
- /** What went out already this session; compaction resets it (the first fire after it resends everything). */
39
+ /**
40
+ * What one conversation was already sent; compaction resets it (the first
41
+ * fire after it resends everything). `lastLoopFileDelivered` is the delivered
42
+ * loop.md's fingerprint (`loopFileMark`), not its text, so the record stays
43
+ * small on the branch.
44
+ */
39
45
  export interface LoopDeliveryState {
40
46
  autonomousPreambleDelivered: boolean;
41
47
  lastLoopFileDelivered: string | null;
@@ -45,6 +51,37 @@ export function freshDeliveryState(): LoopDeliveryState {
45
51
  return { autonomousPreambleDelivered: false, lastLoopFileDelivered: null };
46
52
  }
47
53
 
54
+ /**
55
+ * The `one-code:state` key the record lives under, on the branch of the
56
+ * conversation the fires land in (the main session's, or a subagent's own):
57
+ * it follows `/tree`, survives `/reload` and resume, and a compaction resets
58
+ * it (lib/ledger/state.ts `stateSinceCompaction`).
59
+ */
60
+ export const LOOP_DELIVERY_STATE = "loop-delivery";
61
+
62
+ /**
63
+ * The key a main-session fire's notification details carry its record
64
+ * under. The record is written when that message is delivered (the
65
+ * notifier's `onDelivered`), not when the text is built: the notifier may
66
+ * still hold it, and a `/reload` or `/clear` then drops it unsent.
67
+ */
68
+ export const LOOP_DELIVERY_DETAILS_KEY = "loopDelivery";
69
+
70
+ /** A stored record read back, or a fresh one when it is missing or malformed. */
71
+ export function deliveryFromState(value: unknown): LoopDeliveryState {
72
+ const stored = value as Partial<LoopDeliveryState> | undefined;
73
+ if (!stored || typeof stored !== "object") return freshDeliveryState();
74
+ return {
75
+ autonomousPreambleDelivered: stored.autonomousPreambleDelivered === true,
76
+ lastLoopFileDelivered: typeof stored.lastLoopFileDelivered === "string" ? stored.lastLoopFileDelivered : null,
77
+ };
78
+ }
79
+
80
+ /** What a delivered loop.md is remembered by: equal texts, equal marks. */
81
+ export function loopFileMark(content: string): string {
82
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
83
+ }
84
+
48
85
  export function isAutonomousSentinel(prompt: string): boolean {
49
86
  return prompt === AUTONOMOUS_LOOP || prompt === AUTONOMOUS_LOOP_DYNAMIC;
50
87
  }
@@ -112,7 +149,7 @@ export function readLoopFile(cwd: string, home = homedir()): LoopFile | null {
112
149
  return readLoopFileAt(join(projectConfigDir(cwd), "loop.md")) ?? readLoopFileAt(join(userConfigDir(home), "loop.md"));
113
150
  }
114
151
 
115
- function resolveLoopFile(e: LoopDeliveryState, t: string, o: LoopFile | null, preamble: string): string {let n=t===LOOP_FILE_DYNAMIC_SENTINEL;if(o){let s=n?loopFileDynamicTick():loopFileCronTick();if(e.lastLoopFileDelivered===o.content)return s;return e.lastLoopFileDelivered=o.content,`# /loop tick \u2014 tasks from ${o.path}
152
+ function resolveLoopFile(e: LoopDeliveryState, t: string, o: LoopFile | null, preamble: string): string {let n=t===LOOP_FILE_DYNAMIC_SENTINEL;if(o){let s=n?loopFileDynamicTick():loopFileCronTick(),m=loopFileMark(o.content);if(e.lastLoopFileDelivered===m)return s;return e.lastLoopFileDelivered=m,`# /loop tick \u2014 tasks from ${o.path}
116
153
 
117
154
  The user configured a loop-tasks file. Work through the tasks defined below; these are the instructions for this tick and every subsequent tick (the reminder on later fires refers back to this message).
118
155
 
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * What a session switch or quit would stop (pure). Running background tasks and
3
- * scheduled jobs belong to the session: `/clear`, `/new`, `/resume` and a fork
4
- * stop them, and so do `/reload` and quitting. pi lets an extension cancel a
5
- * switch (`session_before_switch`, `session_before_fork`), so a switch asks
6
- * first; reloading and quitting have no such hook, so the standing widget line
7
- * says it up front.
3
+ * scheduled jobs belong to the session: `/clear`, `/new`, `/resume`, a fork and
4
+ * quitting stop them. A `/reload` stops the running tasks only: it keeps the
5
+ * conversation, so the scheduled jobs come back from the branch
6
+ * (background/index.ts `armFromBranch`). pi lets an extension cancel a switch
7
+ * (`session_before_switch`, `session_before_fork`), so a switch asks first;
8
+ * reloading and quitting have no such hook, so the standing widget line says
9
+ * it up front.
8
10
  */
9
11
 
10
12
  /** A running task or scheduled job, as the dialog and widget name it. */
@@ -48,5 +50,7 @@ export function switchWarning(tasks: readonly SessionWork[], jobs: readonly Sess
48
50
  /** The standing widget line while anything runs, or undefined when nothing does. */
49
51
  export function workWidgetLine(tasks: number, jobs: number): string | undefined {
50
52
  if (tasks === 0 && jobs === 0) return undefined;
51
- return ` ${counts(tasks, jobs)} · stopped by /clear, /reload or quitting${tasks > 0 ? " · /tasks to manage" : ""}`;
53
+ // A /reload stops only the running tasks; the jobs re-arm from the branch.
54
+ const stoppedBy = jobs === 0 ? "/clear, /reload or quitting" : tasks === 0 ? "/clear or quitting" : "/clear or quitting, the tasks by /reload too";
55
+ return ` ${counts(tasks, jobs)} · stopped by ${stoppedBy}${tasks > 0 ? " · /tasks to manage" : ""}`;
52
56
  }
@@ -40,6 +40,10 @@ export interface WakeupSchedule {
40
40
  scheduledFor: number;
41
41
  clampedDelaySeconds: number;
42
42
  wasClamped: boolean;
43
+ /** The store's job id, so a cron_delete of it is matched when the branch is replayed. */
44
+ jobId?: string;
45
+ /** When this prompt's loop started: its age carries across a /reload. */
46
+ startedAt: number;
43
47
  }
44
48
 
45
49
  interface PromptAge {
@@ -59,6 +63,11 @@ export class DynamicLoop {
59
63
  private readonly maxAgeMs = RECURRING_MAX_AGE_MS,
60
64
  ) {}
61
65
 
66
+ /** When the loop for `prompt` started, if it is running. */
67
+ chainStart(prompt: string): number | undefined {
68
+ return this.ages.get(prompt)?.startedAt;
69
+ }
70
+
62
71
  /** A pending wakeup exists. */
63
72
  hasPending(): boolean {
64
73
  return this.store.list().some((job) => job.source === "wakeup");
@@ -82,10 +91,20 @@ export class DynamicLoop {
82
91
  const wasClamped = !(delaySeconds >= MIN_DELAY_SECONDS && delaySeconds <= MAX_DELAY_SECONDS);
83
92
  const scheduledFor = now + clamped * 1000;
84
93
  const target = new Date(scheduledFor);
85
- this.store.create({ cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt, recurring: false, source: "wakeup", fireAt: scheduledFor }, now);
94
+ const created = this.store.create({ cron: `${target.getMinutes()} ${target.getHours()} * * *`, prompt, recurring: false, source: "wakeup", fireAt: scheduledFor }, now);
86
95
  this.ages.set(prompt, { startedAt, lastScheduledFor: scheduledFor });
87
96
  if (viaKeepalive) this.misses++;
88
- return { scheduledFor, clampedDelaySeconds: clamped, wasClamped };
97
+ return { scheduledFor, clampedDelaySeconds: clamped, wasClamped, startedAt, ...(created.ok ? { jobId: created.job.id } : {}) };
98
+ }
99
+
100
+ /**
101
+ * Take back a pending wakeup's loop state from the branch (a /reload, a
102
+ * /tree switch): its age, and whether the keepalive armed it, which spends
103
+ * the loop's one fallback.
104
+ */
105
+ restoreChain(prompt: string, startedAt: number, scheduledFor: number, viaKeepalive: boolean): void {
106
+ this.ages.set(prompt, { startedAt, lastScheduledFor: scheduledFor });
107
+ this.misses = viaKeepalive ? 1 : 0;
89
108
  }
90
109
 
91
110
  /**