@earendil-works/pi-durable 0.99.0 → 0.99.2

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 (172) hide show
  1. package/README.md +435 -46
  2. package/dist/entries.d.ts +20 -2
  3. package/dist/entries.d.ts.map +1 -1
  4. package/dist/entries.js +14 -1
  5. package/dist/entries.js.map +1 -1
  6. package/dist/env/index.d.ts +13 -38
  7. package/dist/env/index.d.ts.map +1 -1
  8. package/dist/env/index.js +2 -0
  9. package/dist/env/index.js.map +1 -1
  10. package/dist/env/node.d.ts.map +1 -1
  11. package/dist/env/node.js +51 -58
  12. package/dist/env/node.js.map +1 -1
  13. package/dist/harness/compaction.d.ts +56 -0
  14. package/dist/harness/compaction.d.ts.map +1 -0
  15. package/dist/harness/compaction.js +385 -0
  16. package/dist/harness/compaction.js.map +1 -0
  17. package/dist/harness/config.d.ts +10 -1
  18. package/dist/harness/config.d.ts.map +1 -1
  19. package/dist/harness/config.js +6 -0
  20. package/dist/harness/config.js.map +1 -1
  21. package/dist/harness/context.d.ts +8 -0
  22. package/dist/harness/context.d.ts.map +1 -1
  23. package/dist/harness/context.js +32 -21
  24. package/dist/harness/context.js.map +1 -1
  25. package/dist/harness/events.d.ts +166 -0
  26. package/dist/harness/events.d.ts.map +1 -0
  27. package/dist/harness/events.js +319 -0
  28. package/dist/harness/events.js.map +1 -0
  29. package/dist/harness/generation.d.ts +37 -6
  30. package/dist/harness/generation.d.ts.map +1 -1
  31. package/dist/harness/generation.js +350 -72
  32. package/dist/harness/generation.js.map +1 -1
  33. package/dist/harness/harness.d.ts.map +1 -1
  34. package/dist/harness/harness.js +124 -3
  35. package/dist/harness/harness.js.map +1 -1
  36. package/dist/harness/inbox.d.ts +57 -0
  37. package/dist/harness/inbox.d.ts.map +1 -0
  38. package/dist/harness/inbox.js +99 -0
  39. package/dist/harness/inbox.js.map +1 -0
  40. package/dist/harness/json.d.ts +10 -0
  41. package/dist/harness/json.d.ts.map +1 -0
  42. package/dist/harness/json.js +33 -0
  43. package/dist/harness/json.js.map +1 -0
  44. package/dist/harness/live.d.ts +66 -7
  45. package/dist/harness/live.d.ts.map +1 -1
  46. package/dist/harness/live.js +71 -7
  47. package/dist/harness/live.js.map +1 -1
  48. package/dist/harness/output.d.ts +62 -0
  49. package/dist/harness/output.d.ts.map +1 -0
  50. package/dist/harness/output.js +261 -0
  51. package/dist/harness/output.js.map +1 -0
  52. package/dist/harness/prompt.d.ts +10 -5
  53. package/dist/harness/prompt.d.ts.map +1 -1
  54. package/dist/harness/prompt.js +66 -13
  55. package/dist/harness/prompt.js.map +1 -1
  56. package/dist/harness/registry.d.ts.map +1 -1
  57. package/dist/harness/registry.js +11 -2
  58. package/dist/harness/registry.js.map +1 -1
  59. package/dist/harness/scheduler.d.ts +37 -12
  60. package/dist/harness/scheduler.d.ts.map +1 -1
  61. package/dist/harness/scheduler.js +632 -72
  62. package/dist/harness/scheduler.js.map +1 -1
  63. package/dist/harness/submissions.d.ts +12 -7
  64. package/dist/harness/submissions.d.ts.map +1 -1
  65. package/dist/harness/submissions.js +72 -33
  66. package/dist/harness/submissions.js.map +1 -1
  67. package/dist/harness/tool.d.ts +36 -0
  68. package/dist/harness/tool.d.ts.map +1 -0
  69. package/dist/harness/tool.js +412 -0
  70. package/dist/harness/tool.js.map +1 -0
  71. package/dist/harness/types.d.ts +185 -11
  72. package/dist/harness/types.d.ts.map +1 -1
  73. package/dist/harness/types.js.map +1 -1
  74. package/dist/harness/usage.d.ts +18 -0
  75. package/dist/harness/usage.d.ts.map +1 -0
  76. package/dist/harness/usage.js +57 -0
  77. package/dist/harness/usage.js.map +1 -0
  78. package/dist/harness/view.d.ts +43 -0
  79. package/dist/harness/view.d.ts.map +1 -0
  80. package/dist/harness/view.js +194 -0
  81. package/dist/harness/view.js.map +1 -0
  82. package/dist/index.d.ts +10 -4
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +6 -1
  85. package/dist/index.js.map +1 -1
  86. package/dist/session/observation.d.ts +18 -11
  87. package/dist/session/observation.d.ts.map +1 -1
  88. package/dist/session/observation.js +24 -13
  89. package/dist/session/observation.js.map +1 -1
  90. package/dist/session/session.d.ts +10 -1
  91. package/dist/session/session.d.ts.map +1 -1
  92. package/dist/session/session.js +18 -4
  93. package/dist/session/session.js.map +1 -1
  94. package/dist/session/transaction.d.ts +10 -1
  95. package/dist/session/transaction.d.ts.map +1 -1
  96. package/dist/session/transaction.js +83 -27
  97. package/dist/session/transaction.js.map +1 -1
  98. package/dist/storage/jsonl/storage.d.ts +22 -2
  99. package/dist/storage/jsonl/storage.d.ts.map +1 -1
  100. package/dist/storage/jsonl/storage.js +1 -1
  101. package/dist/storage/jsonl/storage.js.map +1 -1
  102. package/dist/storage/memory.js +1 -1
  103. package/dist/storage/memory.js.map +1 -1
  104. package/dist/storage/sqlite/migrations.js +1 -1
  105. package/dist/storage/sqlite/migrations.js.map +1 -1
  106. package/dist/testing/storage-benchmark.d.ts.map +1 -1
  107. package/dist/testing/storage-benchmark.js +0 -1
  108. package/dist/testing/storage-benchmark.js.map +1 -1
  109. package/dist/testing/storage-conformance.d.ts.map +1 -1
  110. package/dist/testing/storage-conformance.js +34 -2
  111. package/dist/testing/storage-conformance.js.map +1 -1
  112. package/dist/tools/bash.d.ts +28 -0
  113. package/dist/tools/bash.d.ts.map +1 -0
  114. package/dist/tools/bash.js +69 -0
  115. package/dist/tools/bash.js.map +1 -0
  116. package/dist/tools/edit-diff.d.ts +89 -0
  117. package/dist/tools/edit-diff.d.ts.map +1 -0
  118. package/dist/tools/edit-diff.js +386 -0
  119. package/dist/tools/edit-diff.js.map +1 -0
  120. package/dist/tools/edit.d.ts +18 -0
  121. package/dist/tools/edit.d.ts.map +1 -0
  122. package/dist/tools/edit.js +116 -0
  123. package/dist/tools/edit.js.map +1 -0
  124. package/dist/tools/env.d.ts +5 -0
  125. package/dist/tools/env.d.ts.map +1 -0
  126. package/dist/tools/env.js +7 -0
  127. package/dist/tools/env.js.map +1 -0
  128. package/dist/tools/file-mutation-queue.d.ts +5 -0
  129. package/dist/tools/file-mutation-queue.d.ts.map +1 -0
  130. package/dist/tools/file-mutation-queue.js +46 -0
  131. package/dist/tools/file-mutation-queue.js.map +1 -0
  132. package/dist/tools/image.d.ts +2 -0
  133. package/dist/tools/image.d.ts.map +1 -0
  134. package/dist/tools/image.js +92 -0
  135. package/dist/tools/image.js.map +1 -0
  136. package/dist/tools/index.d.ts +5 -0
  137. package/dist/tools/index.d.ts.map +1 -0
  138. package/dist/tools/index.js +5 -0
  139. package/dist/tools/index.js.map +1 -0
  140. package/dist/tools/path-utils.d.ts +5 -0
  141. package/dist/tools/path-utils.d.ts.map +1 -0
  142. package/dist/tools/path-utils.js +26 -0
  143. package/dist/tools/path-utils.js.map +1 -0
  144. package/dist/tools/read.d.ts +17 -0
  145. package/dist/tools/read.d.ts.map +1 -0
  146. package/dist/tools/read.js +100 -0
  147. package/dist/tools/read.js.map +1 -0
  148. package/dist/tools/write.d.ts +10 -0
  149. package/dist/tools/write.d.ts.map +1 -0
  150. package/dist/tools/write.js +30 -0
  151. package/dist/tools/write.js.map +1 -0
  152. package/dist/{env/utils/truncate.d.ts → truncate.d.ts} +1 -17
  153. package/dist/truncate.d.ts.map +1 -0
  154. package/dist/truncate.js +146 -0
  155. package/dist/truncate.js.map +1 -0
  156. package/dist/types.d.ts +86 -19
  157. package/dist/types.d.ts.map +1 -1
  158. package/dist/types.js.map +1 -1
  159. package/docs/pico-v5-handoff.md +366 -86
  160. package/docs/pico-v5.md +1373 -283
  161. package/package.json +11 -3
  162. package/dist/env/utils/adaptive-publisher.d.ts +0 -24
  163. package/dist/env/utils/adaptive-publisher.d.ts.map +0 -1
  164. package/dist/env/utils/adaptive-publisher.js +0 -79
  165. package/dist/env/utils/adaptive-publisher.js.map +0 -1
  166. package/dist/env/utils/output-capture.d.ts +0 -31
  167. package/dist/env/utils/output-capture.d.ts.map +0 -1
  168. package/dist/env/utils/output-capture.js +0 -238
  169. package/dist/env/utils/output-capture.js.map +0 -1
  170. package/dist/env/utils/truncate.d.ts.map +0 -1
  171. package/dist/env/utils/truncate.js +0 -294
  172. package/dist/env/utils/truncate.js.map +0 -1
package/docs/pico-v5.md CHANGED
@@ -22,11 +22,13 @@ import type {
22
22
  ModelThinkingLevel,
23
23
  TextContent,
24
24
  Tool,
25
+ ToolCall,
25
26
  ToolReference,
26
27
  ToolResultMessage,
27
28
  Transport,
28
29
  UserMessage,
29
30
  } from "@earendil-works/pi-ai";
31
+ import type { ExecutionEnv } from "@earendil-works/pi-durable/env";
30
32
 
31
33
  type JsonObject = { [key: string]: JsonValue };
32
34
  type TaskOutcomeError = { message: string; detail?: JsonValue };
@@ -340,6 +342,8 @@ type AnyTask = {
340
342
  type HarnessOptions<Tool extends ToolRegistration = ToolRegistration> = {
341
343
  readonly models: Models; // the pi-ai Models interface
342
344
  readonly registry: RegistryReader<Tool>; // section 7.1
345
+ /** Default execution environment offered to tools (section 7.3). */
346
+ readonly env?: ExecutionEnv;
343
347
  readonly now?: () => number;
344
348
  readonly onReport?: (error: unknown) => void;
345
349
  };
@@ -365,12 +369,37 @@ type ConversationRetryPolicy = {
365
369
  maxAgentDelayMs?: number;
366
370
  };
367
371
 
372
+ /** Automatic compaction thresholds (section 8.7); manual compaction ignores `enabled`. */
373
+ type CompactionPolicy = {
374
+ /** Threshold and overflow compaction. */
375
+ enabled: boolean;
376
+ /** Room kept free for the answer: generation blocks to compact above `contextWindow - reserveTokens`. */
377
+ reserveTokens: number;
378
+ /** Approximate size of the recent context a summary keeps verbatim. */
379
+ keepRecentTokens: number;
380
+ /** Background compaction starts `backgroundTokens` below the blocking threshold; `0` disables it. */
381
+ backgroundTokens: number;
382
+ };
383
+
384
+ type ToolExecutionMode = "parallel" | "sequential";
385
+
386
+ /** How many queued items of one mode a boundary selects (section 6). */
387
+ type QueueMode = "all" | "one-at-a-time";
388
+
368
389
  type ConversationConfigState = {
369
390
  model?: ModelRef;
370
391
  thinkingLevel: ModelThinkingLevel;
371
392
  activeTools: string[];
372
393
  streamOptions?: ConversationStreamOptions;
373
394
  retry?: ConversationRetryPolicy;
395
+ /** Default `parallel`; see section 8.3. */
396
+ toolExecution?: ToolExecutionMode;
397
+ /** Default `one-at-a-time`; see section 6. */
398
+ steeringMode?: QueueMode;
399
+ /** Default `one-at-a-time`; see section 6. */
400
+ followUpMode?: QueueMode;
401
+ /** Default `DEFAULT_COMPACTION_POLICY`; see section 8.7. */
402
+ compaction?: CompactionPolicy;
374
403
  };
375
404
 
376
405
  /** Built-in rewindable configuration document; see below. */
@@ -393,6 +422,8 @@ function defineEntry<D extends JsonValue = never>(kind: string): Entry<D>;
393
422
  type ContextView = {
394
423
  readonly head: EntryRecord | undefined;
395
424
  readonly entries: readonly EntryRecord[];
425
+ /** Per entry of `entries`, its model messages after edits and rule 9, before rules 7 and 8. */
426
+ readonly contributions: readonly (readonly Message[])[];
396
427
  readonly messages: readonly Message[];
397
428
  };
398
429
 
@@ -417,6 +448,8 @@ type TaskInspection = {
417
448
  | { readonly kind: "running" }
418
449
  | { readonly kind: "ready"; readonly migrates: boolean }
419
450
  | { readonly kind: "waiting"; readonly on: readonly TaskId[] }
451
+ /** Outcome held until its ordinary owned work drains (section 5.5). */
452
+ | { readonly kind: "completing" }
420
453
  | {
421
454
  readonly kind: "blocked";
422
455
  readonly reason: "missing_task" | "task_too_old" | "migration_failed";
@@ -452,6 +485,14 @@ interface Conversation {
452
485
  setStreamOptions(options: ConversationStreamOptions, context: Context): Promise<void>;
453
486
  getRetryPolicy(context: Context): Promise<ConversationRetryPolicy>;
454
487
  setRetryPolicy(policy: ConversationRetryPolicy | undefined, context: Context): Promise<void>;
488
+ getToolExecution(context: Context): Promise<ToolExecutionMode>;
489
+ setToolExecution(mode: ToolExecutionMode | undefined, context: Context): Promise<void>;
490
+ getSteeringMode(context: Context): Promise<QueueMode>;
491
+ setSteeringMode(mode: QueueMode | undefined, context: Context): Promise<void>;
492
+ getFollowUpMode(context: Context): Promise<QueueMode>;
493
+ setFollowUpMode(mode: QueueMode | undefined, context: Context): Promise<void>;
494
+ getCompaction(context: Context): Promise<CompactionPolicy>;
495
+ setCompaction(policy: CompactionPolicy | undefined, context: Context): Promise<void>;
455
496
 
456
497
  commit<T>(
457
498
  change: (tx: Tx) => T | Promise<T>,
@@ -469,9 +510,10 @@ interface Conversation {
469
510
  options: ConversationCreateOptions,
470
511
  context: Context,
471
512
  ): Promise<Conversation>;
472
- collapse(instructions: string | undefined, context: Context): Promise<TaskId>;
513
+ compact(instructions: string | undefined, context: Context): Promise<TaskId<CompactionResult>>;
473
514
  reset(handoff: string | undefined, context: Context): Promise<void>;
474
- abort(context: Context): Promise<void>;
515
+ /** `{ background: true }` also aborts background work under the conversation (section 5.4). */
516
+ abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
475
517
  waitForIdle(context: Context): Promise<void>;
476
518
  viewState(context: Context): Promise<AttachedReplicatedState<ConversationView>>;
477
519
  watch(context: Context): Promise<ConversationWatch>;
@@ -498,6 +540,8 @@ interface Harness extends Session {
498
540
  abortTask(id: TaskId, context: Context): Promise<"marked" | "terminal">;
499
541
  waitForTask<R>(id: TaskId<R>, context: Context): Promise<SettledTask<R>>;
500
542
  waitForIdle(context: Context): Promise<void>;
543
+ /** Session total of every conversation's `pi.usage` (section 8.6). */
544
+ usage(context: Context): Promise<UsageState>;
501
545
  }
502
546
 
503
547
  declare const Harness: {
@@ -520,9 +564,10 @@ settlement.
520
564
  7.1). `createRegistry()` pre-registers the built-in task definitions (section 8);
521
565
  they cannot be disposed or replaced, and open rejects a registry whose snapshot
522
566
  lacks any of them or the built-in `pi` conversation setup.
523
- Open changes surviving `running` tasks to `pending` and does nothing else to
524
- task records: it never migrates or terminalizes a task because its definition
525
- is missing or unmigratable. Such a task stays `pending` and is **blocked**: the
567
+ Open changes surviving `running` tasks to `pending`, keeps `waiting` tasks
568
+ waiting, and re-evaluates finalization and `failFast` for `completing` and
569
+ `waiting` tasks (section 5.5). It never migrates or terminalizes a task because
570
+ its definition is missing or unmigratable. Such a task stays `pending` or `waiting` and is **blocked**: the
526
571
  scheduler skips it and reconsiders it whenever the registry changes (section
527
572
  5.4). Any built-in document touched by recovery migrates through its ordinary
528
573
  typed access path. Open does not scan or migrate other documents. No handler
@@ -540,7 +585,7 @@ no configured model produces a durable `no_model` generation failure.
540
585
  repeat open-time reconciliation, and it throws after close. Work that must happen
541
586
  before any task runs, such as registration or seeding, happens before
542
587
  `resume()`. Calls that ask for progress also enable scheduling, so they never
543
- wait on a paused Harness: `Conversation.submit()`, `Submission.wait()`,
588
+ wait on a paused Harness: `Conversation.submit()`, `Conversation.compact()`, `Submission.wait()`,
544
589
  `Harness.waitForTask()`, `Harness.waitForIdle()`, and
545
590
  `Conversation.waitForIdle()`. Recovered work starts with them. A viewer that only
546
591
  reads never enables scheduling.
@@ -564,9 +609,9 @@ conversation setups (section 7.1) in the same commit, whether through the
564
609
  conveniences or through raw `tx.createConversation()` and `tx.forkConversation()`,
565
610
  for example inside a tool commit. The built-in `pi` setup runs first: an
566
611
  independent conversation gets the default configuration and a fork keeps its
567
- `asOf` copy, and both get an empty `pi.live`. Applications register setups for
568
- their own documents the same way. The conveniences add only `init` and its checks,
569
- which run after every setup. A conversation created by a plain Session has no
612
+ `asOf` copy, and both get empty `pi.live`, `pi.inbox`, and `pi.usage`.
613
+ Applications register setups for their own documents the same way. The
614
+ conveniences add only `init` and its checks, which run after every setup. A conversation created by a plain Session has no
570
615
  built-in documents; its configuration reads as
571
616
  `ConversationConfig.definition.initial()` until something writes it.
572
617
 
@@ -590,6 +635,13 @@ fork. The getters return immutable committed values, falling back to
590
635
  returns `{}` and `getRetryPolicy()` returns the default policy
591
636
  `{ enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }`
592
637
  when the field is absent; `setRetryPolicy(undefined)` removes the field.
638
+ `getToolExecution()` returns `parallel` when the field is absent;
639
+ `setToolExecution(undefined)` removes it. `getSteeringMode()` and
640
+ `getFollowUpMode()` return `one-at-a-time` when their field is absent; their
641
+ setters remove the field for `undefined`. `getCompaction()` returns
642
+ `DEFAULT_COMPACTION_POLICY`, `{ enabled: true, reserveTokens: 16384,
643
+ keepRecentTokens: 20000, backgroundTokens: 32768 }`, when the field is absent;
644
+ `setCompaction(undefined)` removes it.
593
645
  `streamOptions` are forwarded to every generation request of the conversation;
594
646
  `streamOptions.maxRetries` are provider retries inside one request, while `retry`
595
647
  governs durable generation attempts (section 8). Each setter performs
@@ -607,10 +659,10 @@ not registered. The create/fork conveniences check only one thing about `init`
607
659
  writes: names that `init` newly activates must be registered. Names that were
608
660
  already active are never revalidated, so stale unregistered names survive every
609
661
  edit. All other raw document writes are trusted and unchecked, so other writers,
610
- such as post-tools `addTools`, never fault because of registry movement. Readers tolerate what raw
662
+ such as the generation's `addTools` handling, never fault because of registry movement. Readers tolerate what raw
611
663
  writes can produce: request preparation offers the first occurrence of a
612
- duplicated name, and post-tools `addTools` appends only names not already
613
- active.
664
+ duplicated name, and the generation's `tools` phase appends only `addTools`
665
+ names not already active.
614
666
 
615
667
  `init` runs after the conversation creation, which is a table write, so table
616
668
  reads inside it throw `ReadAfterWrite` (section 4). Document access remains
@@ -628,10 +680,18 @@ A missing tool implementation never fails a request. Request preparation offers
628
680
  only active names that are currently registered. If the replayed tool state still
629
681
  offers an unregistered name, the appended system delta lists it in
630
682
  `toolsRemoved`; when the name is registered again, a later delta adds its current
631
- declaration. When the model calls a tool that is not offered or whose
632
- implementation is unavailable at execution time, the tool task appends an error
633
- tool result with `details: { code: "tool_unavailable" }` stating that the tool is
634
- not available, and the run continues so the model can react.
683
+ declaration. When the model calls a tool that its request did not offer, or whose
684
+ implementation is unregistered when its tool task runs, an error tool result with
685
+ an error diagnostic with code `tool_unavailable` (section 7.3) states that the tool is not available,
686
+ and the run continues so the model can react (section 8.3).
687
+
688
+ Configuration takes effect at turn boundaries. Generation preparation reads it
689
+ once per request and fixes that turn's prompt, offered tools, model, and request
690
+ options. A configuration change made while the turn runs, such as deactivating a
691
+ tool the model already called, applies at the next preparation; the current
692
+ turn's calls still execute. The retry policy is the exception: generation reads
693
+ it when it classifies an attempt's result, because it governs the next attempt
694
+ (section 8.3).
635
695
 
636
696
  A `Conversation.commit()` is a Session commit bound to that conversation.
637
697
  `tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
@@ -642,31 +702,35 @@ because `Harness extends Session`.
642
702
 
643
703
  `fork()` requires a concrete visible parent entry and explicit ownership, then
644
704
  applies section 3.7.
645
- `collapse()` returns the newly admitted background collapse task ID, not its
646
- future summary entry. `reset()` durably admits a passive self-head reset or
647
- handoff write and then resolves; while busy, placement follows section 6 and may
648
- occur later. Observe its placement through the conversation watch. An idle wait
649
- does not guarantee placement of queued passive writes.
650
-
651
- `abortTask()` commits `abortRequested` and the durable foreground-subtree
652
- cascade, then signals and joins the active run; the scheduler then starts the
653
- abort invocation. Marking without signalling is internal: the cascade marks
654
- owned tasks in the same commit. `Conversation.abort()` withdraws queued
705
+ `compact()` admits a manual compaction task in one commit and returns its ID,
706
+ not its future summary entry. The task is conversation-owned and not background,
707
+ so `Conversation.abort()` cancels it and idle waits include it. It does not take
708
+ run control, so it does not make the conversation busy: the conversation keeps
709
+ working while it summarizes, and its summary is placed through a write
710
+ submission, at once when idle, otherwise at the next boundary (section 8.7). `reset()` durably admits a write submission of a
711
+ `pi.reset` entry (section 8.1) with `head: "self"`, carrying the handoff text as
712
+ a user message when given, and then resolves; while busy, placement follows
713
+ section 6 and may occur later. Observe its placement through the conversation
714
+ watch. An idle wait does not guarantee placement of queued passive writes.
715
+
716
+ `abortTask()` commits `abortRequested`, then signals and joins the active run;
717
+ the scheduler then starts the abort invocation and marks the foreground-owned
718
+ subtree in its next reconcile commit (section 5.4). `Conversation.abort()` withdraws queued
655
719
  input submissions, marks non-background tasks selected by ordinary ownership
656
720
  traversal, signals them, and resolves only after that scope is ordinarily idle.
657
721
  Passive writes and background subtrees survive. Conversation idle means no live
658
722
  non-background task selected from that conversation. Harness idle applies the
659
- same traversal from every ownerless conversation root. Pending dependency- or
660
- deadline-blocked work is still live and therefore not idle. Cancelling an idle
723
+ same traversal from every ownerless conversation root. Waiting,
724
+ deadline-blocked, and `completing` work is still live and therefore not idle. Cancelling an idle
661
725
  wait aborts only that waiter.
662
726
 
663
727
  Conversation handles are stateless; compare them by `id`. Hosts discover
664
- conversations through lookups and scans. An activity view that lists active
665
- conversations and reports conversations becoming active or idle is specified
666
- with run control (Package 17); there is no creation listener.
728
+ conversations through lookups and scans. There is no creation listener and no
729
+ activity view; `inspect()` and the idle waits report live work.
667
730
 
668
731
  `submit()` returns after durable admission, not settlement. An input submission
669
- creates a user message with the admission timestamp; `whenBusy` defaults to
732
+ creates a user message with the Harness clock's timestamp at placement, which is
733
+ admission when it is placed at once; `whenBusy` defaults to
670
734
  `followUp`. A write submission uses the ordered passive path in section 6 and
671
735
  never starts generation. `Submission.wait()` settles an input only after its
672
736
  answer or terminal failure; it settles a write when the entry is placed or the
@@ -676,20 +740,24 @@ withdrawal operation. `Harness.submission()` reacquires a submission after
676
740
  reopen; records remain queryable after settlement.
677
741
 
678
742
  `abortTask()` durably requests cancellation and returns `marked` after the mark
679
- is committed, any active run invocation has joined, and an abort invocation has
680
- been scheduled, or a blocked task has been settled as `orphaned` (section 5.4);
681
- it does not await terminal settlement. `waitForTask()` observes
743
+ is committed and any active run invocation has joined, or after a blocked task
744
+ without live ordinary owned work has been settled as `orphaned` (section 5.4).
745
+ The abort invocation starts once the task's ordinary owned work is no longer
746
+ live; a `completing` task is only marked, and its final record keeps its held
747
+ outcome with `abortRequested: true` (section 5.5). It does not await terminal
748
+ settlement. `waitForTask()` observes
682
749
  the terminal receipt. Aborting an already terminal task returns `terminal`; an
683
750
  unknown ID rejects. Explicit task abort includes a background task. Cancelling a
684
751
  task or idle wait does not abort work.
685
752
 
686
753
  `inspect()` returns live work at one point on the Session line, for recovery
687
754
  decisions after open, viewers, and diagnostics. It writes nothing, does not
688
- enable scheduling, and runs no task code. Each pending or running task carries
689
- its state under the current registry: `running` with an active invocation;
690
- `ready` when the next scheduling pass would reserve it, with `migrates` when its
691
- definition is newer and has `migrate`; `waiting` on unfinished dependencies; or
692
- `blocked` (section 5.4). A migration shows as failed only after the scheduler
755
+ enable scheduling, and runs no task code. Each live task carries its state
756
+ under the current registry: `running` with an active invocation; `completing`
757
+ for a held outcome; `waiting` with the live tasks it still waits for, the live
758
+ part of its `on`, or, when abort-marked, its live ordinary owned work (section
759
+ 5.5); `ready` when the next scheduling pass would reserve it, with `migrates`
760
+ when its definition is newer and has `migrate`; or `blocked` (section 5.4). A migration shows as failed only after the scheduler
693
761
  tried it, or when the newer definition has no `migrate`; inspection never runs
694
762
  one to find out. Blocked reasons are derived, never stored. Queued and placed
695
763
  submissions and the registry's wrapper failures complete the view. Finished
@@ -965,10 +1033,14 @@ interface Tx {
965
1033
  token: Entry<D>, conversationId: ConversationId, value: TypedEntryDraft<D>,
966
1034
  ): Promise<TypedEntry<D>>;
967
1035
  createTask<I, S extends { phase: string }, R, H extends object>(
968
- task: Task<I, S, R, H>, input: I, options?: TaskOptions,
1036
+ task: Task<I, S, R, H>, input: I, options: TaskOptions,
969
1037
  ): Promise<TaskId<R>>;
970
1038
  /** Settle a queued or placed submission; only a placed input can be answered. A settled one stays unchanged. */
971
1039
  settleSubmission(id: SubmissionId, settlement: SubmissionSettlement): void;
1040
+ /** Newest visible entry of the conversation that carries a `head`. */
1041
+ latestHeadMarker(conversationId: ConversationId): Promise<(EntryRecord & { readonly head: EntryId }) | undefined>;
1042
+ /** Record a queued submission's placement at `entry`: an input becomes `placed`, a write `done` (section 6). The caller appends the entry and edits `pi.inbox` and `pi.live`. */
1043
+ placeSubmission(id: SubmissionId, entry: EntryId): void;
972
1044
 
973
1045
  doc<T extends JsonObject>(token: SessionDocToken<T>): Promise<Draft<T>>;
974
1046
  doc<T extends JsonObject>(token: ConversationDocToken<T>, conversationId: ConversationId): Promise<Draft<T>>;
@@ -993,9 +1065,13 @@ For task ownership, the caller supplies only a typed task ID. The Session derive
993
1065
  the persisted owner conversation from the task's final candidate record.
994
1066
 
995
1067
  An owner task may be committed or staged earlier in the same transaction. Before
996
- Storage admission, the Session rejects a missing, terminal, or abort-marked owner,
997
- including one made terminal or abort-marked later in that transaction. Existing
998
- owner edges remain valid when their owners terminalize afterward. Conversation
1068
+ Storage admission, the Session rejects a missing, `completing`, terminal, or
1069
+ abort-marked owner of a new task or conversation, judged on the owner's final
1070
+ candidate record in that transaction. A task therefore cannot create owned work
1071
+ in the commit that finishes it. A child task whose explicit `conversationId`
1072
+ differs from its owner's conversation, and a child task with `background: true`,
1073
+ reject the same way. Existing owner edges remain valid when their owners finish
1074
+ afterward. Conversation
999
1075
  creation returns an inert record, never an operational handle.
1000
1076
  `forkConversation()` additionally validates one visible entry and applies section
1001
1077
  3.7. Only public typed `tx.doc()` is get-or-create. Internal fork copying may create
@@ -1108,10 +1184,10 @@ callback fails with no pending acquisition
1108
1184
  callback succeeds with no pending acquisition
1109
1185
  prepare every open change -> immutable next revision + self-contained Chord Op[]
1110
1186
  Session evaluates each required/ordinary document write exactly once
1111
- prepare every affected loaded conversation mount revision
1112
1187
  Storage.commit persists the atomic batch while the Session line remains held
1113
1188
  storage succeeds
1114
1189
  adopt every prepared change by pointer swap and enqueue immutable revision/ops publication
1190
+ commit listeners, including conversation view mounts (section 9.3), capture it synchronously
1115
1191
  release the line; invoke listeners later
1116
1192
  storage fails
1117
1193
  abort every prepared change, poison Session, and publish nothing
@@ -1128,8 +1204,8 @@ strict JSON from Storage and enter the tracker without another copy. Chord's
1128
1204
  every root they receive must come from one of these sources. Migration callbacks
1129
1205
  never receive a live tracker revision.
1130
1206
 
1131
- Preparation, validation, checkpoint, or mounted-view preparation failure occurs
1132
- before Storage admission and rolls back normally. The Session performs no
1207
+ Preparation, validation, or checkpoint failure occurs before Storage admission
1208
+ and rolls back normally. The Session performs no
1133
1209
  strict-JSON walk of prepared operations or selected bases: roots are checked on
1134
1210
  entry and Chord checks every draft placement, so every revision, operation
1135
1211
  payload, and base is strict JSON by construction. Tracker branding and `baseRevision` enforce ownership and staleness; the
@@ -1321,7 +1397,7 @@ await session.commit(async tx => {
1321
1397
 
1322
1398
  await tx.appendEntry(conversationId, message); // first table write
1323
1399
  delete live.message; // document mutation remains valid
1324
- await tx.createTask(Follow, { after: message.id }); // further table writes are fine
1400
+ await tx.createTask(Follow, {}, { ownership: { kind: "conversation" } }); // further table writes are fine
1325
1401
  }, context);
1326
1402
  ```
1327
1403
 
@@ -1359,27 +1435,42 @@ type TaskOutcome<R> =
1359
1435
  | { readonly status: "orphaned"; readonly reason: string }
1360
1436
  | { readonly status: "faulted"; readonly error: TaskOutcomeError };
1361
1437
 
1438
+ type JoinPolicy = "failFast" | "allSettled";
1439
+
1362
1440
  type TaskState<S, R> =
1363
1441
  | { readonly status: "pending"; readonly checkpoint: S }
1364
1442
  | { readonly status: "running"; readonly checkpoint: S }
1443
+ /** Parked without an invocation until every task in `on` is terminal; then resumes at `checkpoint` (section 5.5). */
1444
+ | {
1445
+ readonly status: "waiting";
1446
+ readonly checkpoint: S;
1447
+ readonly on: readonly TaskId[];
1448
+ readonly policy: JoinPolicy;
1449
+ }
1450
+ /** Outcome decided; terminal once no ordinary owned work below is live (section 5.5). Runs no more code. */
1451
+ | { readonly status: "completing"; readonly outcome: TaskOutcome<R> }
1365
1452
  | { readonly status: "terminal"; readonly outcome: TaskOutcome<R> };
1366
1453
 
1454
+ /** Who owns a task: its conversation (a top-level task) or another task of the same conversation. */
1455
+ type TaskOwnership = { readonly kind: "conversation" } | { readonly kind: "task"; readonly taskId: TaskId };
1456
+
1367
1457
  type TaskRecord<I, S, R> = {
1368
1458
  readonly id: TaskId<R>;
1369
1459
  readonly conversationId: ConversationId;
1370
1460
  readonly kind: string;
1371
1461
  readonly version: number;
1372
1462
  readonly input: I;
1373
- readonly after: readonly TaskId[];
1463
+ /** Owning task; absent for a task its conversation owns. Immutable. */
1464
+ readonly owner?: TaskId;
1374
1465
  readonly background: boolean;
1375
1466
  readonly abortRequested: boolean;
1376
1467
  } & (
1377
1468
  | {
1378
- readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" }>;
1469
+ readonly state: Extract<TaskState<S, R>, { status: "pending" | "running" | "waiting" }>;
1379
1470
  readonly memos?: Readonly<Record<string, JsonValue>>;
1380
1471
  }
1381
1472
  | {
1382
- readonly state: Extract<TaskState<S, R>, { status: "terminal" }>;
1473
+ readonly state: Extract<TaskState<S, R>, { status: "completing" | "terminal" }>;
1383
1474
  readonly memos?: never;
1384
1475
  }
1385
1476
  );
@@ -1388,11 +1479,14 @@ type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
1388
1479
  readonly state: Extract<TaskState<S, R>, { status: "running" }>;
1389
1480
  };
1390
1481
 
1391
- /** State a task commits for itself: a replacement checkpoint or its terminal outcome. */
1392
- type NextTaskState<S, R> = Extract<TaskState<S, R>, { status: "running" | "terminal" }>;
1482
+ /**
1483
+ * State a task commits for itself: a replacement checkpoint, a wait, or its outcome. A returned `terminal` state
1484
+ * becomes `completing` while ordinary owned work below is live (section 5.5).
1485
+ */
1486
+ type NextTaskState<S, R> = Extract<TaskState<S, R>, { status: "running" | "waiting" | "terminal" }>;
1393
1487
 
1394
1488
  interface HookRunner<H extends object> {
1395
- each<K extends keyof H>(name: K, invoke: (handler: H[K]) => void | Promise<void>): Promise<void>;
1489
+ each<K extends keyof H>(name: K, invoke: (handler: NonNullable<H[K]>) => void | Promise<void>): Promise<void>;
1396
1490
  }
1397
1491
 
1398
1492
  type PhaseHandler<I, P, S, R, H extends object> = (
@@ -1409,6 +1503,8 @@ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, Docum
1409
1503
  readonly registry: RegistrySnapshot<ToolRegistration>;
1410
1504
  readonly models: Models;
1411
1505
  readonly hooks: HookRunner<H>;
1506
+ /** `HarnessOptions.env`; the tool task passes it to tools as `api.env`. */
1507
+ readonly env: ExecutionEnv | undefined;
1412
1508
 
1413
1509
  commit(
1414
1510
  change: (
@@ -1421,6 +1517,15 @@ interface TaskRuntime<I, S, R, H extends object> extends DocumentObserver, Docum
1421
1517
  memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
1422
1518
  memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
1423
1519
  conversation(id: ConversationId, context: Context): Promise<ConversationHandle | undefined>;
1520
+ /** Committed task record. */
1521
+ getTask<T>(id: TaskId<T>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, T> | undefined>;
1522
+ /** Terminal receipt; rejects when the invocation ends. */
1523
+ waitForTask<T>(id: TaskId<T>, context: Context): Promise<SettledTask<T>>;
1524
+ /** Outcomes of terminal tasks, in order; rejects when one is not terminal. Used after a wait (section 5.5). */
1525
+ outcomes<T>(ids: readonly TaskId<T>[], context: Context): Promise<TaskOutcome<T>[]>;
1526
+ /** Committed entry visible from the task's conversation. */
1527
+ entry(id: EntryId, context: Context): Promise<EntryRecord | undefined>;
1528
+ entry<D extends JsonValue>(token: Entry<D>, id: EntryId, context: Context): Promise<TypedEntry<D> | undefined>;
1424
1529
  /** Committed raw active transcript and model context, optionally cut off at `at`. */
1425
1530
  context(conversationId: ConversationId, context: Context, at?: EntryId): Promise<ContextView>;
1426
1531
  /** The Harness clock. */
@@ -1450,8 +1555,11 @@ interface Task<I, S extends { phase: string }, R, H extends object> {
1450
1555
  }
1451
1556
 
1452
1557
  type TaskOptions = {
1558
+ /** Required: a task always names its owner (section 5.5). */
1559
+ readonly ownership: TaskOwnership;
1560
+ /** Default: the owner task's conversation, or the transaction's bound conversation. */
1453
1561
  readonly conversationId?: ConversationId;
1454
- readonly after?: readonly TaskId[];
1562
+ /** Conversation-owned tasks only: excluded from conversation abort and idle, and from cascades. */
1455
1563
  readonly background?: boolean;
1456
1564
  };
1457
1565
 
@@ -1477,9 +1585,12 @@ entry it appends records the task as `byTaskId`. When the
1477
1585
  callback returns a state, the runtime replaces the task's state in the same
1478
1586
  commit, so the checkpoint or outcome is atomic with the callback's entries,
1479
1587
  documents, and child tasks and is type-checked against the task's checkpoint and
1480
- result types. Returning nothing leaves the state unchanged. A terminal state
1481
- drops the memos. `pending` is never returned; only reconciliation and handover
1482
- write it.
1588
+ result types. Returning nothing leaves the state unchanged. A terminal or
1589
+ completing state drops the memos. A `waiting` state ends the invocation even
1590
+ when its checkpoint is unchanged; it rejects when `on` names a missing task, the
1591
+ task itself, or a task on its owner chain, which could never finish first, and
1592
+ an empty `on` resumes at the next scheduling pass. `pending` is never returned;
1593
+ only reconciliation and handover write it.
1483
1594
 
1484
1595
  `Tx` has no task replacement operation. A task changes only its own state,
1485
1596
  through its runtime. The scheduler owns reservation, reconciliation, handover,
@@ -1509,11 +1620,14 @@ record. `sleep(until)` compares against the Harness `now` clock and rejects when
1509
1620
  the invocation is signalled or its context is cancelled. Watches acquired through
1510
1621
  the runtime stop when the invocation ends. `snapshot()`/`snapshotAsOf()` read
1511
1622
  committed documents, for example to supply `PromptInput.read` (section 7.4).
1623
+ `getTask()` and `entry()` read committed records with one lookup each; `waitForTask()` waits for a terminal receipt,
1624
+ for example a child task created by a tool.
1512
1625
  `context()` captures its bounds on the Session line and derives the view from
1513
1626
  immutable entries off the line, like `Conversation.context()`. Like every runtime
1514
1627
  operation, these reject after the invocation ends.
1515
1628
 
1516
- Reservation durably changes `pending` to `running`. One invocation runs phase
1629
+ Reservation durably changes `pending`, or `waiting` once it may resume, to
1630
+ `running`. One invocation runs phase
1517
1631
  handlers in sequence; checkpoint commits retain `running`. Before every phase,
1518
1632
  including the first, the scheduler runs one step callback on the Session line.
1519
1633
  It reads the committed task and synchronously applies the first matching rule
@@ -1525,10 +1639,11 @@ invocation queued earlier therefore either lands before the step and counts, or
1525
1639
  reaches the line after it and rejects. Before the first phase only rules 1–3
1526
1640
  apply:
1527
1641
 
1528
- 1. Terminal: stop.
1642
+ 1. Terminal, `completing`, or `waiting`: stop.
1529
1643
  2. Session closing: stop; preserve the checkpoint and any abort mark for reopen.
1530
- 3. Run mode with a durable abort mark: end and join the run invocation, then
1531
- dispatch a fresh abort invocation.
1644
+ 3. Run mode with a durable abort mark: end and join the run invocation; a fresh
1645
+ abort invocation starts once the task's ordinary owned work is no longer
1646
+ live (section 5.5).
1532
1647
  4. Uncaught error: write terminal `faulted`.
1533
1648
  5. Checkpoint changed, including progress within the same phase: refresh the
1534
1649
  registry snapshot and either hand over (section 5.4) or invoke the phase
@@ -1543,10 +1658,11 @@ invocation still ends; the task stays `running` and the next reservation runs it
1543
1658
  again.
1544
1659
 
1545
1660
  On open, running-task reconciliation changes surviving `running` tasks back to
1546
- `pending`, preserving their checkpoint and abort mark. Task migration runs at
1547
- reservation, atomically with `pending -> running`. One callback handles every
1661
+ `pending`, preserving their checkpoint and abort mark; `waiting` tasks stay
1662
+ waiting, and `completing` and `waiting` tasks are re-evaluated (section 5.5). Task migration runs at
1663
+ reservation, atomically with `pending` or `waiting` -> `running`. One callback handles every
1548
1664
  supported older version. A task whose definition is missing, older than the
1549
- stored version, or fails migration stays `pending` and blocked until a fitting
1665
+ stored version, or fails migration stays `pending` or `waiting` and blocked until a fitting
1550
1666
  definition is registered or the task is aborted (section 5.4).
1551
1667
  `close()` marks the runtime closing, seals admission and reservation, signals
1552
1668
  invocations, and stops watches. Outside the Session line it settles admitted
@@ -1576,7 +1692,7 @@ envelope. Candidate insertion and reading the winner are one Session commit, so
1576
1692
  concurrent candidates return the same durable winner. Memos survive checkpoints
1577
1693
  and disappear in the terminal replacement. Bulk progress belongs in a document.
1578
1694
 
1579
- ### 5.3 Terminal tasks and dependencies
1695
+ ### 5.3 Terminal tasks
1580
1696
 
1581
1697
  The terminal task record is the durable result receipt. Its result may directly
1582
1698
  contain a small value or reference an entry:
@@ -1592,10 +1708,14 @@ A terminal transition atomically:
1592
1708
  3. Retires all documents scoped to that task.
1593
1709
  4. Resolves any submissions settled by the task.
1594
1710
 
1711
+ A task whose ordinary owned work is live splits this at a `completing` hold
1712
+ (section 5.5, rule 4): a task-written outcome's own writes land at hold, and
1713
+ items 1 and 3 and its waiters follow in the final commit.
1714
+
1595
1715
  The execution checkpoint and memos disappear from the terminal representation.
1596
- Terminal records remain queryable for dependencies, waiters, inspection, and
1597
- reopen. A normal run becomes eligible when every `after` task is terminal. An abort
1598
- mark bypasses dependencies so pending work can always reach its abort handler,
1716
+ Terminal records remain queryable for waits, waiters, inspection, and reopen.
1717
+ There are no free-standing task dependencies: ordering comes from a task waiting
1718
+ on other tasks (section 5.5). An abort mark lets pending work reach its abort handler,
1599
1719
  or its `orphaned` settlement when its definition is unavailable.
1600
1720
 
1601
1721
  ### 5.4 Scheduler, abort, and ownership
@@ -1609,47 +1729,75 @@ Abort protocol:
1609
1729
  ```text
1610
1730
  commit abortRequested
1611
1731
  signal and join active run invocation
1732
+ wait until ordinary owned work is no longer live (section 5.5)
1612
1733
  start a fresh abort invocation
1613
1734
  abort handler commits terminal outcome
1614
1735
  ```
1615
1736
 
1616
1737
  A run invocation may not commit after its durable abort mark appears. Every
1617
1738
  runtime operation rejects after its owning invocation ends, even while the
1618
- Session remains open. Returning from one phase handler does not end an invocation
1739
+ Session remains open, and the invocation's signal and handler context abort when
1740
+ it ends for any reason. An invocation ends only after its last handler returned,
1741
+ so this cancels only detached leftovers, such as an unawaited `waitForTask()` or
1742
+ a fetch started with the handler's context; they could no longer write anything. Returning from one phase handler does not end an invocation
1619
1743
  that continues into another phase. Invocation mode is volatile and derived from
1620
1744
  the durable mark on reopen. Cancelling one caller's `Context` only cancels
1621
1745
  that call or wait; it does not durably abort shared work unless the invoked API
1622
1746
  commits an abort mark.
1623
1747
 
1624
- A task may create owned conversations. Conversations are durable scopes; tasks
1625
- are the units of live work counted by idle and marked by abort. History parents
1626
- are irrelevant to ownership traversal.
1748
+ A task may create owned conversations and owned tasks (section 5.5).
1749
+ Conversations are durable scopes; tasks are the units of live work counted by
1750
+ idle and marked by abort. History parents are irrelevant to ownership traversal.
1627
1751
 
1628
1752
  Ordinary traversal starts at an explicitly addressed conversation, visits its
1629
- tasks, and follows conversations owned by each non-background task. It follows
1630
- owner edges after the owner becomes terminal, but a background owner is a
1631
- boundary: ordinary traversal skips that task and its complete owned subtree.
1753
+ conversation-owned tasks, follows the tasks each non-background task owns, and
1754
+ follows the conversations each non-background task owns. It follows owner edges after the
1755
+ owner becomes terminal, but a background task is a boundary: ordinary traversal
1756
+ skips that task and its complete owned subtree.
1632
1757
  Direct conversation operations start inside that conversation regardless of its
1633
1758
  owner. Directly aborting a live background task includes that task and follows
1634
- its ordinary owned subtree; nested background owners remain boundaries. Full
1635
- teardown crosses every boundary, marks every live task, and must seal new
1636
- admission while it gathers the complete indexed ownership subtree.
1759
+ its ordinary owned subtree; nested background owners remain boundaries.
1760
+ `close()` stops every invocation, foreground and background, without writing an
1761
+ outcome, so the work resumes on reopen. A host that must durably cancel
1762
+ everything aborts each live task that `inspect()` lists.
1637
1763
 
1638
1764
  `Conversation.abort()` withdraws queued inputs and marks live non-background
1639
- tasks selected by ordinary traversal. `Conversation.waitForIdle()` waits until
1765
+ tasks selected by ordinary traversal. With `{ background: true }` it crosses
1766
+ background boundaries: it marks every live task that traversal ignoring the
1767
+ background flag reaches when the operation is admitted and withdraws the queued
1768
+ inputs of every conversation it reaches. It then waits until every task it
1769
+ marked is terminal and the conversation is ordinarily idle; background work
1770
+ created afterwards is neither marked nor awaited. `Conversation.waitForIdle()` waits until
1640
1771
  that traversal contains no live non-background task. Harness idle performs the
1641
1772
  same traversal from every ownerless conversation root rather than globally
1642
1773
  counting tasks, so ordinary work below a background owner does not block it.
1643
1774
  Explicit `waitForTask()` waits for its referenced task regardless of the task's
1644
1775
  background flag.
1645
1776
 
1646
- An abort mark atomically and idempotently cascades to foreground-owned work,
1647
- including conversations, tasks, and submissions staged in the same transaction.
1648
- Terminal outcomes `failed`, `faulted`, `orphaned`, and `aborted` record the same
1649
- durable cancellation intent; `completed` does not. Conversation records and
1650
- owner edges are never retired with the task. Active invocations are signalled
1651
- after commit, and a terminal receipt guarantees durable cancellation intent,
1652
- not descendant quiescence.
1777
+ An abort mark cascades idempotently to foreground-owned work. The durable
1778
+ cancellation intent is a live owner's own record: its abort mark, or a held
1779
+ `completing` outcome other than `completed` (section 5.5). A terminal owner never
1780
+ cascades: it became terminal only after its ordinary owned work drained, so its
1781
+ intent is already applied, and work later started in a conversation it owned,
1782
+ for example a user interrogating a finished subagent, runs normally and is
1783
+ reached by ordinary traversal from above. Queued inputs in its owned
1784
+ conversations stay queued, as after a failed run (section 6). The scheduler derives the marks in a
1785
+ later reconcile commit, and again at open, so a crash in between loses nothing;
1786
+ work already queued on the Session line may make one more commit first. Every
1787
+ live non-background task whose owner chain, walking up through owning tasks and
1788
+ owning conversations, reaches a cancelled live owner, without first crossing a
1789
+ background task that has no cancellation intent of its own, gets an abort mark, including work
1790
+ created there after the cascade. Deriving the marks in their own commit
1791
+ keeps them out of the owner's commit, which may already have written the tasks
1792
+ involved. Every conversation the cascade reaches is treated like
1793
+ `Conversation.abort()`: its queued input submissions become `unanswered` with
1794
+ reason `aborted` and leave its inbox, while queued writes stay. The cascade starts
1795
+ below the cancelled task, so that task's own conversation keeps its queue.
1796
+ Held outcomes `failed`, `faulted`, `orphaned`, and `aborted` record the same
1797
+ durable cancellation intent as an abort mark; `completed` does not. Conversation
1798
+ records and owner edges are never retired with the task. Active invocations are
1799
+ signalled after commit. A terminal receipt guarantees that the task's ordinary
1800
+ owned work drained; background work below it may continue.
1653
1801
 
1654
1802
  Every run invocation start resolves the task's definition by `TaskRecord.kind`
1655
1803
  from the scheduler's current registry snapshot:
@@ -1683,25 +1831,39 @@ missing or cannot take the task. The Harness therefore settles it as terminal
1683
1831
  ran and decided the outcome, while `orphaned` means no task code ran, so external
1684
1832
  effects the task started may remain uncleaned. The orphaned `reason` is the
1685
1833
  blocked reason (`missing_task`, `task_too_old`, or `migration_failed`). When
1686
- `abortTask()` finds no active invocation and the current snapshot
1687
- cannot take the task, the marking commit settles it directly; otherwise the
1688
- scheduler settles it when it would reserve the abort invocation. Only an abort (direct, by
1834
+ `abortTask()` finds no active invocation, no live ordinary owned work, and a
1835
+ current snapshot that cannot take the task, the marking commit settles it
1836
+ directly; otherwise the scheduler settles it when it would reserve the abort
1837
+ invocation, which is after the owned work drained (section 5.5), so an orphaned
1838
+ outcome never holds. Only an abort (direct, by
1689
1839
  conversation, or by cascade) orphans a task; a missing definition alone never
1690
1840
  does. The orphaning commit performs the cleanup the task's code cannot: affected
1691
1841
  input submissions become unanswered with the reason, any matching active run
1692
- control is cleared, and task-scoped documents retire. No transcript entry is
1693
- written; the terminal task record and unanswered submissions carry the reason.
1842
+ control is cleared, and task-scoped documents retire. The terminal task record
1843
+ and unanswered submissions carry the reason; the only transcript entry written
1844
+ is the conversion of a committed generation partial (below).
1694
1845
  Faulting a run task performs the same control/submission cleanup with a
1695
- `faulted` outcome.
1846
+ `faulted` outcome; a fault while ordinary owned work is live holds, and its
1847
+ cleanup runs in the final commit (section 5.5, rule 4).
1696
1848
 
1697
1849
  The scheduler knows nothing about runs or task kinds. The Harness, which owns
1698
1850
  submissions, run control, and the built-in tasks, gives it one hook that the
1699
- scheduler calls in the same commit for every terminal outcome it writes itself
1700
- (`faulted` and `orphaned`). The hook ignores tasks whose kind is not a built-in
1701
- run kind, so it never creates `pi.live` elsewhere. It settles the run when
1702
- `pi.live.run` names the task (section 8): its inputs become `unanswered` with reason `faulted`
1703
- (detail: the error message) or the blocked reason, and `run` and `generation`
1704
- are removed. Outcomes a task commits for itself do their own settlement.
1851
+ scheduler calls in the commit that makes an outcome it wrote itself (`faulted`
1852
+ and `orphaned`) terminal, which is the final commit after a hold. The hook ignores tasks whose kind is not a built-in
1853
+ run, tool, or compaction kind, so it never creates `pi.live` elsewhere. It settles the run when
1854
+ `pi.live.run` names the task (section 8): a committed generation partial becomes
1855
+ an aborted `pi.assistant` entry, exactly as the generation abort handler converts
1856
+ it, so the transcript keeps what the model produced and `pi.usage` counts its
1857
+ spend (the scheduler's commit has no task scope, so the entry has no
1858
+ `byTaskId`); its inputs become `unanswered` with reason `faulted` (detail: the error
1859
+ message) or the blocked reason; and `run`, `generation`, and `tools` are
1860
+ removed. Faults come from task bugs or malformed provider data, such as a
1861
+ non-JSON value in a response, or a commit the Storage rejected without effect
1862
+ (`StorageRejected`). An uncertain storage failure poisons the Session and writes
1863
+ no outcome. For a `pi.tool` task it marks the task's tool slot `done`
1864
+ without an entry; the run continues, and context derivation synthesizes the
1865
+ missing result (section 2.1). For a `pi.compaction` task it removes the task's
1866
+ compaction status (section 8.7). Outcomes a task commits for itself do their own settlement.
1705
1867
 
1706
1868
  At every normal phase boundary (section 5.1, rule 5), the step refreshes the
1707
1869
  invocation's registry snapshot. If the task definition resolved by name is a different object
@@ -1715,23 +1877,133 @@ definition, reports once through `onReport` per resolved definition, and
1715
1877
  reconsiders at its next boundary. A handler that
1716
1878
  never settles never hands over.
1717
1879
 
1880
+ ### 5.5 Structured concurrency
1881
+
1882
+ Tasks and conversations form one ownership tree. Every task names its owner at
1883
+ creation, `tx.createTask(task, input, { ownership })` with a required
1884
+ `TaskOwnership`: its conversation (`{ kind: "conversation" }`, a top-level task)
1885
+ or a task (`{ kind: "task", taskId }`, a child task). Every conversation is
1886
+ ownerless or owned by a task (section 2.2). A child task always lives in its
1887
+ owner's conversation; only owned conversations cross conversation boundaries.
1888
+ Owner edges are immutable.
1889
+
1890
+ The **ordinary owned work** of a task `T` is every task that ordinary traversal
1891
+ reaches below `T`: the tasks `T` owns, the tasks in the conversations `T` owns,
1892
+ and, transitively, the same for each of those that is not background. A
1893
+ background task and everything below it are excluded. A conversation-owned task
1894
+ may be background; a child task may not.
1895
+
1896
+ Two rules follow from the tree.
1897
+
1898
+ **Abort flows down** (section 5.4). A live owner's cancellation intent (its abort
1899
+ mark, or a held non-`completed` outcome) marks its ordinary owned work. Background
1900
+ tasks are boundaries unless aborted directly or by `Conversation.abort(context,
1901
+ { background: true })`.
1902
+
1903
+ **Owned work does not outlive its owner's finish.** A task finishes only after
1904
+ its ordinary owned work drained:
1905
+
1906
+ 1. A terminal state a task commits, or a terminal outcome the scheduler writes
1907
+ (`faulted`, `orphaned`), while the task's ordinary owned work is live is
1908
+ stored as `{ status: "completing", outcome }` instead. The scheduler writes
1909
+ the final `terminal` record in a later commit once no ordinary owned work is
1910
+ live, evaluated after every commit, including work created after the hold.
1911
+ Whether work is live is judged on the commit's candidate records, so work the
1912
+ finishing commit itself creates, for example a task in a conversation the
1913
+ task owns, holds it. At open it re-evaluates every `completing` task.
1914
+ 2. A held outcome is final: no phase, runtime commit, or abort handler runs
1915
+ again, no definition is needed, the task is never reserved or migrated, and
1916
+ `abortTask()` on it only marks it and returns `marked`, which cancels the
1917
+ work below. The final record keeps the held outcome and the mark.
1918
+ 3. A held non-`completed` outcome is cancellation intent, so the work below is
1919
+ aborted, drains, and then the task becomes terminal. A held `completed`
1920
+ outcome waits for the work to finish normally.
1921
+ 4. Writes split at the hold. A task-written terminal commit lands all of its
1922
+ other writes, such as a tool's result entry and slot, at hold. Only the
1923
+ record's terminal state, task-document retirement, and task waiters are
1924
+ deferred to the final commit. A scheduler-written outcome writes only the
1925
+ record at hold; its Harness cleanup (section 5.4) runs in the final commit,
1926
+ so a faulted run task keeps `pi.live.run` until its tools drained.
1927
+ 5. Waiters, idle waits, inspection, and ordinary traversal see a `completing`
1928
+ task as live until its final commit.
1929
+
1930
+ New owned work needs a live owner: `createTask` with task ownership and
1931
+ `createConversation` with task ownership reject an owner that is `completing`,
1932
+ `terminal`, or abort-marked in the commit's final candidate (section 3.3).
1933
+ Conversations stay usable after their owner finished: new runs
1934
+ started there, for example to interrogate a finished subagent, are ordinary work
1935
+ of that conversation, reached by traversal from above, and not part of any
1936
+ finish.
1937
+
1938
+ **Waiting.** A phase may commit `{ status: "waiting", checkpoint, on, policy }`.
1939
+ The task stops without an invocation and resumes at `checkpoint` once every task
1940
+ in `on` is terminal. `on` may name any tasks, including already terminal ones;
1941
+ tasks the waiting task does not own, whose `owner` is not the waiting task,
1942
+ require `policy: "allSettled"`. `on` may not name a missing task, the task
1943
+ itself, or a task on its owner chain (section 5.1). With `failFast`, the first
1944
+ task in `on` that holds or ends with a non-`completed` outcome gives every other
1945
+ live task in `on` an abort mark, in the scheduler's next reconcile commit. The waiting task itself is not marked:
1946
+ it resumes once all of `on` is terminal and reads their outcomes with
1947
+ `runtime.outcomes()`. A task may create children and keep running phases before
1948
+ it waits, and may wait on any subset of its children in sequence.
1949
+
1950
+ **Abort order is bottom-up.** An abort invocation of a task starts only once its
1951
+ ordinary owned work is no longer live, so an abort handler sees final outcomes
1952
+ below it. An abort mark on a waiting task lets it leave the wait early for its
1953
+ abort handler under this same rule; tasks in its `on` that it does not own are
1954
+ not awaited. Ordering is judged on committed records: code a child runs after
1955
+ its terminal commit is not ordered. An abort handler may not return `waiting`
1956
+ and cannot create owned children, because its task is abort-marked (section
1957
+ 3.3): it compensates inline, or creates background conversation-owned tasks,
1958
+ which no cascade reaches, and waits for them with `runtime.waitForTask()`,
1959
+ which holds its invocation.
1960
+
1961
+ The cascade of the example `Checkout` task, which waits `failFast` on four
1962
+ `Payment` children: an expired card fails its payment; the three live payments
1963
+ get abort marks and refund in their own abort handlers; once all four are
1964
+ terminal, `Checkout` resumes, reads `[aborted, failed, aborted, aborted]`
1965
+ through `outcomes()`, and decides its own outcome. `abortTask(Checkout)` instead
1966
+ marks `Checkout`, the cascade marks the live payments, their abort handlers run
1967
+ first, then `Checkout.abort` runs and sees their final outcomes.
1968
+
1718
1969
  ## 6. Submissions and inbox
1719
1970
 
1720
1971
  Submission records back awaitable host objects. Admission is Harness-internal;
1721
- run tasks settle the inputs they answer with `tx.settleSubmission()`. The inbox
1722
- itself is an ordered conversation document containing tagged items:
1972
+ boundaries place queued submissions with `tx.placeSubmission()`, and run tasks
1973
+ settle the inputs they answer with `tx.settleSubmission()`. Queued submissions
1974
+ wait in the built-in inbox document, an ordered list of tagged items:
1723
1975
 
1724
1976
  ```ts
1725
1977
  type InboxItem =
1726
- | { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly message: Message }
1727
- | { readonly id: SubmissionId; readonly mode: "write"; readonly entry: EntryDraft };
1978
+ | { readonly id: SubmissionId; readonly mode: "steer" | "followUp"; readonly content: UserInput }
1979
+ /** `entry` is the write's `EntryDraft`, stored as plain JSON. */
1980
+ | { readonly id: SubmissionId; readonly mode: "write"; readonly entry: JsonObject };
1981
+
1982
+ type InboxState = { items: InboxItem[] };
1728
1983
  ```
1729
1984
 
1985
+ | field | value |
1986
+ |---|---|
1987
+ | kind | `pi.inbox` |
1988
+ | version | `1` |
1989
+ | scope/history/fork | conversation, `latest`, `initial` |
1990
+ | `initial()` | `{ items: [] }` |
1991
+ | checkpoint | complete base whenever `items` is empty |
1992
+ | view mount | `docs["pi.inbox"]` |
1993
+ | created | with every Harness conversation (section 2.2) |
1994
+
1995
+ Items are in ID order. A queued input stores its content; its `pi.user` entry
1996
+ gets the Harness clock's timestamp at placement. Queued submissions belong to
1997
+ their conversation, so a fork starts with an empty inbox.
1998
+
1730
1999
  Run control lives in the built-in live document `pi.live` (section 8). Its
1731
2000
  optional `run` value names the task currently responsible for the run and its
1732
2001
  placed input-submission IDs. `run !== undefined` defines `busy`; get-or-create
1733
2002
  of the idle document does not. The value remains while generation, tools, and
1734
- post-tools hand work to one another. The ID list is mutable state because a
2003
+ tools hand work to one another: `taskId` names the generation that settles the
2004
+ inputs, including while its tool round runs, then the next
2005
+ generation. Tool tasks never own the run; the current round's tool tasks are
2006
+ listed in `pi.live.tools`. The ID list is mutable state because a
1735
2007
  boundary adds placed steering inputs to an active run; every terminal path
1736
2008
  settles exactly the listed inputs.
1737
2009
 
@@ -1739,43 +2011,85 @@ Admission and terminal transitions:
1739
2011
 
1740
2012
  | action | submission state | other writes |
1741
2013
  |---|---|---|
1742
- | idle input submission | `placed`, with user entry | create run controller/generation |
1743
- | busy input submission | `queued` | append steer/follow-up inbox item |
1744
- | idle write submission | `done`, with entry | append entry; no run |
1745
- | busy write submission | `queued` | append write inbox item |
1746
- | boundary places user item | `placed`, with entry | add ID to current/successor run |
2014
+ | input, idle with empty inbox | `placed`, with user entry | create run and generation |
2015
+ | write, idle with empty inbox | `done`, with entry | append entry; no run |
2016
+ | input or write, busy or non-empty inbox | `queued` | append inbox item |
2017
+ | boundary places user item | `placed`, with entry | add ID to current or successor run |
1747
2018
  | boundary places write | `done`, with entry | append entry |
1748
- | run answers | input `done`, with required answer entry | clear/hand off run controller |
1749
- | run fails or aborts | input `unanswered`, with reason | clear/hand off run controller |
2019
+ | run answers | input `done`, with required answer entry | remove `run`, or hand it to a successor |
2020
+ | run fails or its task aborts | input `unanswered`, with reason | remove `run`; inbox unchanged |
1750
2021
  | withdraw queued item | `unanswered`, reason `aborted` | remove inbox item |
1751
- | stale item | `unanswered`, reason `stale` | remove inbox item |
2022
+ | stale head write | `unanswered`, reason `stale` | remove inbox item |
1752
2023
 
1753
2024
  `requestId` deduplicates within one conversation before any write; reusing one
1754
2025
  for the other submission type rejects. A busy input with `whenBusy: "reject"`
1755
- writes no record and reports `ConversationBusy`. Before an idle input places its
1756
- own entry, it runs a final boundary to drain older eligible queued items. A
1757
- `Submission` waits until `done` or `unanswered`; abort withdraws only a still-
2026
+ writes no record and reports `ConversationBusy`. An idle input queues with mode
2027
+ `steer` when `whenBusy` is `steer` and `followUp` otherwise.
2028
+
2029
+ An idle conversation with a non-empty inbox, for example after a failed run,
2030
+ queues every new submission behind the waiting items and runs a final boundary
2031
+ in the same commit. Order is preserved: a follow-up queued before the failure is
2032
+ selected before the new input.
2033
+
2034
+ A `Submission` waits until `done` or `unanswered`; abort withdraws only a still-
1758
2035
  queued submission, reports `already_placed` for a placed input, and reports
1759
- `settled` for any terminal submission. Conversation abort withdraws queued steer/follow-up submissions but keeps writes
1760
- for later placement.
2036
+ `settled` for any terminal submission. Conversation abort withdraws queued
2037
+ steer/follow-up submissions but keeps writes for later placement.
1761
2038
 
1762
- Boundary selection is deterministic by item ID:
2039
+ Boundary selection is deterministic by item ID, with the conversation's
2040
+ configured `steeringMode` and `followUpMode` (section 2.2): `one-at-a-time`
2041
+ selects the first item of that mode, `all` every item of that mode.
1763
2042
 
1764
2043
  | boundary | write | steer | follow-up |
1765
2044
  |---|---|---|---|
1766
2045
  | `postTools` | all | first/all by mode | none |
1767
2046
  | `final` | all | first/all by mode | first/all by mode |
1768
2047
 
1769
- A queued self-head write cuts older pending user items: those submissions become
1770
- stale, the write is placed, and the current run terminates. Other head writes
1771
- whose target predates the caller's newest known head are stale.
2048
+ A boundary places its selected writes first, in ID order, and then its selected
2049
+ user items, in ID order. A user item queued before a reset or compaction summary
2050
+ therefore lands after it and runs in the new context. Queued user items never
2051
+ become stale because of a head.
2052
+
2053
+ A head write, queued or placed at once, whose target is older than the start of
2054
+ the active range, the newest head marker's `head`, is stale: placing it would bring back history that
2055
+ head cut. Heads placed earlier in the same boundary count: after a queued reset,
2056
+ a queued summary targeting an older entry is stale. A `head: "self"` write is
2057
+ never stale.
2058
+
2059
+ Compaction summaries are head writes (section 8.7), so this rule alone orders
2060
+ compactions by cut position: a summary cutting before the active range start is
2061
+ stale, and one cutting at or after it is placed, whenever it was selected.
2062
+ Example: background compaction B selects at tail 100 and cuts at 70. While it
2063
+ summarizes, a blocking compaction A cuts at 150 and appends its summary. B's
2064
+ summary then settles `stale`, because 70 is older than 150. Had A cut at 60
2065
+ instead, B would be placed after it: B summarized the context at its selection,
2066
+ the then-newest summary plus the entries before 70, which covers everything A's
2067
+ summary covers. That holds while the only heads are compaction summaries and
2068
+ resets, which make older cuts stale; an application edit placed while a
2069
+ compaction summarizes is lost, and an application head can be undone (section
2070
+ 12).
2071
+
2072
+ A `postTools` boundary that selects a `head: "self"` write (a reset) behaves as
2073
+ `final`: it also selects follow-ups, and the current run ends with its inputs
2074
+ `unanswered` with reason `reset`, because its context was cut before an answer.
2075
+ A reset placed at a `final` boundary follows the answer, so the inputs are
2076
+ already `done`.
1772
2077
 
1773
2078
  At ordinary `postTools`, generation continues even with no queued trigger;
1774
- selected steer IDs join that continuation. A terminating/handoff post-tools
1775
- boundary uses final behavior instead. At `final`, the current run's placed
1776
- input submissions settle first; selected user IDs start one successor generation. Writes
1777
- never trigger generation by themselves. A final boundary without continuation
1778
- or user triggers leaves the conversation idle.
2079
+ selected steer IDs join that continuation. A terminating or handoff tool round
2080
+ (section 8.5) uses final behavior instead. At `final`, the current run's placed
2081
+ input submissions settle first, unless an `onYield` continuation keeps them open
2082
+ (below); selected user IDs start one successor generation with those IDs as the
2083
+ new run's inputs. Writes never trigger generation by
2084
+ themselves. A final boundary without user triggers leaves the conversation idle,
2085
+ except for an `onYield` continuation (section 8.3), which applies only when the
2086
+ boundary selected no user item and no reset.
2087
+
2088
+ Only successful run ends apply the final boundary: an answer, `terminate`, or
2089
+ `handoff`. Failure, a run task's abort handler, fault, and orphan settle the
2090
+ run's inputs `unanswered` and leave the inbox alone (`Conversation.abort()`
2091
+ separately withdraws queued user items); the queued items stay visible in
2092
+ `pi.inbox` until the next submission's boundary or their withdrawal.
1779
2093
 
1780
2094
  Selected and stale items are removed positionally while retained item order is
1781
2095
  preserved. Chord's Astra operation generator must express scattered removals
@@ -1957,7 +2271,7 @@ hooks read that snapshot and never take their own. At every normal phase boundar
1957
2271
  the scheduler takes a fresh one (section 5.4). A tool task keeps the composed tool
1958
2272
  it pinned until execution settles. Different phases may observe different
1959
2273
  registry states; nothing requires one run to see a single registry state across
1960
- its generation, tool, and post-tools tasks. Host operations, such as the create
2274
+ its generation and tool tasks. Host operations, such as the create
1961
2275
  conveniences, take one snapshot inside their commit.
1962
2276
 
1963
2277
  ```ts
@@ -1997,14 +2311,91 @@ errors always propagate.
1997
2311
  | `beforeRequest` | replacement chain | report, continue |
1998
2312
  | `afterResponse` | all observers | report, continue |
1999
2313
  | `onYield` | first continuation wins | report, continue |
2000
- | `beforeTool` | call replacement chain; first block wins | block tool with error text |
2314
+ | `beforeTool` | argument replacement chain; first block wins | block tool with error text |
2001
2315
  | `afterTool` | result replacement chain | report, continue |
2002
2316
  | `afterTools` | all observers | report, continue |
2003
- | `beforeCollapse` | first decision wins | report, continue |
2317
+ | `beforeCompact` | first decision wins | report, continue |
2004
2318
 
2005
2319
  Hooks use task memos for durable first-writer-wins decisions. There is no public
2006
2320
  semantic event channel; current UI status is document state.
2007
2321
 
2322
+ ```ts
2323
+ type HookResult<T> = T | undefined | Promise<T | undefined>;
2324
+
2325
+ /** What a hook may use: committed reads and the asking task's memos. */
2326
+ interface HookApi extends DocumentReader {
2327
+ readonly taskId: TaskId;
2328
+ readonly conversationId: ConversationId;
2329
+ memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
2330
+ memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
2331
+ }
2332
+
2333
+ interface GenerationHooks {
2334
+ /** Before every request attempt, including recovery; the result is used for that request only. */
2335
+ beforeRequest(
2336
+ request: { readonly messages: readonly Message[] },
2337
+ api: HookApi,
2338
+ context: Context,
2339
+ ): HookResult<{ readonly messages: readonly Message[] }>;
2340
+ /** Every terminal provider message, before classification. */
2341
+ afterResponse(message: AssistantMessage, api: HookApi, context: Context): void | Promise<void>;
2342
+ /** A final answer; `continue` appends a user message and continues the run. */
2343
+ onYield(answer: AssistantMessage, api: HookApi, context: Context): HookResult<{ readonly continue: UserInput }>;
2344
+ /** After every tool of the round is terminal; `results` are the round's result entries in call order. */
2345
+ afterTools(assistant: EntryId, results: readonly EntryId[], api: HookApi, context: Context): void | Promise<void>;
2346
+ }
2347
+
2348
+ interface ToolHooks {
2349
+ /** Before intent; replaces the arguments or blocks the call with error text. */
2350
+ beforeTool(
2351
+ call: ToolCall,
2352
+ api: HookApi,
2353
+ context: Context,
2354
+ ): HookResult<{ readonly arguments?: JsonObject; readonly block?: string }>;
2355
+ /** After execution, before the result entry; replaces the result. */
2356
+ afterTool(
2357
+ call: ToolCall,
2358
+ result: ToolExecutionResult,
2359
+ api: HookApi,
2360
+ context: Context,
2361
+ ): HookResult<ToolExecutionResult>;
2362
+ }
2363
+
2364
+ interface CompactionHooks {
2365
+ /**
2366
+ * After range selection, before summarizing; the first decision wins. `entries` are the active entries the summary
2367
+ * replaces, the head marker first, and `messages` their model context, the summarizer's source; `firstKept` is the
2368
+ * first entry kept verbatim.
2369
+ */
2370
+ beforeCompact(
2371
+ compaction: {
2372
+ readonly reason: CompactionReason;
2373
+ readonly entries: readonly EntryRecord[];
2374
+ readonly messages: readonly Message[];
2375
+ readonly firstKept: EntryId;
2376
+ readonly instructions?: string;
2377
+ },
2378
+ api: HookApi,
2379
+ context: Context,
2380
+ ): HookResult<{ readonly decline: true } | { readonly summary: string }>;
2381
+ }
2382
+ ```
2383
+
2384
+ `GenerationTask`, `ToolTask`, and `CompactionTask` are the exported built-in task
2385
+ tokens whose `H` parameters are these interfaces. `runtime.hooks.each(name,
2386
+ invoke)` calls `invoke` with every handler registered under `name` whose
2387
+ registration matches the task's conversation, in registry order of the phase
2388
+ snapshot. A registration matches when it has no scope, when its scope names the
2389
+ task's conversation, or, with `subtree`, when the task's conversation is owned
2390
+ transitively through tasks of the scope's conversation. Owner edges never change,
2391
+ so the Harness may cache each conversation's owner chain. An ordinary throw from
2392
+ `invoke` is reported through `onReport` and `each` continues with the next
2393
+ handler; once the invocation is signalled, the error propagates. A task that
2394
+ composes differently, such as `beforeTool` turning a throw into a block, or stops
2395
+ after a first decision, does so inside its own `invoke`. Hooks receive the task's
2396
+ runtime as their `HookApi`; hook memos and the task's own memos share one
2397
+ namespace, so hook authors prefix their memo names.
2398
+
2008
2399
  ### 7.3 Tools
2009
2400
 
2010
2401
  ```ts
@@ -2014,10 +2405,20 @@ type ToolControl = {
2014
2405
  readonly handoff?: string;
2015
2406
  };
2016
2407
 
2408
+ /** Remark about a call for the model and the UI; never part of the tool's data. */
2409
+ type ToolDiagnostic = {
2410
+ readonly severity: "info" | "warn" | "error";
2411
+ readonly message: string;
2412
+ readonly code?: string;
2413
+ };
2414
+
2017
2415
  type ToolExecutionResult = {
2018
2416
  readonly content?: ToolResultMessage["content"];
2019
2417
  readonly isError?: boolean;
2020
2418
  readonly details?: JsonValue;
2419
+ readonly diagnostics?: readonly ToolDiagnostic[];
2420
+ /** Spend of the execution itself, such as a model call; stored on the result and in `pi.usage.tools`. */
2421
+ readonly usage?: Usage;
2021
2422
  readonly control?: ToolControl;
2022
2423
  };
2023
2424
 
@@ -2032,7 +2433,10 @@ interface ToolExecutionApi extends DocumentObserver, DocumentReader {
2032
2433
  readonly taskId: TaskId;
2033
2434
  readonly conversationId: ConversationId;
2034
2435
  readonly callId: string;
2436
+ /** `HarnessOptions.env` unless a wrapper supplies another environment. */
2437
+ readonly env: ExecutionEnv | undefined;
2035
2438
  output(chunk: string | Uint8Array): void;
2439
+ diagnostic(diagnostic: ToolDiagnostic): void;
2036
2440
  details(value: JsonValue, context: Context): Promise<void>;
2037
2441
  commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
2038
2442
  memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
@@ -2050,6 +2454,9 @@ interface ToolExecutionApi extends DocumentObserver, DocumentReader {
2050
2454
 
2051
2455
  type ToolRegistration = Tool & {
2052
2456
  readonly replay?: "safe" | "unsafe";
2457
+ readonly executionMode?: ToolExecutionMode;
2458
+ /** Pure repair of commonly malformed arguments; runs before validation and must not mutate `args`. */
2459
+ prepareArguments?(args: JsonValue): JsonValue;
2053
2460
  readonly outputLimits?: {
2054
2461
  readonly maxBytes?: number;
2055
2462
  readonly maxLines?: number;
@@ -2064,14 +2471,41 @@ type ToolRegistration = Tool & {
2064
2471
  ```
2065
2472
 
2066
2473
  Omitted `replay` is `unsafe`. Omitted `outputLimits` are 50 KiB, 2,000 lines,
2067
- and `retain: "head"`.
2474
+ and `retain: "head"`. Omitted `executionMode` follows the conversation's
2475
+ `toolExecution`; one `sequential` call makes its whole round sequential
2476
+ (section 8.3).
2068
2477
 
2069
- A running tool reports two things to the UI, mirroring the two halves of its
2070
- final result:
2478
+ Tools reach files and processes only through `api.env`, never through an
2479
+ environment captured at registration, so the environment is chosen per call. The
2480
+ Harness supplies `HarnessOptions.env`. A wrapper may supply another, for example
2481
+ a per-conversation working directory or sandbox:
2482
+
2483
+ ```ts
2484
+ registry.tools.wrap("bash", "workspace", (tool) => ({
2485
+ ...tool,
2486
+ execute: async (args, api, context) => {
2487
+ const workspace = await api.snapshot(WorkspaceDoc, api.conversationId, context);
2488
+ return tool.execute(args, { ...api, env: environmentFor(workspace) }, context);
2489
+ },
2490
+ }));
2491
+ ```
2492
+
2493
+ A tool rerun after recovery must get the same environment. When the state a
2494
+ wrapper reads can change while a call runs, the wrapper memoizes its choice with
2495
+ `api.memo()`. `api` is a plain object so wrappers can spread
2496
+ it. A tool that needs an environment and receives none throws, which produces an
2497
+ ordinary error result.
2498
+
2499
+ A running tool reports output and details to the UI, mirroring the two halves of
2500
+ its final result, plus diagnostics (below):
2071
2501
 
2072
2502
  - `output(chunk)` appends running text output, like stdout. If `execute()` omits
2073
2503
  `content`, the final retained output becomes one text content item; no output
2074
- becomes an empty content list.
2504
+ becomes an empty content list. Retained output is an exact slice of whole lines
2505
+ of the stream (the first lines for `head`, the last for `tail`), trailing
2506
+ newline included; a single line longer than `maxBytes` is cut at the byte limit
2507
+ on a character boundary. Control characters other than tab and newline are
2508
+ removed from the retained text; accepting a chunk does no per-chunk sanitizing.
2075
2509
  - `details(value)` replaces the running details with a complete JSON value; it
2076
2510
  does not merge keys. If `execute()` omits `details`, the last value becomes the
2077
2511
  final `details`, so a renderer handles one details shape from the first
@@ -2080,19 +2514,56 @@ final result:
2080
2514
  Neither is sent to the model while the tool runs. `output()` synchronously
2081
2515
  accepts UTF-8 output into that invocation-owned bounded buffer and throws after
2082
2516
  invocation end. Throttled commits publish the retained output, dropped
2083
- byte/line counts, and the current details in the tool presentation document. Explicit
2517
+ byte/line counts, and the current details and diagnostics in its `pi.live.tools`
2518
+ slot. The throttle is adaptive, like the environment's shell output capture: the
2519
+ first change after an idle period commits at once; each commit then delays the
2520
+ next by at least 100 ms and by its written size at 100 KiB/s, so a large
2521
+ rewrite buys a proportionally longer pause. Changes made during the delay
2522
+ coalesce into the next commit. The throttle is Harness policy, not part of
2523
+ `outputLimits`, which only bounds what is retained. Explicit
2084
2524
  text in explicit result content is bounded by the same limits before transcript
2085
- persistence; non-text content is retained as declared by its pi-ai type.
2525
+ persistence; non-text content is retained as declared by its pi-ai type. When
2526
+ bounding drops text, the Harness adds a `warn` diagnostic with code `truncated`
2527
+ stating the dropped lines and bytes.
2528
+
2529
+ Diagnostics are a channel, not text. Remarks about a call, such as truncated
2530
+ output, a spill path, a corrected path, a file changed on disk, or a capped
2531
+ search, go through `api.diagnostic()` or `ToolExecutionResult.diagnostics`,
2532
+ never into the content the model reads as the tool's data. The Harness adds the
2533
+ ones it owns, and a tool adds only what it alone knows. Every diagnostic is
2534
+ model-visible; information only for UIs belongs in `details`. `diagnostic()`
2535
+ synchronously records the diagnostic in the tool's slot with the next throttled
2536
+ commit and throws after invocation end. At settlement the result's diagnostics
2537
+ are those recorded through `api`, then those in the result, then the Harness's.
2538
+ When there are any, the result content ends with one text item:
2539
+
2540
+ ```text
2541
+ <harness>
2542
+ [warn] output truncated: 51,204 lines, 2,301,112 bytes dropped
2543
+ </harness>
2544
+ ```
2545
+
2546
+ with one `[severity] message` line per diagnostic, and the `pi.tool-result`
2547
+ entry stores the structured list, possibly empty, as `data: { diagnostics }`
2548
+ (section 8.1). The
2549
+ stored message is exactly what the model saw, while UIs and code read the list.
2550
+ A `warn` diagnostic does not set `isError`.
2086
2551
 
2087
2552
  `output()` never spills complete output to a file because spilling requires a
2088
2553
  filesystem, which may be remote or unavailable. A tool that must preserve
2089
2554
  complete output spills through the `ExecutionEnv` or `FileSystem` it was given,
2090
- such as shell execution with spill capture, and reports the resulting path in its
2091
- details.
2555
+ and reports the resulting path in a diagnostic, and in its details when a
2556
+ renderer needs it. The environment's shell streams raw output chunks and spills
2557
+ the complete output to a file once it crosses byte or line thresholds; it keeps
2558
+ no bounded view of its own, so `output()` is the one place output is bounded,
2559
+ sanitized, and throttled. The `bash` tool pipes those chunks into `output()`,
2560
+ reports the spill path as a diagnostic, and throws on a nonzero exit or timeout;
2561
+ the error result still carries the retained output and diagnostics.
2092
2562
 
2093
2563
  The `details()` promise resolves after the corresponding or coalesced document
2094
- commit. During normal settlement, accepted output updates drain before
2095
- the tool-result entry and terminal task record commit. Abort and close obey
2564
+ commit. During normal settlement the tool task stops its throttle and awaits the
2565
+ commit in flight; the terminal commit, which appends the result entry, is the
2566
+ final flush and settles any `details()` promise still pending. Abort and close obey
2096
2567
  invocation and Session admission gates: uncommitted buffered updates may be
2097
2568
  discarded, while admitted commits settle. Cancellation, callback, tracker
2098
2569
  preparation, and checkpoint failures occur before Storage admission and do not
@@ -2108,23 +2579,30 @@ not offered, and calls produce `tool_unavailable`) and is reported. A wrapper
2108
2579
  without a base contributes nothing. The composite supplies the declaration,
2109
2580
  argument validation, replay policy, and execution.
2110
2581
 
2111
- A tool call is accepted only if it was offered in the request's effective
2112
- system/tool history. A call to a tool that is not offered or has no registered
2113
- implementation produces the `tool_unavailable` error result described in section
2114
- 2.2 instead of failing the run. The tool task resolves the composed tool once
2115
- from its snapshot before argument validation and pins it until execution
2116
- settles, even across later snapshot refreshes. Arguments must satisfy both the offered
2117
- declaration and the pinned implementation's schema; they are validated before
2118
- and after `beforeTool` hooks.
2119
-
2120
- After hooks and validation, the tool task durably records the final call and
2582
+ A tool call is accepted only if its request offered the tool. Generation checks
2583
+ this against the tool set replayed from the committed model context through the
2584
+ request's `cutoff`; `beforeRequest` replacements do not change it. It answers a
2585
+ call to a tool it did not offer with the `tool_unavailable` result described in
2586
+ section 2.2, without a tool task. A configuration change after preparation does
2587
+ not affect this check (section 2.2). The tool task resolves the composed tool
2588
+ once from its phase snapshot and uses that implementation until execution
2589
+ settles: one phase handler resolves, validates, runs `beforeTool`, records intent,
2590
+ executes, and commits the result, so no phase boundary separates resolution from
2591
+ settlement. An unregistered implementation produces `tool_unavailable`.
2592
+ The implementation's `prepareArguments`, if any, first repairs the call's
2593
+ arguments, for example `edits` sent as a JSON string; the stored call keeps what
2594
+ the model sent. Arguments are validated against the resolved implementation's
2595
+ schema before and after `beforeTool`; a failure, or a throwing repair, produces an
2596
+ `invalid_arguments` error result.
2597
+
2598
+ After hooks and validation, the tool task durably records the final arguments and
2121
2599
  resolved replay policy before execution. Recovery does not rerun `beforeTool`
2122
2600
  and passes the same stored arguments to `execute()`. A replay-safe tool may
2123
2601
  reconstruct a submission from those arguments when that transformation is pure.
2124
2602
  Random values, timestamps, mutable document/configuration reads, or other derived
2125
2603
  inputs that must remain stable are first captured in a durable memo, checkpoint,
2126
- or task input. A background supervisor receives the final submission draft in
2127
- its own durable input so it can finish independently. Recovery does not let a
2604
+ or task input. A background reporter task receives the final message in its own
2605
+ durable input so it can finish independently. Recovery does not let a
2128
2606
  changed registry declaration alter the stored replay policy.
2129
2607
 
2130
2608
  A tool-acquired conversation handle accepts only input submissions; tools use
@@ -2132,7 +2610,7 @@ ordinary transaction writes for passive entries.
2132
2610
 
2133
2611
  A tool executes in a durable task. It may:
2134
2612
 
2135
- - publish bounded running output and details to its presentation document;
2613
+ - publish bounded running output and details to its `pi.live.tools` slot;
2136
2614
  - commit memos;
2137
2615
  - create and wait for tasks;
2138
2616
  - atomically create or fork explicitly owned conversations through `commit()`;
@@ -2159,54 +2637,75 @@ the same way; the admitted submission remains durable after those methods reject
2159
2637
  Invocation-owned document watches stop when the invocation ends.
2160
2638
 
2161
2639
  A foreground subagent conversation is explicitly owned by its tool task. One
2162
- transaction creates or forks the child and records its durable registry mapping;
2163
- after settlement the tool reacquires the child, submits with the registered
2164
- request ID, and waits for that submission's result. Aborting or abnormally
2165
- terminalizing the tool task cascades through the owned scope.
2166
-
2167
- A background subagent is provisioned in one transaction. After deduplicating by
2168
- its durable registry key, the tool stages a background supervisor task `B`, a
2169
- child conversation `C` explicitly owned by `B`, and the registry mapping from the
2170
- semantic name to `C` plus its stable request ID. `B` may be staged earlier in the
2171
- same transaction and used immediately as `C`'s owner. Its durable input contains
2172
- the exact submission draft and the registry location/key; it need not contain
2173
- `C`'s not-yet-created ID. Once scheduled, `B` resolves `C` from the mapping,
2174
- verifies the immutable owner edge, and performs normal `Conversation.submit()`.
2175
- A crash before admission makes `B` retry; a crash after admission returns the
2176
- existing request-ID-deduplicated `Submission`. The initiating tool may race the
2177
- same submit for lower latency and wait only for the durable admission receipt.
2178
- The supervisor may complete after setup: its terminal record retains
2179
- `background`, so ancestor ordinary abort and idle traversal continue to stop at
2180
- its owned scope.
2181
-
2182
- Applications may maintain a conversation document mapping semantic subagent
2183
- names to durable conversation IDs and application-minted submission request IDs.
2184
- Such a live registry uses `fork: "initial"` so children do not inherit the
2185
- parent's agent list and its update is not a selected fork source. Conversation
2186
- creation/forking, supervisor creation when applicable, and the registry mapping
2187
- commit atomically. The supervisor input, tool's durable final arguments, or a
2188
- durable checkpoint must retain enough information to reconstruct the exact
2189
- submission draft; the registry itself need not duplicate that payload. Initial
2190
- or later input then uses the registered stable request ID with the full
2191
- `Conversation.submit()` state machine. A crash before admission leaves a durable
2192
- request to submit; a crash after admission retries the same request and receives
2193
- the existing `Submission`. A read-only lookup by conversation/request ID can
2194
- report absence or return the durable queued/placed/done/unanswered receipt.
2195
- Submission admission therefore needs no private transaction shortcut and does
2196
- not move onto `Tx`. Kernel ownership indexes independently drive abort and idle
2197
- traversal.
2198
-
2199
- A tool result may request `addTools`, `terminate`, or `handoff`. Post-tools
2200
- applies added tool names to configured loadout, uses a final boundary for
2201
- terminate/handoff, and writes a headed handoff entry when requested.
2640
+ transaction creates or forks the child; the owner edge is its durable record, so
2641
+ a replay-safe rerun finds the child with `scanConversations({ ownerTaskId })`
2642
+ before creating one. After settlement the tool reacquires the child, submits
2643
+ with a request ID derived from its task ID, which a rerun keeps, and waits for
2644
+ that submission's result. Provider call IDs are not unique across a Session and
2645
+ must not serve as request IDs. Aborting or abnormally
2646
+ terminalizing the tool task cascades through the owned scope. The tool reports
2647
+ the child in its running details, for example `api.details({ conversationId })`,
2648
+ so a UI that sees the call can attach to the child's view or events.
2649
+
2650
+ A persistent background subagent outlives the parent's turns and can be
2651
+ messaged, steered, stopped, and listed later. One transaction, after
2652
+ deduplicating by the subagent's name in an application registry document,
2653
+ creates a background conversation-owned anchor task `A` in the parent, a child
2654
+ conversation `C` owned by `A`, and the registry mapping from the name to `C`.
2655
+ `A` completes at once; it holds `completing` while `C` has ordinary work
2656
+ (section 5.5) and then stays terminal. Its record keeps `background`, so the
2657
+ parent's ordinary abort and idle traversal stop at it, while
2658
+ `Conversation.abort(context, { background: true })` still reaches `C`'s work
2659
+ through it, and `Harness.waitForIdle()` does not wait for it. Later runs in `C`
2660
+ are ordinary work of `C`; a terminal owner never cascades.
2661
+
2662
+ Each message to a subagent is delivered by a background conversation-owned
2663
+ reporter task in the parent, created in the tool's transaction and keyed there
2664
+ by the tool task ID so a rerun of the call does not create another. The
2665
+ reporter submits to `C` with a request ID derived from its own task ID, waits
2666
+ for that submission's settlement, and posts the answer to the parent as an
2667
+ input with `whenBusy: "followUp"`, under another request ID derived from its
2668
+ task ID. A crash before either admission retries it; a crash after returns the
2669
+ existing `Submission`, so the child gets the message once and the parent gets
2670
+ the answer once. Submission admission therefore needs no private transaction
2671
+ shortcut and does not move onto `Tx`. A registry of names uses `fork:
2672
+ "initial"` so forks of the parent do not inherit it. A UI lists subagents from
2673
+ the registry; a child is working while its `pi.live.run` is set. Owner edges
2674
+ alone drive abort and idle traversal.
2675
+
2676
+ A tool result may request `addTools`, `terminate`, or `handoff`. The
2677
+ generation's `tools` phase (section 8.5) appends added tool names to the
2678
+ configured loadout; they take effect at the next preparation. The round
2679
+ terminates only when every result of the round requests `terminate`, as in the
2680
+ pi agent loop; the `tools` phase then uses a final boundary. Any
2681
+ `handoff` in the round, the last one in call order when several ask, ends the
2682
+ run the same way after appending a `pi.reset` entry with `head: "self"` and the
2683
+ handoff text as a user message (section 8.1), exactly what `reset(handoff)`
2684
+ writes.
2685
+
2686
+ A result's `usage` is stored on the tool-result message and added to the
2687
+ conversation's `pi.usage.tools[toolName]` in the result commit (section 8.6). A
2688
+ tool that runs an owned conversation must not report that conversation's spend
2689
+ again: the child's own `pi.usage` already counts it (section 12).
2202
2690
 
2203
2691
  On reopen, a tool reruns only when both its stored intent policy and the current
2204
2692
  registered declaration say `safe`. A current `unsafe` declaration may veto a
2205
2693
  stored-safe replay; a current-safe declaration never upgrades stored unsafe. A
2206
2694
  tool with no current registration is treated as `unsafe`. Every other orphaned
2207
2695
  effect produces an interrupted result containing the
2208
- durable partial output. Completed, failed, and aborted
2209
- tool terminal outcomes retain their tool-result entry ID for post-tools.
2696
+ durable partial output. Completed, failed, and aborted tool terminal outcomes
2697
+ retain their tool-result entry ID for the generation's `tools` phase.
2698
+
2699
+ Error results the Harness writes itself set `isError` and carry an `error`
2700
+ diagnostic with one of the codes `tool_unavailable`, `invalid_arguments`,
2701
+ `blocked`, `interrupted`, `aborted`, or `tool_error` (a throw from `execute()`)
2702
+ and the error text as its message. Their content is the durable partial output,
2703
+ if any, and `details` is the tool's last reported value, if any.
2704
+
2705
+ `@earendil-works/pi-durable/tools` provides `read`, `bash`, `edit`, and `write`
2706
+ factories, ported from the agent harness tools. They use only `api.env` and are
2707
+ registered like any other tool; nothing registers them automatically. `read`
2708
+ does not return images yet.
2210
2709
 
2211
2710
  ### 7.4 System prompt and dynamic tools
2212
2711
 
@@ -2254,8 +2753,9 @@ Generation preparation takes these steps against its phase snapshot:
2254
2753
  append one positional `pi.system` entry when they differ.
2255
2754
 
2256
2755
  Preparation does not recheck the transcript before appending: only the Harness
2257
- writes to a busy conversation, through submissions, run tasks, and boundaries,
2258
- so the transcript it read is still current (section 12).
2756
+ writes to a busy conversation, through submissions, run tasks, boundaries, and a
2757
+ blocking compaction, which appends only while its generation waits for it
2758
+ (section 8.7), so the transcript it read is still current (section 12).
2259
2759
 
2260
2760
  Model and thinking level are request options, not prompt state.
2261
2761
 
@@ -2303,7 +2803,7 @@ A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove
2303
2803
  previous `content` or restore section order merely by restating current values.
2304
2804
  Therefore, when a head removes the previous request-visible baseline, which is
2305
2805
  the case when the active context has a head marker and no `pi.system` entry
2306
- follows that marker, the new
2806
+ was appended after that marker (has a higher ID), the new
2307
2807
  `pi.system` entry adds `ContextEdit` omissions for every earlier `pi.system`
2308
2808
  entry still retained after the cut. Its own message is then a complete baseline
2309
2809
  containing every desired section in order and every effective tool declaration.
@@ -2313,10 +2813,16 @@ values, so every later preparation finds a `pi.system` entry after the marker.
2313
2813
  Head rebaselining takes precedence over ordinary order/value patching.
2314
2814
  Without a head cut, an order mismatch uses the two-entry remove/re-add sequence
2315
2815
  above; only when order already matches does preparation emit the minimal changed
2316
- values, `null` removals, and tool additions/removals. A changed tool declaration
2317
- is removed and re-added in the same message. When the offered tool order differs
2318
- from the desired order, the message removes every offered tool and re-adds the
2319
- desired tools in order.
2816
+ values and `null` removals.
2817
+
2818
+ Tool changes are planned separately and ride on the last planned entry, or on
2819
+ one entry of their own when sections are unchanged. Declarations are compared
2820
+ with pi-ai `declarationsEqual()` and written with `toToolDeclaration()`, so
2821
+ application metadata never enters the transcript. A changed declaration is
2822
+ removed and re-added in the same message. Replay keeps retained tools in place
2823
+ and appends additions; when that would not yield the desired order, the message
2824
+ removes every offered tool and re-adds the desired tools in order. A head
2825
+ rebaseline adds every desired tool.
2320
2826
 
2321
2827
  The rendered strings stored in historical `SystemMessage.sections` remain
2322
2828
  authoritative even if the current renderer changes. Pi-ai decides whether to
@@ -2359,8 +2865,7 @@ The initial implementation provides:
2359
2865
  |---|---|
2360
2866
  | `pi.generation` | prepare system/loadout, request or poll model, retry, classify response |
2361
2867
  | `pi.tool` | validate, hook, execute, persist output and details, append result |
2362
- | `pi.post-tools` | wait for tools, apply controls, run boundary, continue generation |
2363
- | `pi.collapse` | select a transcript range, summarize, append a headed summary |
2868
+ | `pi.compaction` | select a transcript range, summarize, place a headed summary |
2364
2869
 
2365
2870
  Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
2366
2871
  resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
@@ -2376,23 +2881,29 @@ to normal interrupted/aborted transcript entries, clears presentation state,
2376
2881
  and then retries or terminates according to the task phase. Retry deadlines,
2377
2882
  attempts, compaction, and tool progress are current document state for late
2378
2883
  joiners; completed-attempt usage/accounting is an entry or terminal detail.
2379
- Bounded output records whether content was truncated and any retained file path.
2884
+ Truncation and spill paths are tool diagnostics (section 7.3).
2380
2885
 
2381
2886
  Compaction changes model context by appending a summary entry with a head. It
2382
2887
  does not delete transcript history.
2383
2888
 
2384
2889
  ### 8.1 Built-in entries
2385
2890
 
2386
- Built-in entry kinds carry no `data`; each is exported as an `Entry` token.
2891
+ Built-in entry kinds carry no `data`, except `pi.tool-result`, whose token is
2892
+ `Entry<{ diagnostics: ToolDiagnostic[] }>`; every tool result carries `data`,
2893
+ with an empty list when it has no diagnostics (section 7.3), and
2894
+ `pi.compaction`, whose token is `Entry<{ reason: CompactionReason }>`. Each kind
2895
+ is exported as an `Entry` token.
2387
2896
 
2388
2897
  | kind | `model` | written by |
2389
2898
  |---|---|---|
2390
- | `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions |
2899
+ | `pi.user` | `[UserMessage]`, timestamp from the Harness clock at admission or placement | submissions, `onYield` continuations |
2391
2900
  | `pi.assistant` | `[AssistantMessage]` with any stop reason | generation |
2392
2901
  | `pi.system` | `[SystemMessage]` with `content: ""` (section 7.4) | generation preparation |
2393
- | `pi.tool-result` | `[ToolResultMessage]` | tool tasks |
2902
+ | `pi.tool-result` | `[ToolResultMessage]` | tool tasks; generation for calls to tools its request did not offer |
2903
+ | `pi.reset` | absent, or `[UserMessage]` with the handoff text; always `head: "self"` | `reset()`, generation `tools` phase for `handoff` |
2904
+ | `pi.compaction` | `[UserMessage]` with the wrapped summary; `head` is the first kept entry | compaction tasks (section 8.7) |
2394
2905
 
2395
- Every provider result becomes a `pi.assistant` entry: answers, failed attempts
2906
+ Every generation response becomes a `pi.assistant` entry: answers, failed attempts
2396
2907
  with their error text and usage, and converted partials with stop reason
2397
2908
  `aborted`. Context derivation (section 2.1, rule 9) keeps failed and aborted
2398
2909
  messages out of later requests, so no separate usage or notice kind exists.
@@ -2413,6 +2924,39 @@ type LiveState = {
2413
2924
  /** Provider-side deferred response being polled. */
2414
2925
  deferred?: { pollAt: number };
2415
2926
  };
2927
+ /** The current tool round in call order, from the tool-calling answer until the generation's `tools` phase ends it. */
2928
+ tools?: {
2929
+ callId: string;
2930
+ name: string;
2931
+ /**
2932
+ * Absent for a call not started yet (sequential round, `pending`) and for a call its request did not offer,
2933
+ * which starts `done` with the `entry` generation wrote.
2934
+ */
2935
+ taskId?: TaskId;
2936
+ status: "pending" | "running" | "done";
2937
+ /** Retained running output and what the bounds dropped. */
2938
+ output?: string;
2939
+ droppedBytes?: number;
2940
+ droppedLines?: number;
2941
+ /** Last `details()` value. */
2942
+ details?: JsonValue;
2943
+ /** Diagnostics recorded through `api.diagnostic()`. */
2944
+ diagnostics?: ToolDiagnostic[];
2945
+ /** Result entry once done; absent when the tool task faulted or was orphaned. */
2946
+ entry?: EntryId;
2947
+ }[];
2948
+ /** Live compaction tasks in task ID order; absent when none (section 8.7). */
2949
+ compactions?: CompactionStatus[];
2950
+ };
2951
+
2952
+ type CompactionStatus = {
2953
+ taskId: TaskId<CompactionResult>;
2954
+ reason: CompactionReason;
2955
+ /** Whether a generation waits for it: a compaction the generation owns. */
2956
+ blocking: boolean;
2957
+ attempt: number;
2958
+ /** Durable backoff before the next summarization attempt. */
2959
+ retry?: { at: number; error: string };
2416
2960
  };
2417
2961
  ```
2418
2962
 
@@ -2422,18 +2966,39 @@ type LiveState = {
2422
2966
  | version | `1` |
2423
2967
  | scope/history/fork | conversation, `latest`, `initial` |
2424
2968
  | `initial()` | `{}` |
2425
- | checkpoint | complete base whenever `generation` is absent |
2969
+ | checkpoint | complete base whenever nothing runs: `generation` absent and no `running` tool slot |
2426
2970
  | view mount | `docs["pi.live"]` |
2427
2971
  | created | with every Harness conversation (section 2.2) |
2428
2972
 
2429
- Nothing is in flight at every turn boundary and while idle, so the stored delta
2430
- chain spans at most one generation, including its retries and deferred polls, or
2431
- one tool round, and each base is small. Tool progress adds its own condition with the tool task.
2432
-
2433
- Tool progress and compaction status join this document with the tool and
2434
- collapse tasks. Partials are normalized to strict JSON before assignment. Every
2435
- terminal path of a run task removes `run` and `generation` in the commit that
2436
- settles the run's inputs. `tx.settleSubmission()` stages each input's new status
2973
+ Nothing runs while idle, at a final boundary, in the commit where generation
2974
+ hands over to its tool round (every slot is still `pending` or `done`), between
2975
+ the tools of a sequential round, and whenever no tool of a parallel round is
2976
+ `running`. A slot holds output only while `running`, so every base is small, and
2977
+ the stored delta chain spans at most one generation, including its retries and
2978
+ deferred polls, or the overlapping execution of one round's tools. Retained head output grows by Chord string appends. A sliding tail
2979
+ usually becomes a front trim plus an append; when Chord's bounded overlap search
2980
+ finds no shared part, as with highly repetitive output or a window larger than
2981
+ its 64 KiB scan, the commit writes the retained window as one set. Either way
2982
+ each commit writes at most one window, and the throttle bounds the rate.
2983
+
2984
+ Generation creates `tools` with one `pending` slot per call in the commit that
2985
+ appends the tool-calling answer; a call its request did not offer starts `done`
2986
+ with its result entry. A tool task sets its slot `running` in its intent commit,
2987
+ publishes throttled output and details into it, and in its terminal commit sets
2988
+ `done` and `entry` and removes `output`, `droppedBytes`, `droppedLines`,
2989
+ `details`, and `diagnostics`, which the result entry now carries (section 7.3).
2990
+ The generation's `tools` phase removes `tools`. Slot updates apply only while a slot with the task's
2991
+ `taskId` exists; without one, the durable partial output, details, and
2992
+ diagnostics are empty.
2993
+
2994
+ A compaction's status is added in the commit that creates the task and removed
2995
+ in the commit that decides its outcome: the task's own outcome commit, even when
2996
+ that outcome holds as `completing` (section 5.5), or the scheduler's cleanup
2997
+ (section 5.4).
2998
+ It stays small: the summary text lives in the task's placement, never here. Partials are
2999
+ normalized to strict JSON before assignment. Every terminal path of a run task
3000
+ removes `run`, `generation`, and `tools` in the commit that settles the run's
3001
+ inputs. `tx.settleSubmission()` stages each input's new status
2437
3002
  and resolves the transaction's latest candidate submission record, falling back
2438
3003
  to committed state, during assembly, like task-document validation (section 3.3),
2439
3004
  so it is not a caller table read and works after the commit's first table write.
@@ -2443,18 +3008,44 @@ so it is not a caller table read and works after the commit's first table write.
2443
3008
  ```ts
2444
3009
  type GenerationInput = {};
2445
3010
  type GenerationCheckpoint =
2446
- | { phase: "prepare"; attempt: number }
3011
+ | {
3012
+ phase: "prepare";
3013
+ attempt: number;
3014
+ /** The blocking compaction this generation waited for; it starts no other compaction. */
3015
+ compacted?: TaskId<CompactionResult>;
3016
+ /** Error text of the overflow that started `compacted`; checked once when `prepare` resumes. */
3017
+ overflow?: string;
3018
+ }
2447
3019
  | {
2448
3020
  phase: "request";
2449
3021
  attempt: number;
3022
+ compacted?: TaskId<CompactionResult>;
2450
3023
  model: ModelRef;
2451
3024
  thinkingLevel: ModelThinkingLevel;
2452
3025
  streamOptions: ConversationStreamOptions;
3026
+ toolExecution: ToolExecutionMode;
2453
3027
  /** Newest entry included in the request. */
2454
3028
  cutoff: EntryId;
2455
3029
  }
2456
- | { phase: "retry"; attempt: number; until: number }
2457
- | { phase: "poll"; attempt: number; model: ModelRef; handle: DeferredHandle; pollAt: number };
3030
+ | { phase: "retry"; attempt: number; compacted?: TaskId<CompactionResult>; until: number }
3031
+ | {
3032
+ phase: "poll";
3033
+ attempt: number;
3034
+ compacted?: TaskId<CompactionResult>;
3035
+ model: ModelRef;
3036
+ toolExecution: ToolExecutionMode;
3037
+ cutoff: EntryId;
3038
+ handle: DeferredHandle;
3039
+ pollAt: number;
3040
+ }
3041
+ | {
3042
+ /** Waiting on the round's tool tasks; `waiting` holds this checkpoint (section 8.5). */
3043
+ phase: "tools";
3044
+ assistant: EntryId;
3045
+ tools: TaskId<ToolTaskResult>[];
3046
+ /** Calls of a sequential round not started yet, in call order. */
3047
+ pending: string[];
3048
+ };
2458
3049
  type GenerationResult = { entryId: EntryId };
2459
3050
  ```
2460
3051
 
@@ -2463,9 +3054,35 @@ The run's inputs live in `pi.live.run`, not in the task input.
2463
3054
 
2464
3055
  - `prepare` runs section 7.4 against the committed configuration. When no model
2465
3056
  is configured or `models.getModel()` does not know it, the task fails with
2466
- `no_model`. Otherwise one commit appends the planned `pi.system` entries and
3057
+ `no_model`. When it resumes with `overflow` and its compaction did not
3058
+ complete with an `entryId`, the run fails with `model_error` and the overflow
3059
+ text as detail. Otherwise one commit appends the planned `pi.system` entries and
2467
3060
  moves to `request` with the new tail as `cutoff` and the configuration's model,
2468
- thinking level, and stream options.
3061
+ thinking level, stream options, and tool execution mode. These stay fixed for
3062
+ this request attempt; a retry prepares again. `compacted` carries over to
3063
+ `request`, `retry`, `poll`, and the next `prepare`.
3064
+ - Before that commit, `prepare` checks the compaction thresholds (section 8.7)
3065
+ when the conversation's compaction policy is enabled, the model's
3066
+ `contextWindow` is positive, and `compacted` is absent. The estimate starts at
3067
+ the newest assistant message in the committed model context whose entry was
3068
+ appended after the head marker (all qualify without one) and whose usage is
3069
+ nonzero: pi-ai `calculateContextTokens()` of its usage, plus pi-ai
3070
+ `estimateMessageTokens()` of every context message after it and of the planned
3071
+ system messages. Its request included the marker, because a head is placed
3072
+ only while no request is in flight. Without such a message, every message is
3073
+ estimated. Both thresholds apply only when range selection finds a cut.
3074
+ - Above `contextWindow - reserveTokens`, the blocking threshold: instead of
3075
+ appending, one commit creates a compaction owned by the generation with reason
3076
+ `threshold`, adds its status, and commits `waiting` on it with `allSettled`
3077
+ and checkpoint `{ phase: "prepare", attempt, compacted }`. Whatever its
3078
+ outcome, `prepare` then runs again and sends the request.
3079
+ - Above `contextWindow - reserveTokens - backgroundTokens` with
3080
+ `backgroundTokens > 0` and no compaction status in `pi.live`: the commit that
3081
+ moves to `request` also creates a background compaction, owned by the
3082
+ conversation, with reason `threshold`, and adds its status. The generation
3083
+ does not wait for it. The retry policy is read when the
3084
+ attempt's result is classified, because it governs the next attempt (section
3085
+ 2.2).
2469
3086
  - `request` and `poll` resolve the checkpoint's model through
2470
3087
  `models.getModel()`; an unknown model fails the task with `no_model`, like
2471
3088
  `prepare`. `request` converts a leftover partial (below) before it resolves
@@ -2474,15 +3091,37 @@ The run's inputs live in `pi.live.run`, not in the task input.
2474
3091
  interrupted attempt into an aborted `pi.assistant` entry. It then streams the
2475
3092
  model context through `cutoff` with the invocation signal, the thinking level
2476
3093
  as `reasoning` (omitted for `off`), and the pinned `streamOptions`,
2477
- committing throttled partials. Recovery resends the same messages with the
2478
- same pinned model, thinking level, and stream options.
3094
+ committing throttled partials. Before streaming, the `beforeRequest` chain may
3095
+ replace the messages for this request only. Recovery resends the same
3096
+ committed messages with the same pinned model, thinking level, and stream
3097
+ options, and reruns `beforeRequest`.
3098
+ - `afterResponse` observes every terminal message, from `request` or `poll`,
3099
+ before classification; a still deferred result is not terminal.
2479
3100
  - Before classifying, the handler stops the partial throttle and awaits any
2480
3101
  partial commit in flight, so no stale partial lands after the outcome. The
2481
3102
  terminal message is classified in one commit that also clears the partial:
2482
- - `stop`/`length`: append the answer, settle the run's inputs `done`, remove
2483
- `run` and `generation`, and complete with `{ entryId }`. The same commit
2484
- applies the final boundary (section 6).
2485
- - `toolUse`: append the assistant entry and continue through the tool chain.
3103
+ - `stop`/`length`: before the commit, the `onYield` chain runs; the first
3104
+ `{ continue }` wins. The commit appends the answer and applies the final
3105
+ boundary (section 6). With a continuation and no selected user item or
3106
+ reset, it appends a `pi.user` entry with the continuation content, creates a
3107
+ successor generation, and hands it `pi.live.run`, keeping the inputs open.
3108
+ Otherwise it settles the run's inputs `done`, removes `run` and
3109
+ `generation`, and starts a successor run for the selected user items, if
3110
+ any; a dropped continuation is not retried. Both complete with
3111
+ `{ entryId }`.
3112
+ - `toolUse` with at least one tool call: the commit appends the assistant
3113
+ entry and starts the tool round described below, moving to `waiting` in the
3114
+ `tools` phase. A `toolUse` message without calls is classified like `stop`.
3115
+ - `error` that pi-ai `isContextOverflow()` recognizes, while the compaction
3116
+ policy is enabled, `compacted` is absent, and range selection finds a cut:
3117
+ the commit appends the error entry, removes `generation`, creates a
3118
+ compaction owned by the generation with reason `overflow`, adds its status,
3119
+ and commits `waiting` on it with `allSettled` and checkpoint `{ phase:
3120
+ "prepare", attempt, compacted, overflow }`; the recovery request does not
3121
+ count against the retry policy. Any other overflow,
3122
+ including a second one, is never retried and fails like the non-retryable
3123
+ errors below. A `stop` or `length` response is never classified as overflow;
3124
+ the next threshold check handles silent overflow.
2486
3125
  - `error` that `isRetryableAssistantError()` accepts while the conversation's
2487
3126
  retry policy allows another attempt (`enabled` and `attempt <= maxRetries`,
2488
3127
  so `maxRetries` counts retries after the first attempt): append the error entry and move to
@@ -2500,10 +3139,294 @@ The run's inputs live in `pi.live.run`, not in the task input.
2500
3139
  known (a failure is reported), converts a committed partial, settles the inputs `unanswered` with
2501
3140
  `aborted`, removes `run` and `generation`, and ends `aborted`.
2502
3141
 
3142
+ A tool round starts in the commit that appends the tool-calling answer:
3143
+
3144
+ 1. The offered tools are replayed with pi-ai `getCurrentTools()` from the
3145
+ committed model context through `cutoff`: `request` already holds it, and
3146
+ `poll` derives it again. A call to a tool not offered gets its
3147
+ `tool_unavailable` result entry here, without a task.
3148
+ 2. Every other call gets a `pi.tool` task owned by the generation, with input
3149
+ `{ assistant, callId }`. The round is sequential when the pinned
3150
+ `toolExecution` is `sequential` or any called tool's registration in the phase
3151
+ snapshot has `executionMode: "sequential"`: then only the first call gets its
3152
+ task now and the rest wait in `pending`. Otherwise every call gets its task at
3153
+ once and they run in parallel. The checkpoint's `tools` lists the tasks
3154
+ created so far and grows by one per started sequential call, while
3155
+ `pending` shrinks.
3156
+ 3. The generation commits `waiting` on its tool tasks with `allSettled` and the
3157
+ `tools` checkpoint (section 8.5); `pi.live.run` stays with it.
3158
+ 4. `pi.live.tools` receives the round's slots (section 8.2), and `generation` is
3159
+ removed.
3160
+
2503
3161
  Input submissions settle `unanswered` with one of these reasons: `no_model`,
2504
3162
  `model_error` (detail: provider error text), `aborted`, `faulted` (detail: error
2505
3163
  message), or an orphaning blocked reason (section 5.4). Fault and orphan
2506
- settlement discard a committed partial without writing a transcript entry.
3164
+ settlement convert a committed partial into an aborted `pi.assistant` entry,
3165
+ like the abort handler.
3166
+
3167
+ ### 8.4 Tool
3168
+
3169
+ ```ts
3170
+ type ToolTaskInput = { assistant: EntryId; callId: string };
3171
+ type ToolTaskCheckpoint =
3172
+ | { phase: "call" }
3173
+ | { phase: "execute"; arguments: JsonObject; replay: "safe" | "unsafe" };
3174
+ type ToolTaskResult = { entryId: EntryId; control?: ToolControl };
3175
+ ```
3176
+
3177
+ `pi.tool` is version 1 and starts at `{ phase: "call" }`. The input stays small
3178
+ because the terminal record keeps it; the call is read from the assistant entry.
3179
+
3180
+ - `call` reads the call with `runtime.entry()`, resolves the composed tool from
3181
+ its phase snapshot, validates the arguments with pi-ai
3182
+ `validateToolArguments()`, runs the `beforeTool` chain, and validates again
3183
+ (section 7.3). One commit then records intent: it moves to `execute` with the
3184
+ final arguments and the tool's replay policy and sets the slot `running`. The
3185
+ same handler executes the tool, runs the `afterTool` chain, and commits the
3186
+ result. An unregistered tool, invalid arguments, or a block commits the
3187
+ corresponding error result instead, without intent.
3188
+ - `execute` is reached only by recovery. When both the stored and the currently
3189
+ registered policy are `safe`, it executes again with the stored arguments,
3190
+ without `beforeTool`, and settles like `call`. Otherwise it commits an
3191
+ `interrupted` error result from the slot's durable partial output, details, and
3192
+ diagnostics, and ends `failed` with `{ entryId }`.
3193
+ - The result commit bounds the content, appends the diagnostics block (section
3194
+ 7.3), appends one `pi.tool-result` entry with `model: [{ role: "toolResult",
3195
+ toolCallId, toolName, content, details, isError, timestamp }]` and
3196
+ `data: { diagnostics }`, marks the slot `done`, and
3197
+ completes with `{ entryId, control }`. An `isError` result still completes.
3198
+ - A throw from `execute()` becomes a `tool_error` result and the task ends
3199
+ `failed` with `{ entryId }`; once the invocation is signalled it propagates
3200
+ instead. Ending `failed`, like `aborted`, records cancellation intent (section
3201
+ 5.4), so conversations the call owns, which nothing supervises any more, are
3202
+ aborted. A returned `isError` result still completes, and its call's owned work
3203
+ survives. Either way the run continues with the result entry. A tool task whose
3204
+ owned conversations still have ordinary work when it commits its result holds
3205
+ `completing` with the result entry already written (section 5.5); the
3206
+ generation resumes only when the tool task is terminal.
3207
+ - The abort handler commits an `aborted` error result from the slot's durable
3208
+ partial output, details, and diagnostics and ends `aborted` with `{ entryId }`.
3209
+
3210
+ ### 8.5 Tool rounds
3211
+
3212
+ The generation resumes in its `tools` phase once every tool task it waits on is
3213
+ terminal. In a sequential round with calls left in `pending`, one commit creates
3214
+ the next call's owned tool task and waits on it again with the shorter `pending`.
3215
+ Otherwise it reads the tool records with `runtime.getTask()` and the round's
3216
+ result entries from the `pi.live.tools` slots, runs the `afterTools` observers,
3217
+ and then commits once:
3218
+
3219
+ - It appends every `addTools` name not already active to the configured
3220
+ loadout; the next preparation offers it.
3221
+ - When every result of the round requests `terminate`, or any requests
3222
+ `handoff`, it appends the handoff's `pi.reset` entry, if any, settles the
3223
+ run's inputs `done` with the tool-calling answer, removes `run` and `tools`,
3224
+ and applies the final boundary.
3225
+ - Otherwise it removes `tools` and applies the `postTools` boundary. When that
3226
+ boundary placed a reset, the run's inputs settle `unanswered` with `reset` and
3227
+ selected user items start a successor run (section 6). Otherwise selected
3228
+ steer IDs join `pi.live.run`, and it creates the next generation, owned by the
3229
+ conversation, and hands it the run.
3230
+
3231
+ It completes with `{ entryId }` of the tool-calling answer. Its abort handler
3232
+ runs only after its tool tasks are terminal (section 5.5). For every call still
3233
+ in `pending`, which never started, it appends an `aborted` error result; a
3234
+ started call whose task faulted or was orphaned keeps
3235
+ its `done` slot without an entry (section 8.2). It then settles the inputs
3236
+ `unanswered` with `aborted`, removes `run`, `generation`, and `tools`, and ends
3237
+ `aborted`. `abortTask()` on the generation therefore aborts its whole round.
3238
+
3239
+ ### 8.6 Usage
3240
+
3241
+ ```ts
3242
+ type UsageState = {
3243
+ /** Assistant entries and compaction summarization attempts, keyed `provider/modelId`. */
3244
+ models: Record<string, Usage>;
3245
+ /** Tool results, keyed by tool name; their usage has no model identity. */
3246
+ tools: Record<string, Usage>;
3247
+ };
3248
+ ```
3249
+
3250
+ | field | value |
3251
+ |---|---|
3252
+ | kind | `pi.usage` |
3253
+ | version | `1` |
3254
+ | scope/history/fork | conversation, `latest`, `initial` |
3255
+ | `initial()` | `{ models: {}, tools: {} }` |
3256
+ | checkpoint | complete base on every change |
3257
+ | view mount | `docs["pi.usage"]` |
3258
+ | created | with every Harness conversation (section 2.2) |
3259
+
3260
+ `pi.usage` is the ledger of the conversation's own spend. Every built-in writer
3261
+ of a `pi.assistant` entry adds its message's `usage` to `models` under the
3262
+ message's own `provider/model`, and every writer of
3263
+ a `pi.tool-result` entry with `usage` adds it to `tools`, in the same commit.
3264
+ Every summarization attempt of a compaction task adds its response's `usage` to
3265
+ `models` in the commit that classifies it (section 8.7); that spend has no entry,
3266
+ whether the summary is placed, fails, or ends stale. Failed and aborted attempts
3267
+ count. A fork starts at zero, so no spend is counted
3268
+ twice. `Harness.usage()` sums every conversation's document into the Session
3269
+ total; other totals, such as an ownership subtree, are application sums over
3270
+ `scanConversations()`. Nothing stores a total across conversations.
3271
+
3272
+ ### 8.7 Compaction
3273
+
3274
+ ```ts
3275
+ type CompactionReason = "manual" | "threshold" | "overflow";
3276
+ type CompactionInput = { reason: CompactionReason; instructions?: string };
3277
+ /** The pinned summarization request. */
3278
+ type SummaryRequest = {
3279
+ attempt: number;
3280
+ model: ModelRef;
3281
+ thinkingLevel: ModelThinkingLevel;
3282
+ streamOptions: ConversationStreamOptions;
3283
+ maxTokens: number;
3284
+ /** Newest entry of the context the range was selected from. */
3285
+ tail: EntryId;
3286
+ /** First entry kept verbatim; the summary's `head`. */
3287
+ firstKept: EntryId;
3288
+ };
3289
+ type CompactionCheckpoint =
3290
+ | { phase: "select" }
3291
+ | ({ phase: "summarize" } & SummaryRequest)
3292
+ | ({ phase: "retry"; until: number } & SummaryRequest);
3293
+ /**
3294
+ * `entryId` of a blocking compaction's summary, or the `submissionId` of a conversation-owned compaction's summary
3295
+ * write; both absent when nothing was compacted.
3296
+ */
3297
+ type CompactionResult = { entryId?: EntryId; submissionId?: SubmissionId };
3298
+ ```
3299
+
3300
+ `pi.compaction` is version 1 and starts at `{ phase: "select" }`. Compaction
3301
+ replaces an old prefix of the model context with a summary entry whose `head` is
3302
+ the first kept entry (section 2.1). Raw history stays in storage. There are three
3303
+ ways to start one; the task is the same, only ownership and placement differ:
3304
+
3305
+ | started by | owner | background | generation waits | placement |
3306
+ |---|---|---|---|---|
3307
+ | `Conversation.compact()` | conversation | no | no | write submission |
3308
+ | generation above the background threshold | conversation | yes | no | write submission |
3309
+ | generation above the blocking threshold, or on overflow | the generation | no | yes | direct append |
3310
+
3311
+ Only a blocking compaction runs while the conversation's run waits for it. The
3312
+ others never take run control: the conversation keeps working while they
3313
+ summarize, and their summary is placed like any passive write. Compactions do
3314
+ not coordinate with each other; several may run at once, and section 6 decides
3315
+ between their summaries.
3316
+
3317
+ **Range selection** is a pure function of a `ContextView`, whose
3318
+ `contributions` give each active entry's model messages after every edit in the
3319
+ range (section 2.1, rules 4 and 9), including edits carried by older in-range
3320
+ markers, and `keepRecentTokens`.
3321
+
3322
+ 1. Cut candidates are the non-marker entries whose contribution begins with a
3323
+ user or assistant message. Tool results and system entries are never
3324
+ candidates, so a kept assistant message keeps its tool results. A user entry
3325
+ is not a candidate either while a result for a call of the assistant before
3326
+ it follows it, before the next assistant (section 2.1, rule 7).
3327
+ 2. Walk the non-marker entries from newest to oldest, adding pi-ai
3328
+ `estimateMessageTokens()` of each contribution. At the first entry where the
3329
+ sum reaches `keepRecentTokens`, the cut is the first candidate at or after
3330
+ that entry in transcript order, or the newest candidate when none follows.
3331
+ 3. There is nothing to compact when the sum never reaches `keepRecentTokens`, or
3332
+ when no non-marker entry before the cut contributes a model message.
3333
+
3334
+ The summarized entries are the head marker, if any, followed by the non-marker
3335
+ entries before the cut. Their messages are their contributions, ordered as in
3336
+ section 2.1, rules 7 and 8, so an earlier summary is summarized again together
3337
+ with the history after it. Nothing is compacted while the context is smaller
3338
+ than `keepRecentTokens`, so with a window where `contextWindow - reserveTokens`
3339
+ is below it, overflow may come before any threshold compaction.
3340
+
3341
+ ```text
3342
+ 1 user 2 assistant (read) 3 tool result, 30k tokens 4 assistant 5 user 6 assistant
3343
+ keepRecentTokens 20k: the walk from 6 reaches 20k at 3; the first candidate at or after 3 is 4.
3344
+ The summary covers 1-3; the model context becomes [summary, 4, 5, 6].
3345
+ ```
3346
+
3347
+ Phases:
3348
+
3349
+ - `select` reads the committed configuration and captures the committed
3350
+ context. When no model is configured or `models.getModel()` does not know it,
3351
+ the task fails with `no_model`. With nothing to compact it completes with
3352
+ `{}`. Otherwise the `beforeCompact` hooks (section 7.2) run with the
3353
+ summarized entries and messages; the first decision wins. `{ decline: true }` completes
3354
+ with `{}`, and `{ summary }` is placed as below in the same commit. Without a
3355
+ decision, one commit moves to `summarize` with attempt 1, the configured model,
3356
+ thinking level, and stream options, `maxTokens` = `min(floor(0.8 *
3357
+ reserveTokens), model.maxTokens)` (the model's value only when positive), the
3358
+ captured tail, and the cut as `firstKept`.
3359
+ - `summarize` derives the summarized messages again from the context at `tail`,
3360
+ which is immutable, and serializes them to text: `[User]: ...`,
3361
+ `[Assistant thinking]: ...`, `[Assistant]: ...`,
3362
+ `[Assistant tool calls]: name(key=json, ...)`, and `[Tool result]: ...`
3363
+ truncated to 2000 characters; system messages are omitted. The request has no
3364
+ tools and two messages: a system message with the built-in summarization
3365
+ system prompt, and a user message with the serialized text in
3366
+ `<conversation>` tags followed by the built-in summarization prompt, and
3367
+ `Additional focus: <instructions>` when the input has instructions. It uses the
3368
+ pinned thinking level as `reasoning`, and the pinned stream options without
3369
+ `deferred`, with `cacheRetention: "none"` and the pinned `maxTokens`.
3370
+ Recovery resends the same request. The response is classified in one commit
3371
+ that adds its usage to `pi.usage` (section 8.6):
3372
+ - `stop` with non-empty text and no tool call: the text is the summary, placed
3373
+ as below.
3374
+ - `error` that `isRetryableAssistantError()` accepts while the conversation's
3375
+ retry policy allows another attempt: move to `retry` with `until = now +
3376
+ retryDelayMs(policy, attempt)`, and set the status's `attempt` and `retry`.
3377
+ - anything else, including a `length` stop, which leaves the summary
3378
+ incomplete: fail with `model_error`.
3379
+ - `retry` sleeps until `until`, then returns to `summarize` with the next attempt
3380
+ and the same pinned request.
3381
+
3382
+ The built-in prompts are the coding agent's structured checkpoint format. The
3383
+ prompt also asks the model to carry forward an earlier summary at the start of
3384
+ the conversation.
3385
+
3386
+ **Placement.** The summary entry is
3387
+
3388
+ ```ts
3389
+ {
3390
+ kind: "pi.compaction",
3391
+ head: firstKept,
3392
+ model: [{ role: "user", content: [{ type: "text", text: wrapped }], timestamp: now }],
3393
+ data: { reason },
3394
+ }
3395
+ ```
3396
+
3397
+ where `wrapped` is `"The conversation history before this point was compacted
3398
+ into the following summary:\n\n<summary>\n"`, the summary, and
3399
+ `"\n</summary>"`. The commit that places it also removes the task's status and
3400
+ commits the task's `completed` outcome:
3401
+
3402
+ - A blocking compaction appends the entry directly and completes with
3403
+ `{ entryId }`. Its generation holds the run and waits, so nothing else writes
3404
+ the conversation.
3405
+ - A conversation-owned compaction admits the entry as a write submission with
3406
+ request ID `compaction:<taskId>`, following section 6 exactly: in an idle
3407
+ conversation with an empty inbox it is appended at once or settles `stale`;
3408
+ in an idle conversation with queued items it queues and a final boundary runs;
3409
+ in a busy one it queues for the next boundary. It completes with
3410
+ `{ submissionId }` and never waits for a queued placement;
3411
+ `Harness.submission()` observes the placement.
3412
+
3413
+ The next generation's preparation finds a head marker without a later
3414
+ `pi.system` entry and writes a complete system baseline (section 7.4).
3415
+
3416
+ **Cancellation.** The abort handler removes the task's status and ends
3417
+ `aborted`; it writes no entry. An attempt cut short by abort or a crash has no
3418
+ committed response, so its usage is not counted. A manual compaction is
3419
+ ordinary work of its conversation: `Conversation.abort()` aborts it. A
3420
+ background compaction survives it and stops only through `abortTask()` or
3421
+ `Conversation.abort(context, { background: true })`. A blocking compaction is
3422
+ aborted with its generation, before the generation's abort handler runs
3423
+ (section 5.5). A summary that is already queued is a write submission:
3424
+ conversation abort keeps it, and `Submission.abort()` withdraws it.
3425
+
3426
+ When a blocking compaction ends without an `entryId`, because it found nothing
3427
+ to compact, was declined, failed, faulted, or was aborted directly, a threshold
3428
+ generation sends its request anyway and an overflow generation fails with
3429
+ `model_error` (section 8.3).
2507
3430
 
2508
3431
 
2509
3432
  ## 9. Document observation and Chord
@@ -2662,75 +3585,196 @@ rejection is observed.
2662
3585
 
2663
3586
  ### 9.3 Conversation view
2664
3587
 
2665
- The public view is a fixed structural mount of selected built-in documents:
3588
+ The public view is a fixed structural mount of the conversation's active
3589
+ transcript and its built-in documents:
2666
3590
 
2667
3591
  ```ts
2668
3592
  type ConversationView = {
2669
3593
  readonly conversation: ConversationRecord;
3594
+ /** Raw active entries, as `ContextView.entries` (section 2.1): the head marker, then the non-head entries from its head. */
2670
3595
  readonly entries: readonly EntryRecord[];
3596
+ /** `pi.conversation.config`, `pi.live`, `pi.inbox`, and `pi.usage`, keyed by kind; absent documents are absent. */
2671
3597
  readonly docs: Readonly<Record<string, JsonObject>>;
2672
3598
  };
2673
3599
  ```
2674
3600
 
2675
- The concrete built-in document IDs and fields are public protocol once their
2676
- implementation layer is approved. Third-party documents are initially exposed
2677
- through their own Chord services, not automatically mounted.
3601
+ These kinds and their fields are public protocol. Third-party documents are
3602
+ exposed through their own Chord services, not mounted.
2678
3603
 
2679
- The mount consumes one complete Session commit and publishes one Chord batch:
3604
+ The Harness keeps at most one mount per conversation. The first `viewState()`,
3605
+ `watch()`, or event attachment builds its revision on the Session line from the
3606
+ committed active transcript and documents; the mount is dropped when its last
3607
+ observer detaches. A mount derives each next revision from the Session's
3608
+ `subscribeCommits()` publications, like `watchDoc()`: every publication is
3609
+ already durable, so the view shows only committed state. One publication that
3610
+ touches the conversation yields one Chord batch:
2680
3611
 
2681
3612
  ```text
2682
3613
  document op ["s", ["generation", "message"], value]
2683
3614
  -> view op ["s", ["docs", "pi.live", "generation", "message"], value]
2684
3615
  ```
2685
3616
 
2686
- Entry appends/head changes and every changed mounted document are included in
2687
- the same publication. Before Storage admission, the Session derives the mounted
2688
- operation batch and prepares each affected loaded mount's next immutable revision
2689
- with the optimized immutable applier. Failure rolls back normally. After Storage
2690
- success, finalization only installs the prepared mount pointers/cursors and
2691
- enqueues publication. Mounted document revisions may be structurally shared
2692
- because they obey the same trusted immutability contract. The mount performs no
2693
- semantic projection and owns no second persistence authority. A Chord adapter
2694
- assigns a contiguous in-memory delivery sequence per view source lifetime.
2695
-
2696
- `Conversation.viewState()` exposes the mount directly as a disposable read-only
2697
- Chord state for facets and UI services. `Conversation.watch()` exposes the same
2698
- mount through Package 12's serialized exact-frame watch with bounded pending
2699
- frames and full-value overflow replacements. An empty mounted operation batch
2700
- creates no revision; a redundant nonempty batch remains a real publication.
2701
- Neither API adds another tracker, persistence authority, or semantic event
2702
- envelope.
2703
-
2704
- ### 9.4 Agent-mode notifications
2705
-
2706
- The Session kernel and Chord structural sources do not maintain a semantic event
2707
- journal. Coding-agent JSON/RPC compatibility uses a thin agent-mode adapter
2708
- derived from each uncoalesced committed publication before any per-watch
2709
- overflow replacement. It owns no tracker or persistence and emits notifications only after
2710
- the commit that makes them true.
2711
-
2712
- The adapter protocol covers run start/settlement, committed assistant progress,
2713
- message entry settlement, tool intent/progress/result, submission queue/outcome,
2714
- retry/deferred/compaction state, configuration changes, and faults. One commit
2715
- may produce an ordered batch. Progress notifications represent Pico's throttled
2716
- durable partials, not every raw provider frame. The exact legacy `AgentEvent`
2717
- wire format is not preserved.
2718
-
2719
- Notifications have no hydration or replay contract. A consumer requiring a
2720
- complete lifecycle subscribes before admitting the submission; a late or reconnecting
2721
- consumer hydrates structural state and history instead. Product adapters apply
2722
- these rules:
2723
-
2724
- - TUI hydrates and renders `ConversationView`, then applies structural updates;
2725
- notifications may drive transient animation but are not its authority.
2726
- - Print awaits its input `Submission` and prints that submission's answer.
2727
- - JSON/RPC expose correlated commands plus the ordered agent notification
2728
- protocol, with transport backpressure and disconnect policy owned by that
2729
- adapter.
2730
-
2731
- This adapter is allowed even though a public Session-kernel semantic stream is a
2732
- non-goal. It must not derive notifications from a lossy, overflow-reset watch
2733
- when complete subscribed lifecycle delivery is promised.
3617
+ An appended entry without a head is a splice at the end of `entries`. An
3618
+ appended head marker `H` makes the entries `H` followed by the current non-head
3619
+ entries at or after `H.head`: the kept entries stay, and splices remove the
3620
+ others and insert `H` at the front. Every kept entry is already mounted because
3621
+ Harness writers never target a head before the current range: resets and
3622
+ handoffs head themselves, compaction summaries target a kept entry of the
3623
+ current range, and head writes that reach further back are stale (section 6). A raw head write that targets further back shows only the
3624
+ mounted entries (section 12).
3625
+ Document creation and retirement set and delete the `docs` key. Parent entries
3626
+ through `parent.at` are immutable, so a child's mount follows only its own
3627
+ conversation. A publication that touches nothing mounted creates no revision; a
3628
+ redundant nonempty batch remains a real publication. Mounted document revisions
3629
+ are structurally shared under the trusted immutability contract. The mount
3630
+ performs no semantic projection and owns no persistence.
3631
+
3632
+ `Conversation.viewState()` exposes the mount as a disposable read-only Chord
3633
+ state. `Conversation.watch()` exposes the same mount through the serialized
3634
+ exact-frame watch of section 9.2, with bounded pending frames and full-value
3635
+ overflow replacements.
3636
+
3637
+ ### 9.4 Agent events (experimental)
3638
+
3639
+ `watchEvents(harness, conversationId, context): Promise<AgentEventStream>`,
3640
+ exported from the package root, is an experimental adapter that
3641
+ translates one conversation's committed publications into agent events shaped
3642
+ like the coding agent's `AgentSessionEvent`s. It owns no tracker or persistence,
3643
+ covers exactly one conversation (not its owned subtree), and emits an event only
3644
+ after the commit that makes it true. Its protocol may change without notice.
3645
+
3646
+ ```ts
3647
+ type AgentEvent =
3648
+ | {
3649
+ type: "snapshot";
3650
+ entries: readonly EntryRecord[];
3651
+ run?: { inputs: readonly SubmissionId[] };
3652
+ /** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
3653
+ generation?: { attempt: number; message?: AssistantMessage; retry?: { at: number; error: string }; deferred?: { pollAt: number } };
3654
+ tools: readonly ToolSlot[];
3655
+ /** `pi.live.compactions` (section 8.2). */
3656
+ compactions: readonly CompactionStatus[];
3657
+ inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
3658
+ config: ConversationConfigState;
3659
+ usage: UsageState;
3660
+ }
3661
+ | { type: "run_start"; inputs: readonly SubmissionId[] }
3662
+ | { type: "run_end"; inputs: readonly SubmissionId[] }
3663
+ | { type: "turn_start" }
3664
+ | { type: "turn_end" }
3665
+ | { type: "message_start"; message: Message }
3666
+ | { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
3667
+ | { type: "message_end"; entry: EntryRecord }
3668
+ | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: JsonObject }
3669
+ | {
3670
+ type: "tool_execution_update";
3671
+ toolCallId: string;
3672
+ toolName: string;
3673
+ /** A front trim and then an append of the retained window, or its replacement. */
3674
+ output?: { trimStart?: number; append?: string } | { set: string };
3675
+ details?: JsonValue;
3676
+ diagnostics?: readonly ToolDiagnostic[];
3677
+ }
3678
+ /** `entry` is absent when the tool task faulted or was orphaned. */
3679
+ | { type: "tool_execution_end"; toolCallId: string; toolName: string; entry?: EntryRecord }
3680
+ | { type: "inbox_update"; items: readonly { id: SubmissionId; mode: InboxItem["mode"] }[] }
3681
+ | { type: "submission"; record: SubmissionRecord }
3682
+ | { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
3683
+ | { type: "auto_retry_end"; attempt: number }
3684
+ | { type: "deferred_poll"; pollAt: number }
3685
+ | { type: "entry_appended"; entry: EntryRecord }
3686
+ | { type: "config_changed"; config: ConversationConfigState }
3687
+ | { type: "usage_changed"; usage: UsageState }
3688
+ | { type: "task_failed"; taskId: TaskId; kind: string; message: string }
3689
+ | { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
3690
+ /** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
3691
+ | { type: "compaction_end"; taskId: TaskId; reason: CompactionReason };
3692
+
3693
+ /** One change to the in-flight assistant message, relative to that message. */
3694
+ type MessageChange =
3695
+ | { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: AssistantMessage["content"][number] }
3696
+ | { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
3697
+ | { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
3698
+ | { type: "block"; contentIndex: number; block: AssistantMessage["content"][number] }
3699
+ | { type: "message"; message: AssistantMessage };
3700
+
3701
+ interface AgentEventStream {
3702
+ /** The `snapshot` event at attachment. */
3703
+ readonly snapshot: Extract<AgentEvent, { type: "snapshot" }>;
3704
+ start(listener: (events: readonly AgentEvent[], context: Context) => Promise<void>): void;
3705
+ stop(): Promise<WatchEnd>;
3706
+ readonly closed: Promise<WatchEnd>;
3707
+ }
3708
+ ```
3709
+
3710
+ Every event is relative to the `snapshot` and the events before it, never to
3711
+ Pico's document layout. `snapshot` replaces everything a consumer holds; every
3712
+ other event applies on top of it. Attachment captures the snapshot and
3713
+ registers for later publications atomically on the Session line.
3714
+
3715
+ Events derive from committed changes:
3716
+
3717
+ - `run_start`/`run_end`: `pi.live.run` appears, is removed, or is replaced by a
3718
+ successor run whose first input differs. Steers joining the current run and
3719
+ handovers between its tasks are not run events. The inputs' outcomes are
3720
+ `submission` events.
3721
+ - `turn_start`: a `pi.generation` task is created. `turn_end`: a generation's
3722
+ outcome is committed, when it holds `completing` or becomes terminal,
3723
+ whichever comes first, so a successor created at hold starts after it.
3724
+ - `message_start`: the first committed partial of an attempt, or, for a message
3725
+ entry without a committed partial, the entry itself; `message_end` follows for
3726
+ every entry with `model` messages. Generation commits a partial only once it has
3727
+ content, and every built-in path that clears one appends its entry, so each
3728
+ started message ends with its entry. Entries without messages are
3729
+ `entry_appended`.
3730
+ - `message_update`: the Chord operations on the committed partial, translated:
3731
+ an insertion into `content` starts blocks, an append to a block's `text` or
3732
+ `thinking` is a delta, an append to a string inside a tool call's `arguments`
3733
+ is a `toolcall_delta` with the path relative to `arguments`, any other change
3734
+ inside a block sends that `block`, which already holds the batch's later
3735
+ changes to it, and any other change to `content` sends the whole `message`.
3736
+ Each update carries the partial's current `usage`, as the coding agent's JSON
3737
+ mode does; a change of only `usage` sends no changes. Only deltas travel, so throttled
3738
+ partials cause no write amplification on the wire.
3739
+ - `tool_execution_start`: a slot becomes `running`; `args` come from the tool
3740
+ task's intent checkpoint in the same commit. A slot that becomes `done`
3741
+ without running (a call not offered, invalid arguments, a block, a fault
3742
+ before intent) gets only `tool_execution_end`. `tool_execution_update`: output
3743
+ appends, front trims, and replacements of the slot's retained window, and the
3744
+ slot's current `details` and `diagnostics` when they change; removed ones, as
3745
+ when a safe replay restarts the tool, send `null` and `[]`.
3746
+ `tool_execution_end`: the slot becomes `done`, with its `pi.tool-result`
3747
+ entry, which carries the diagnostics, or without one after a fault or
3748
+ orphan. An unfinished slot that disappears because its run ended ends with the
3749
+ result entry appended in the same commit, as for the unstarted calls of an
3750
+ aborted round, directly before its `message_start`, or without an entry.
3751
+ - `inbox_update`, `config_changed`, `usage_changed`: the document changed; a
3752
+ retired one reads as its initial value, as in a snapshot.
3753
+ - `auto_retry_start`/`auto_retry_end`, `deferred_poll`: `pi.live.generation`
3754
+ gains or drops `retry`, or gains `deferred` or moves its `pollAt`.
3755
+ - `task_failed`: a task of the conversation settles `faulted` or `orphaned`.
3756
+ - `compaction_start`/`compaction_end`: a status appears in or disappears from
3757
+ `pi.live.compactions`. A retry backoff shows only in the snapshot's
3758
+ `compactions`. A queued summary is placed later with its own
3759
+ `message_start`/`message_end` and `submission` events.
3760
+
3761
+ One commit produces one batch, in this order: `tool_execution_start`,
3762
+ `message_start` of a first partial, `message_update`, `tool_execution_update`,
3763
+ and retry/deferred events; then entries in append order with their
3764
+ `message_start`/`message_end` or `entry_appended`, where a tool result's
3765
+ `tool_execution_end` directly precedes its `message_start`, as in the coding
3766
+ agent; then the `tool_execution_end` of tools ending without an entry;
3767
+ then `compaction_end`, `task_failed`, `turn_end`, `run_end`; then `submission`
3768
+ events in ID order; then `inbox_update`, `config_changed`, and `usage_changed`;
3769
+ and last `compaction_start`, `run_start`, and `turn_start`. A stream buffers at most 100 undelivered
3770
+ batches; adding another replaces every undelivered batch with one `snapshot` of
3771
+ the newest committed state. The stream therefore converges but does not promise
3772
+ every transition; `Submission.wait()` reports exact outcomes. A reconnecting
3773
+ consumer attaches again and starts from its `snapshot`; nothing is replayed.
3774
+ Transport backpressure and disconnect policy belong to the consumer.
3775
+
3776
+ Print mode awaits its own input `Submission` and prints its answer. A TUI
3777
+ renders `ConversationView`; events may drive transient animation.
2734
3778
 
2735
3779
  ## 10. Storage contract
2736
3780
 
@@ -2758,7 +3802,7 @@ type EntryQuery = {
2758
3802
  type TaskQuery = {
2759
3803
  readonly conversationId?: ConversationId;
2760
3804
  readonly kind?: string;
2761
- readonly status?: "pending" | "running" | "terminal";
3805
+ readonly status?: "pending" | "running" | "waiting" | "completing" | "terminal";
2762
3806
  readonly abortRequested?: boolean;
2763
3807
  readonly background?: boolean;
2764
3808
  };
@@ -3008,6 +4052,52 @@ These are contracts, not invitations to add defensive machinery:
3008
4052
  with an active run. Raw entries appended by `Harness.commit()` or a custom
3009
4053
  task while a generation prepares its request can misplace its system prompt entries;
3010
4054
  use a write submission.
4055
+ - **Queued items after a failed run:** failure and task abort leave the inbox alone.
4056
+ Queued follow-ups wait in `pi.inbox`, and their `wait()` does not settle, until
4057
+ the next submission's boundary places them or the host withdraws them. A
4058
+ compaction summary is such a submission: when a manual or background
4059
+ compaction finishes in that idle conversation, its final boundary places the
4060
+ waiting follow-ups and starts a run.
4061
+ - **Compaction spend:** every manual and background compaction pays for one
4062
+ summarization request per attempt, also when its summary ends stale because a
4063
+ later compaction cut further.
4064
+ - **Heads and edits during compaction:** a summary reflects the context when its
4065
+ compaction selected the range. An application edit placed while it
4066
+ summarizes, such as a write replacing entry 20, is lost when the summary cuts
4067
+ past its target: the summary still describes the old entry 20, and the edit's
4068
+ target leaves the range. An application head placed meanwhile, for example
4069
+ one cutting at 85 to forget entries 10-84, is undone by a summary cutting at 90:
4070
+ that summary still describes 10-84. Place such writes before compacting.
4071
+ - **Summary timestamps:** a queued summary's user message carries the time its
4072
+ compaction finished, not its placement. Code that judges usage staleness by
4073
+ message timestamps, such as pi-ai `estimateContextTokens()` over view
4074
+ messages, can misjudge; the Harness estimate uses entry order (section 8.3).
4075
+ - **Raw head writes into the past:** a head written directly with `tx.appendEntry()`
4076
+ that targets an entry before the conversation's active range changes model
4077
+ context, but a mounted view keeps only the entries it already holds until the
4078
+ mount is rebuilt. Use a write submission, whose stale check rejects it.
4079
+ - **Double-counted subagent spend:** a tool that runs an owned conversation must
4080
+ not report that conversation's usage in its result; the child's `pi.usage`
4081
+ already counts it, and subtree sums would count it twice.
4082
+ - **Owned work holds its owner:** a task that owns live ordinary work stays
4083
+ `completing` until that work ends (section 5.5). Foreground work an extension
4084
+ starts in a subagent's conversation holds the calling tool, and with it the
4085
+ run; interrogating a subagent while its tool is completing extends the hold.
4086
+ Esc or a host timeout ends it; work that should not hold its owner is created
4087
+ conversation-owned and `background`.
4088
+ - **Work created by hooks:** hooks run inside the asking task's invocation, so
4089
+ work they create owned by that task holds it. Work that must delay a task's
4090
+ finish has to be created before the task commits its outcome; a listener
4091
+ reacting to published events afterwards cannot extend it. A run task's owned
4092
+ work must not write the transcript after the run was released.
4093
+ - **Compensation in abort handlers:** an abort handler cannot create owned
4094
+ children, because its task is abort-marked. Compensate at the level
4095
+ that did the effect, in that task's own abort handler, or inline, or through
4096
+ background conversation-owned tasks awaited with `waitForTask()`; a
4097
+ non-background one would be aborted by any cascade that reaches its
4098
+ conversation.
4099
+ - **Hook memo names:** hooks share the asking task's memo namespace with the
4100
+ task and other hooks. Prefix memo names.
3011
4101
  - **Long transactions:** an async commit callback holds the Session mutation
3012
4102
  line. Never await models, tools, processes, network calls, humans, a nested
3013
4103
  Session commit, or a Session waiter inside it. Use methods on the current `Tx`.