one-code-extension 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/docs/guide/differences-from-claude-code.md +9 -6
  3. package/docs/guide/installation.md +3 -4
  4. package/docs/guide/troubleshooting.md +1 -1
  5. package/extensions/auto-mode/denials.ts +3 -2
  6. package/extensions/background/cron.ts +127 -0
  7. package/extensions/background/index.ts +135 -47
  8. package/extensions/background/loop-fire.ts +39 -2
  9. package/extensions/background/switch-guard.ts +10 -6
  10. package/extensions/background/wakeup.ts +21 -2
  11. package/extensions/bash/index.ts +8 -30
  12. package/extensions/btw/index.ts +32 -18
  13. package/extensions/cache-marks/index.ts +42 -0
  14. package/extensions/claude-context/index.ts +157 -172
  15. package/extensions/compaction/index.ts +11 -8
  16. package/extensions/context-budget/budget.ts +16 -2
  17. package/extensions/context-budget/index.ts +15 -14
  18. package/extensions/context-management/index.ts +11 -28
  19. package/extensions/effort/index.ts +70 -7
  20. package/extensions/file-tracker/index.ts +111 -44
  21. package/extensions/file-tracker/replay.ts +76 -49
  22. package/extensions/file-tracker/restore.ts +12 -9
  23. package/extensions/file-tracker/tracker.ts +14 -1
  24. package/extensions/hooks/index.ts +60 -76
  25. package/extensions/hooks/prompt-context.ts +59 -0
  26. package/extensions/hooks/trust.ts +13 -7
  27. package/extensions/ledger/index.ts +46 -0
  28. package/extensions/ledger/wire.ts +154 -0
  29. package/extensions/lib/agent-cron.ts +17 -1
  30. package/extensions/lib/agent-loader.ts +3 -2
  31. package/extensions/lib/anthropic-auth.ts +98 -0
  32. package/extensions/lib/child-extensions.ts +14 -4
  33. package/extensions/lib/context-facts.ts +75 -56
  34. package/extensions/lib/covered-messages.ts +23 -0
  35. package/extensions/lib/deferred.ts +152 -12
  36. package/extensions/lib/ledger/fold-memo.ts +49 -0
  37. package/extensions/lib/ledger/fold.ts +265 -0
  38. package/extensions/lib/ledger/layout.ts +72 -0
  39. package/extensions/lib/ledger/plan.ts +102 -0
  40. package/extensions/lib/ledger/records.ts +276 -0
  41. package/extensions/lib/ledger/state.ts +47 -0
  42. package/extensions/lib/ledger/validate.ts +141 -0
  43. package/extensions/lib/ledger/wire.ts +632 -0
  44. package/extensions/lib/local-command.ts +24 -6
  45. package/extensions/lib/model-catalog/huggingface.json +5 -1
  46. package/extensions/lib/model-catalog/models-dev.json +10535 -10299
  47. package/extensions/lib/model-catalog/openrouter.json +191 -393
  48. package/extensions/lib/notifications.ts +57 -17
  49. package/extensions/lib/pi-version.ts +25 -8
  50. package/extensions/lib/plan-mode-channels.ts +29 -7
  51. package/extensions/lib/previous-mark.ts +101 -0
  52. package/extensions/lib/process-state.ts +48 -0
  53. package/extensions/lib/process-tree.ts +3 -2
  54. package/extensions/lib/reminders.ts +327 -621
  55. package/extensions/lib/request-replay.ts +186 -78
  56. package/extensions/lib/side-call.ts +11 -0
  57. package/extensions/lib/subagent-handback.ts +0 -1
  58. package/extensions/lib/tool-additions.ts +2 -22
  59. package/extensions/lib/turn-budget-layout.ts +18 -2
  60. package/extensions/lsp/index.ts +6 -8
  61. package/extensions/mcp/announce.ts +63 -98
  62. package/extensions/mcp/index.ts +51 -90
  63. package/extensions/mcp/trust.ts +13 -6
  64. package/extensions/model-default/index.ts +86 -19
  65. package/extensions/permissions/index.ts +157 -48
  66. package/extensions/permissions/modes.ts +5 -0
  67. package/extensions/plan-mode/index.ts +142 -45
  68. package/extensions/plan-mode/run-state.ts +26 -0
  69. package/extensions/plugins/index.ts +4 -1
  70. package/extensions/powershell/index.ts +2 -0
  71. package/extensions/recap/index.ts +11 -14
  72. package/extensions/search-tools/index.ts +2 -0
  73. package/extensions/skill/announce.ts +30 -0
  74. package/extensions/skill/index.ts +65 -48
  75. package/extensions/skill/invoke.ts +0 -67
  76. package/extensions/subagents/agents.ts +30 -8
  77. package/extensions/subagents/fork-cache.ts +9 -3
  78. package/extensions/subagents/index.ts +180 -107
  79. package/extensions/subagents/outcome.ts +3 -0
  80. package/extensions/subagents/parent-mode.ts +50 -0
  81. package/extensions/subagents/pending-claim.ts +18 -1
  82. package/extensions/subagents/runner.ts +29 -2
  83. package/extensions/subagents/runs.ts +5 -0
  84. package/extensions/system-prompt/index.ts +40 -6
  85. package/extensions/system-prompt/template.ts +10 -1
  86. package/extensions/system-reminder/index.ts +174 -202
  87. package/extensions/tool-search/index.ts +241 -167
  88. package/extensions/workflow/agent-session.ts +4 -1
  89. package/extensions/workflow/index.ts +16 -4
  90. package/extensions/workflow/run-manager.ts +4 -0
  91. package/extensions/worktree/index.ts +50 -14
  92. package/package.json +5 -2
  93. package/extensions/lib/context-stack.ts +0 -81
  94. package/extensions/subagents/announce.ts +0 -69
  95. package/extensions/tool-search/announce.ts +0 -58
@@ -31,10 +31,11 @@
31
31
  */
32
32
  import { AsyncLocalStorage } from "node:async_hooks";
33
33
  import { randomUUID } from "node:crypto";
34
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
34
+ import { type ExtensionAPI, type ExtensionContext, VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent";
35
35
  import { withKeepAlive } from "../lsp/keep-alive.ts";
36
36
  import { ONE_SHOT_COMMAND_FAILED_CHANNEL, RunOutcomeLatch } from "./interrupt.ts";
37
37
  import { escapeXml } from "./local-command.ts";
38
+ import { piFiresBeforeAgentStartForCustomRuns } from "./pi-version.ts";
38
39
  import { isUserRun, PROMPT_OPTIONS_CHANNEL } from "./prompt-options.ts";
39
40
  import { wrapReminder } from "./reminders.ts";
40
41
 
@@ -481,11 +482,6 @@ export const NOTIFICATION_ID_KEY = "notificationId";
481
482
  /** `details` key on a coalesced message: the `{customType, details}` of each notification it carries, in arrival order. */
482
483
  export const NOTIFICATION_BATCH_KEY = "notifications";
483
484
 
484
- /** True for the `details` of a message the notifier sent (one notification or a coalesced batch). */
485
- export function isNotificationDetails(details: unknown): boolean {
486
- return typeof details === "object" && details !== null && (NOTIFICATION_ID_KEY in details || NOTIFICATION_BATCH_KEY in details);
487
- }
488
-
489
485
  /** Inject a harness notification (a task/agent completion, a monitor event, a review note) into the conversation. */
490
486
  export interface TaskNotifier {
491
487
  (customType: string, text: string, details?: Record<string, unknown>): void;
@@ -533,6 +529,25 @@ export interface TaskNotifierOptions {
533
529
  * report, so its hand-back would only repeat it.
534
530
  */
535
531
  withdrawOnDelivery?: boolean;
532
+ /**
533
+ * Whether pi fires `before_agent_start` for a run `sendMessage` opens from
534
+ * idle (pi 1.2.0 and later). Then such a run builds One Code's system
535
+ * prompt like a typed one, and no notification needs the user-message
536
+ * path (see "First turn of a session"). Defaults to the running pi's
537
+ * version; tests set it to model an older pi.
538
+ */
539
+ customRunsFireBeforeAgentStart?: boolean;
540
+ /**
541
+ * Runs once a notification this notifier sent reaches the conversation,
542
+ * with its customType and the details it was sent with: at its
543
+ * `message_end`, or at the send for one delivered as a user message (see
544
+ * "First turn of a session"), whose message carries no details for pi to
545
+ * hand back. A producer whose message makes something true for the model
546
+ * (a `/loop` fire's instructions) records that here, not when it calls the
547
+ * notifier: the notifier may still hold the message, and a `/reload`
548
+ * drops it.
549
+ */
550
+ onDelivered?: (customType: string, details: Record<string, unknown>) => void;
536
551
  }
537
552
 
538
553
  /**
@@ -619,9 +634,10 @@ export const DEFAULT_COALESCE_MS = 250;
619
634
  * anything dispatched afterwards; a throwing `sendMessage` is swallowed as a
620
635
  * second line.
621
636
  *
622
- * First turn of a session: pi's idle `sendMessage(…, {triggerTurn: true})`
623
- * calls `_runAgentPrompt` directly and skips the `prompt()` preamble, so no
624
- * `before_agent_start` fires — and our system prompt is set from that hook.
637
+ * First turn of a session, on pi before 1.2.0: pi's idle
638
+ * `sendMessage(…, {triggerTurn: true})` calls `_runAgentPrompt` directly and
639
+ * skips the `prompt()` preamble, so no `before_agent_start` fires — and our
640
+ * system prompt is set from that hook.
625
641
  * A session whose first input was `/loop …` therefore ran its first request on
626
642
  * pi's stock system prompt with no reminder stack (review H1, measured). Until
627
643
  * no `prompt()` has run in this process, an idle notification is delivered as a
@@ -636,6 +652,15 @@ export const DEFAULT_COALESCE_MS = 250;
636
652
  * so the system-prompt extension installs the prompt for it from
637
653
  * `context_with_system` (`system-prompt/idle-turn.ts`). Upstream ask:
638
654
  * working-docs/upstream_prs.md #17.
655
+ *
656
+ * pi 1.2.0 fires `before_agent_start` for that run too, so there every
657
+ * notification goes the custom-message way from the start. Before, the
658
+ * fallback ran on every pi, and a `/reload` re-runs the factory, which starts
659
+ * over as if no prompt had run: a re-armed `/loop` wakeup went out as a plain
660
+ * user message and lost its customType and no-op fold (lifecycle grid
661
+ * R3-M17). On an older pi the fallback is still right after a reload: the
662
+ * system-prompt extension has lost the options too, and the user-message
663
+ * path is the only one that rebuilds the prompt.
639
664
  */
640
665
  export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOptions = {}): TaskNotifier {
641
666
  interface Pending {
@@ -652,6 +677,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
652
677
  details: Record<string, unknown>;
653
678
  }
654
679
  const coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
680
+ const customRunsPrompt = options.customRunsFireBeforeAgentStart ?? piFiresBeforeAgentStartForCustomRuns(PI_VERSION);
655
681
  const pending = new Map<string, Pending>();
656
682
  // Every notifier hears every message_end; simultaneous producers must not share an outbox id.
657
683
  const producerId = randomUUID();
@@ -660,11 +686,12 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
660
686
  /** False once the session this notifier belongs to has shut down. */
661
687
  let active = true;
662
688
  /**
663
- * True once an idle turn can be built without `prompt()`: a prompt has run
664
- * (the only path that emits before_agent_start), or a command announced
665
- * pi's prompt options.
689
+ * True once an idle turn can be built without `prompt()`: always on pi
690
+ * 1.2.0 and later; before it, once a prompt has run (the only path that
691
+ * emits before_agent_start there) or a command announced pi's prompt
692
+ * options.
666
693
  */
667
- let prompted = false;
694
+ let prompted = customRunsPrompt;
668
695
  /** True between agent_start and agent_settled. */
669
696
  let busy = false;
670
697
  /** True from a turn settling aborted/errored until the next turn starts: hold, do not start turns. */
@@ -698,6 +725,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
698
725
  // followUp: pi 1.2.0 counts a starting run as busy and rejects a
699
726
  // bare send there; when idle, the option changes nothing.
700
727
  pi.sendUserMessage(frameForDelivery(entry.text, "opens-turn"), { deliverAs: "followUp" });
728
+ options.onDelivered?.(entry.customType, entry.details);
701
729
  return;
702
730
  }
703
731
  pi.sendMessage(
@@ -805,10 +833,14 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
805
833
  };
806
834
 
807
835
  pi.on("message_end", (event) => {
808
- const message = event.message as { role?: string; details?: unknown };
836
+ const message = event.message as { role?: string; customType?: string; details?: unknown };
809
837
  if (message.role !== "custom") return;
810
- const id = (message.details as Record<string, unknown> | undefined)?.[NOTIFICATION_ID_KEY];
811
- if (typeof id === "string") pending.delete(id);
838
+ const details = message.details as Record<string, unknown> | undefined;
839
+ const id = details?.[NOTIFICATION_ID_KEY];
840
+ if (typeof id !== "string") return;
841
+ pending.delete(id);
842
+ // Every notifier hears every message_end: only its own ids are its deliveries.
843
+ if (details && id.startsWith(`${producerId}-`)) options.onDelivered?.(message.customType ?? "", details);
812
844
  });
813
845
  pi.on("agent_end", (event, ctx) => {
814
846
  outcome.record(event.messages, ctx.signal?.aborted);
@@ -847,7 +879,7 @@ export function createTaskNotifier(pi: TaskNotifierApi, options: TaskNotifierOpt
847
879
  clearBatch();
848
880
  held = [];
849
881
  active = true;
850
- prompted = false;
882
+ prompted = customRunsPrompt;
851
883
  busy = false;
852
884
  interrupted = false;
853
885
  });
@@ -930,6 +962,14 @@ export function oneShotSessionNote(): string {
930
962
  ].join("\n");
931
963
  }
932
964
 
965
+ /**
966
+ * The notice that a session whose first message said it was one-shot runs
967
+ * interactively now (a `-p` session continued in the TUI). One Code's own text.
968
+ */
969
+ export function interactiveSessionNote(): string {
970
+ return "This session is interactive now, although its first message said it was one-shot: a user can reply, background work notifies you when it ends, scheduled jobs fire, and questions and plan approvals reach the user.";
971
+ }
972
+
933
973
  function assertNever(mode: never): never {
934
974
  throw new Error(`Unhandled session mode: ${String(mode)}`);
935
975
  }
@@ -9,16 +9,16 @@
9
9
  * never blocking — when outside it. The bundled `one-code` app pins pi inside
10
10
  * the range, so it never warns.
11
11
  *
12
- * The minimum is the oldest pi the extension set loads on: 0.84.3 first
13
- * exports `createPowerShellToolDefinition`, and on anything older the
14
- * PowerShell extension fails at load, which stops pi before any warning can
15
- * show. The peer range in package.json and the guide name the same floor
16
- * (pi-version.test.ts), and CI runs `test/e2e/pi-floor-smoke.mjs` against it.
12
+ * The minimum is 1.0.0: the record-once request path (extensions/ledger)
13
+ * builds on pi 1.0's session entries, transcript system messages and request
14
+ * pipeline (decisions/distribution.md). The peer range in package.json and the
15
+ * guide name the same floor (pi-version.test.ts), and CI runs
16
+ * `test/e2e/pi-floor-smoke.mjs` against it.
17
17
  */
18
18
 
19
19
  /** Inclusive minimum and exclusive maximum pi version this release is tested against. */
20
- export const TESTED_PI_MIN = "0.84.3";
21
- export const TESTED_PI_MAX_EXCLUSIVE = "1.4.0";
20
+ export const TESTED_PI_MIN = "1.0.0";
21
+ export const TESTED_PI_MAX_EXCLUSIVE = "1.5.0";
22
22
 
23
23
  /** Dotted-numeric parse; undefined for anything that is not plain x.y.z numbers. */
24
24
  export function parseVersion(version: string): number[] | undefined {
@@ -59,6 +59,18 @@ export function piVersionWarning(runningPiVersion: string | undefined): string |
59
59
  );
60
60
  }
61
61
 
62
+ /**
63
+ * Whether the running pi fires `before_agent_start` for a run an extension
64
+ * opens from idle with `sendMessage(…, { triggerTurn: true })`. pi 1.2.0
65
+ * added it (its changelog, "before_agent_start for extension-triggered
66
+ * runs"); before, only `prompt()` fired the event. A version check, not a
67
+ * feature test: nothing tells the two apart before such a run starts, and an
68
+ * unparseable version reads as no, the older and safer behavior.
69
+ */
70
+ export function piFiresBeforeAgentStartForCustomRuns(runningPiVersion: string | undefined): boolean {
71
+ return piAtLeast(runningPiVersion, "1.2.0");
72
+ }
73
+
62
74
  /**
63
75
  * Whether the running pi ships built-in extensions that can replace ours'
64
76
  * names (`builtin:tool-search`, `builtin:mcp`; lib/replaced-builtins.mjs).
@@ -67,7 +79,12 @@ export function piVersionWarning(runningPiVersion: string | undefined): string |
67
79
  * as no.
68
80
  */
69
81
  export function piHasBuiltinExtensions(runningPiVersion: string | undefined, firstVersion: string): boolean {
82
+ return piAtLeast(runningPiVersion, firstVersion);
83
+ }
84
+
85
+ /** Whether the running pi is `min` or later; a missing or unparseable version reads as no. */
86
+ function piAtLeast(runningPiVersion: string | undefined, min: string): boolean {
70
87
  if (!runningPiVersion) return false;
71
- const order = compareVersions(runningPiVersion, firstVersion);
88
+ const order = compareVersions(runningPiVersion, min);
72
89
  return order !== undefined && order >= 0;
73
90
  }
@@ -11,16 +11,38 @@
11
11
  export const MODE_CHANNEL = "one-code:set-permission-mode";
12
12
  /** Announces plan mode's one writable file (`{ path }`); the permissions matcher consumes it. */
13
13
  export const PLAN_FILE_CHANNEL = "one-code:plan-file-path";
14
- /** Session entry type persisting plan mode's allocated path across resume/branch (`{ path }`). */
14
+ /**
15
+ * Session entry type persisting plan mode's allocated path across resume/branch
16
+ * (`{ path, sessionId?, copiedFrom? }`): the session that allocated it, and the
17
+ * plan a fork's copy was taken from.
18
+ */
15
19
  export const PLAN_FILE_ENTRY = "one-code:plan-mode-file";
16
20
 
17
- /** The plan file the branch's latest plan-file entry records, if any. */
18
- export function planFileOnBranch(entries: readonly { type: string; customType?: string; data?: unknown }[]): string | undefined {
19
- let path: string | undefined;
21
+ export interface PlanFileRecord {
22
+ path: string;
23
+ sessionId?: string;
24
+ copiedFrom?: string;
25
+ }
26
+
27
+ type EntryLike = { type: string; customType?: string; data?: unknown };
28
+
29
+ /** Every plan-file record in `entries`, oldest first. */
30
+ export function planFileRecords(entries: readonly EntryLike[]): PlanFileRecord[] {
31
+ const records: PlanFileRecord[] = [];
20
32
  for (const entry of entries) {
21
33
  if (entry.type !== "custom" || entry.customType !== PLAN_FILE_ENTRY) continue;
22
- const recorded = (entry.data as { path?: unknown } | undefined)?.path;
23
- if (typeof recorded === "string") path = recorded;
34
+ const data = entry.data as { path?: unknown; sessionId?: unknown; copiedFrom?: unknown } | undefined;
35
+ if (typeof data?.path !== "string") continue;
36
+ records.push({
37
+ path: data.path,
38
+ ...(typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {}),
39
+ ...(typeof data.copiedFrom === "string" ? { copiedFrom: data.copiedFrom } : {}),
40
+ });
24
41
  }
25
- return path;
42
+ return records;
43
+ }
44
+
45
+ /** The plan file the branch's latest plan-file entry records, if any. */
46
+ export function planFileOnBranch(entries: readonly EntryLike[]): string | undefined {
47
+ return planFileRecords(entries).at(-1)?.path;
26
48
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Keep the previous request's conversation breakpoint (pure).
3
+ *
4
+ * Anthropic finds an earlier cache entry only a limited way back from a
5
+ * breakpoint, about 20 content blocks, and pi moves the one conversation
6
+ * breakpoint to each request's tail. A request that adds many blocks since the
7
+ * last one (a large parallel tool batch), or a long run of `tool_addition`
8
+ * blocks after the session-facts system message, then cannot reach the entry
9
+ * the previous request wrote and re-caches the whole conversation (findings
10
+ * §72: a live pair read 11,707 of 24,837 tokens, and 24,837 with this mark).
11
+ * Marking the block the previous request marked again lets the server match
12
+ * that entry directly. It costs nothing: the entry exists, so the mark reads.
13
+ */
14
+
15
+ import { appendPoint } from "./ledger/wire.ts";
16
+ import { MAX_CACHE_MARKERS } from "./request-replay.ts";
17
+
18
+ type Block = Record<string, unknown> & { cache_control?: unknown };
19
+ type Message = { role?: unknown; content?: unknown };
20
+
21
+ /** Where a request's last conversation breakpoint sat, and the block it marked. */
22
+ export interface MessageMark {
23
+ message: number;
24
+ block: number;
25
+ marker: unknown;
26
+ /** The marked block itself: pi builds each request afresh but reuses large strings (an image's data), so a structural compare short-circuits on them. */
27
+ content: Block;
28
+ }
29
+
30
+ const blocksOf = (message: unknown): Block[] =>
31
+ Array.isArray((message as Message | null)?.content) ? ((message as Message).content as Block[]) : [];
32
+
33
+ /** Equal JSON values, ignoring a top-level `cache_control`. */
34
+ function sameBlock(a: Block, b: Block): boolean {
35
+ const keys = (block: Block) => Object.keys(block).filter((key) => key !== "cache_control");
36
+ const left = keys(a);
37
+ return left.length === keys(b).length && left.every((key) => sameValue(a[key], b[key]));
38
+ }
39
+ function sameValue(a: unknown, b: unknown): boolean {
40
+ if (a === b) return true;
41
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null || Array.isArray(a) !== Array.isArray(b)) return false;
42
+ const left = Object.keys(a);
43
+ return left.length === Object.keys(b).length && left.every((key) => sameValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]));
44
+ }
45
+
46
+ /** The last breakpoint in `messages`, or undefined when none carries one. */
47
+ export function lastMessageMark(messages: unknown): MessageMark | undefined {
48
+ if (!Array.isArray(messages)) return undefined;
49
+ for (let message = messages.length - 1; message >= 0; message--) {
50
+ const blocks = blocksOf(messages[message]);
51
+ for (let block = blocks.length - 1; block >= 0; block--) {
52
+ const marker = blocks[block]?.cache_control;
53
+ if (marker !== undefined) return { message, block, marker, content: blocks[block] };
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ function marksIn(value: unknown): number {
60
+ if (!Array.isArray(value)) return 0;
61
+ return value.reduce((sum: number, entry) => {
62
+ const own = (entry as Block | null)?.cache_control !== undefined ? 1 : 0;
63
+ return sum + own + blocksOf(entry).filter((block) => block?.cache_control !== undefined).length;
64
+ }, 0);
65
+ }
66
+
67
+ /**
68
+ * The payload with `previous` marked again: only when that block is still
69
+ * there with the same bytes, no later block already took its place as the
70
+ * tail mark, and a breakpoint slot is free. Otherwise the payload unchanged.
71
+ */
72
+ export function withPreviousMark<T extends Record<string, unknown>>(payload: T, previous: MessageMark | undefined): T {
73
+ if (!previous || !Array.isArray(payload.messages)) return payload;
74
+ const messages = payload.messages as Message[];
75
+ const blocks = blocksOf(messages[previous.message]);
76
+ const target = blocks[previous.block];
77
+ if (!target || target.cache_control !== undefined || !sameBlock(target, previous.content)) return payload;
78
+ if (marksIn(payload.tools) + marksIn(payload.system) + marksIn(messages) >= MAX_CACHE_MARKERS) return payload;
79
+ const copy = [...messages];
80
+ const content = [...blocks];
81
+ content[previous.block] = { ...target, cache_control: previous.marker };
82
+ copy[previous.message] = { ...messages[previous.message], content };
83
+ return { ...payload, messages: copy };
84
+ }
85
+
86
+ /**
87
+ * The breakpoint the base request carried, located in `messages` (the request
88
+ * that extends it, spliced): the last block of the base's last element before
89
+ * its trailing directives, with the marker this request's own tail mark uses.
90
+ * Undefined when the request has no tail mark or the base is empty.
91
+ */
92
+ export function baseMark(messages: unknown, baseList: readonly unknown[], api: string): MessageMark | undefined {
93
+ if (!Array.isArray(messages)) return undefined;
94
+ const end = appendPoint(baseList, api);
95
+ const tail = lastMessageMark(messages);
96
+ if (end === 0 || !tail || tail.message < end) return undefined;
97
+ const blocks = blocksOf(messages[end - 1]);
98
+ const block = blocks.length - 1;
99
+ if (block < 0) return undefined;
100
+ return { message: end - 1, block, marker: tail.marker, content: blocks[block] };
101
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Process state: what lives as long as the One Code process and no longer
3
+ * (lifecycle-grid-hunt plan, part D). Every piece of state an extension holds
4
+ * is one of three kinds:
5
+ *
6
+ * - branch state follows the conversation: a resume, `/reload`, `/fork` and
7
+ * `/tree` each read it back from the branch (a `one-code:state` entry,
8
+ * lib/ledger/state.ts, or a fold of the branch's own entries);
9
+ * - process state survives `/reload` and the session swaps of `/clear`,
10
+ * `/new`, `/resume` and `/fork`, and ends with the process (a grant the
11
+ * user gave "for this session", a notice owed to the next session): this
12
+ * module;
13
+ * - resources (a timer, a child process, a connection) end with the instance
14
+ * that opened them, in `session_shutdown`.
15
+ *
16
+ * Module scope is none of them: pi re-evaluates every extension module at a
17
+ * `/reload` and at a `/resume` into another directory (findings §8), so a
18
+ * module-level value is lost there and kept everywhere else. The store here
19
+ * is a `globalThis` slot, which no re-evaluation resets.
20
+ *
21
+ * Pure: no pi imports.
22
+ */
23
+
24
+ const SLOT = Symbol.for("one-code:process-state");
25
+
26
+ type Store = Map<string, unknown>;
27
+
28
+ function store(): Store {
29
+ const slot = globalThis as { [SLOT]?: Store };
30
+ return (slot[SLOT] ??= new Map());
31
+ }
32
+
33
+ /**
34
+ * The process's value for `key`, created by `init` on first use. Keys are
35
+ * namespaced by their owner (`"permissions:grants"`); the value is shared by
36
+ * every instance of that owner in the process, so it must be mutated, not
37
+ * replaced.
38
+ */
39
+ export function processState<T>(key: string, init: () => T): T {
40
+ const values = store();
41
+ if (!values.has(key)) values.set(key, init());
42
+ return values.get(key) as T;
43
+ }
44
+
45
+ /** Forget every process value: a test's fresh process. */
46
+ export function resetProcessStateForTest(): void {
47
+ store().clear();
48
+ }
@@ -165,8 +165,9 @@ function terminateRememberedGroups(): void {
165
165
  * Remember a detached background tree from spawn until its group becomes empty.
166
166
  * Register before awaiting its leader, so even immediate process exit covers
167
167
  * running work. The unref'd reaper cannot keep a print run alive. No session
168
- * shutdown handler touches this set; process listeners keep its module instance
169
- * alive even when jiti builds a new extension instance after /clear.
168
+ * shutdown handler touches this set; the process listeners keep this module
169
+ * instance alive, and reach its set, even after pi evaluates the module again
170
+ * (a /reload, a /resume into another directory: findings §8).
170
171
  */
171
172
  export function rememberProcessGroup(child: ChildProcess): void {
172
173
  if (child.pid == null || rememberedGroups.has(child)) return;