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
@@ -1,22 +1,27 @@
1
1
  /**
2
2
  * Rebuild read-before-write state from a session branch (pure): which files
3
- * the model has read or written this conversation, so a `--resume`d session
4
- * does not block every edit with "has not been read in this conversation" —
5
- * a claim the model can see is false in its own transcript (review T9).
3
+ * the model has seen this conversation, so a `--resume`d session does not
4
+ * block every edit with "has not been read in this conversation", a claim
5
+ * the model can see is false in its own transcript (review T9).
6
6
  *
7
- * The content the model saw is not in the transcript, so a replayed file is
8
- * observed with its CURRENT content: an edit after resume is allowed, and a
9
- * change made while the session was closed is not flagged. That is Claude
10
- * Code's behaviour too (its read state persists across resume); the stale-edit
11
- * guard still catches anything that changes from this point on.
7
+ * The content the model saw is not in the transcript, but each read and write
8
+ * result records what it saw (`details.oneCodeFile`, `FileObservation`) under
9
+ * the absolute path the tool resolved. The replay keys on that path, never on
10
+ * the call's own spelling: a worktree rewrites a relative path only in the
11
+ * call it runs, so the transcript's `a.ts` would resolve to the main
12
+ * checkout's twin (round-3 review M15). A post-compaction restore records the
13
+ * same on its pending entry, and a changed-on-disk notice records the
14
+ * versions it named. The caller compares each observation with the disk: a
15
+ * file that still matches is read, one that changed since is stale. A result
16
+ * that recorded nothing (a session from before the record existed) leaves its
17
+ * file unread: the disk as it is now is never taken as what the model saw
18
+ * (round-2 review H1).
12
19
  */
13
20
 
14
- import { pathArgument } from "../auto-mode/paths.ts";
21
+ import { latestCompaction } from "../lib/compaction-boundary.ts";
22
+ import { type FileVersion, isFileVersion, readRecord } from "../lib/ledger/records.ts";
15
23
  import { shownInFull } from "./shell-reads.ts";
16
24
 
17
- const READ_TOOLS = new Set(["read", "notebook_edit"]);
18
- const WRITE_TOOLS = new Set(["edit", "write", "notebook_edit"]);
19
-
20
25
  interface ToolCallBlock {
21
26
  type: "toolCall";
22
27
  id: string;
@@ -24,63 +29,85 @@ interface ToolCallBlock {
24
29
  arguments?: Record<string, unknown>;
25
30
  }
26
31
 
27
- type BranchMessage = { role?: string; content?: unknown; toolCallId?: string; isError?: boolean; timestamp?: number };
32
+ type BranchMessage = { role?: string; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean; details?: { oneCodeFile?: unknown } };
33
+
34
+ /** What a read or write result saw of its file, recorded in its details as `oneCodeFile`: a version with its size and modification time. */
35
+ export type FileObservation = Required<FileVersion>;
36
+
37
+ const isObservation = (value: unknown): value is FileObservation => isFileVersion(value) && value.size !== undefined && value.mtimeMs !== undefined;
28
38
 
29
39
  /**
30
- * Every tool call on the branch that names a path (`path`, `file_path` or
31
- * `notebook_path`, as the gates read it) and got a successful result, in
32
- * result order, with the result message's time.
40
+ * Every successful tool result on the branch that recorded what it saw of a
41
+ * file, in branch order: its position, the tool that ran (from its call, else
42
+ * the result's own `toolName`) and the observation.
33
43
  */
34
- export function* successfulPathCalls(entries: readonly unknown[]): Generator<{ name: string; path: string; timestamp: number | undefined }> {
35
- const calls = new Map<string, { name: string; path: string }>();
36
- for (const entry of entries) {
44
+ function* fileResults(entries: readonly unknown[]): Generator<{ index: number; name: string | undefined; seen: FileObservation }> {
45
+ const names = new Map<string, string>();
46
+ for (const [index, entry] of entries.entries()) {
37
47
  const e = entry as { type?: string; message?: BranchMessage };
38
48
  if (e?.type !== "message" || !e.message) continue;
39
49
  const m = e.message;
40
50
  if (m.role === "assistant" && Array.isArray(m.content)) {
41
- for (const block of m.content as ToolCallBlock[]) {
42
- if (block?.type !== "toolCall" || !block.id) continue;
43
- const path = pathArgument(block.arguments);
44
- if (path?.trim()) calls.set(block.id, { name: block.name, path });
45
- }
46
- } else if (m.role === "toolResult" && m.toolCallId && !m.isError) {
47
- const call = calls.get(m.toolCallId);
48
- if (call) yield { ...call, timestamp: m.timestamp };
51
+ for (const block of m.content as ToolCallBlock[]) if (block?.type === "toolCall" && block.id) names.set(block.id, block.name);
52
+ } else if (m.role === "toolResult" && !m.isError) {
53
+ const seen = m.details?.oneCodeFile;
54
+ if (isObservation(seen)) yield { index, name: (m.toolCallId ? names.get(m.toolCallId) : undefined) ?? m.toolName, seen };
49
55
  }
50
56
  }
51
57
  }
52
58
 
53
- const touchesFile = (name: string) => READ_TOOLS.has(name) || WRITE_TOOLS.has(name);
54
-
55
- /** Paths (as written in the tool input) of every successful read/edit/write on the branch, in order, deduplicated. */
56
- export function pathsReadOnBranch(entries: readonly unknown[]): string[] {
57
- const seen = new Set<string>();
58
- for (const call of successfulPathCalls(entries)) if (touchesFile(call.name)) seen.add(call.path);
59
- return [...seen];
59
+ /** What the branch shows the model saw of a file, and what it was told about the file since. */
60
+ export interface SeenFile {
61
+ /** The version the model last saw, as the result or restore that showed it recorded it. */
62
+ seen: FileObservation;
63
+ /** Content hashes of the changed versions a notice named after that. */
64
+ notified: Set<string>;
60
65
  }
61
66
 
62
67
  /**
63
- * Each path (as written in the tool input) a successful read/edit/write on the
64
- * branch touched, with the time of its last such result message: the order
65
- * the post-compaction file restore uses after a resume, when the modification
66
- * times stat'ed at those reads are gone (Claude Code uses the transcript time
67
- * the same way).
68
+ * Each file the model saw in the entries still in context (`inContextEntries`),
69
+ * by absolute path: the last result or post-compaction restore that showed
70
+ * it, and the changed versions a notice named since.
71
+ *
72
+ * In the kept turns before the latest compaction a `read` result counts, as
73
+ * at the compaction itself: a kept edit or write does not show the whole
74
+ * file, so it counts only for a file a kept read showed before it, where it
75
+ * records the version the edit left (the live tracker observes the file
76
+ * again after its own write, and the compaction keeps that). A restore counts only after the latest compaction, and there it
77
+ * counts whether or not a request delivered it yet. A later compaction is
78
+ * the only thing that drops a restore undelivered (lib/ledger/fold.ts), so
79
+ * one recorded since the latest is either delivered or rides the next
80
+ * request, and the model sees the file before it can call a tool on it; the
81
+ * live session counts it read from the compaction on, too. A notice counts
82
+ * wherever it lies after the file's last observation: it is delivered or
83
+ * pending, and announcing the same version again would repeat it.
68
84
  */
69
- export function lastTouchesOnBranch(entries: readonly unknown[]): Map<string, number> {
70
- const touches = new Map<string, number>();
71
- for (const call of successfulPathCalls(entries)) {
72
- if (touchesFile(call.name) && call.timestamp !== undefined) touches.set(call.path, call.timestamp);
73
- }
74
- return touches;
85
+ export function filesSeenOnBranch(entries: readonly unknown[]): Map<string, SeenFile> {
86
+ const compaction = latestCompaction(entries)?.index ?? -1;
87
+ const results = new Map([...fileResults(entries)].map((result) => [result.index, result]));
88
+ const files = new Map<string, SeenFile>();
89
+ const see = (seen: FileObservation) => files.set(seen.path, { seen, notified: new Set() });
90
+ entries.forEach((entry, index) => {
91
+ const result = results.get(index);
92
+ if (result) {
93
+ if (index > compaction || result.name === "read" || files.has(result.seen.path)) see(result.seen);
94
+ return;
95
+ }
96
+ const record = readRecord(entry as never);
97
+ if (record?.kind !== "pending") return;
98
+ if (index > compaction) for (const seen of record.data.observations ?? []) if (isObservation(seen)) see(seen);
99
+ for (const told of record.data.notified ?? []) files.get(told.path)?.notified.add(told.sha256);
100
+ });
101
+ return files;
75
102
  }
76
103
 
77
104
  /**
78
- * The paths (as written in the tool input) that a successful `read` in the
79
- * kept turns returned: those files are in context already, so the
80
- * post-compaction restore skips them.
105
+ * The files (absolute, as their results recorded them) that a successful
106
+ * `read` in the kept turns returned: those files are in context already, so
107
+ * the post-compaction restore skips them.
81
108
  */
82
109
  export function keptReadPaths(kept: readonly unknown[]): string[] {
83
- return [...successfulPathCalls(kept)].filter((call) => call.name === "read").map((call) => call.path);
110
+ return [...fileResults(kept)].filter((result) => result.name === "read").map((result) => result.seen.path);
84
111
  }
85
112
 
86
113
  /**
@@ -33,22 +33,25 @@ export function restoreCandidates(touched: ReadonlyMap<string, number>, skip: (p
33
33
  /** What reading a candidate again gave: its text as the read tool returns it, or nothing (gone, unreadable, an image). */
34
34
  export type RestoredRead = { path: string; text: string | undefined };
35
35
 
36
+ /** One `<system-reminder>` block of a restore; `restores` names the file whose contents it puts back in context. */
37
+ export type RestoreBlock = { text: string; restores?: string };
38
+
36
39
  /**
37
- * The reminder texts (one per `<system-reminder>` block) for the reads, in
38
- * order: two blocks for a file within the per-file cap, one note for a larger
39
- * one, nothing for a file that could not be read. A file's blocks are kept only
40
- * while the running total stays within the overall cap. `restored` lists the
41
- * files whose contents went back into context.
40
+ * The reminder blocks for the reads, in order: two blocks for a file within
41
+ * the per-file cap (the result block `restores` it), one note for a larger
42
+ * one, nothing for a file that could not be read. A file's blocks are kept
43
+ * only while the running total stays within the overall cap. `restored` lists
44
+ * the files whose contents went back into context.
42
45
  */
43
- export function restoreBlocks(reads: RestoredRead[], estimateTokens: (text: string) => number): { blocks: string[]; restored: string[] } {
44
- const blocks: string[] = [];
46
+ export function restoreBlocks(reads: RestoredRead[], estimateTokens: (text: string) => number): { blocks: RestoreBlock[]; restored: string[] } {
47
+ const blocks: RestoreBlock[] = [];
45
48
  const restored: string[] = [];
46
49
  let total = 0;
47
50
  for (const { path, text } of reads) {
48
51
  if (text === undefined) continue;
49
52
  const fits = estimateTokens(text) <= RESTORE_FILE_MAX_TOKENS;
50
- const fileBlocks = fits ? [readCallBlock(path), readResultBlock(text)] : [tooLargeNote(path)];
51
- const tokens = fileBlocks.reduce((sum, block) => sum + estimateTokens(block), 0);
53
+ const fileBlocks: RestoreBlock[] = fits ? [{ text: readCallBlock(path) }, { text: readResultBlock(text), restores: path }] : [{ text: tooLargeNote(path) }];
54
+ const tokens = fileBlocks.reduce((sum, block) => sum + estimateTokens(block.text), 0);
52
55
  if (total + tokens > RESTORE_TOTAL_MAX_TOKENS) continue;
53
56
  total += tokens;
54
57
  blocks.push(...fileBlocks);
@@ -23,6 +23,8 @@ export interface TrackedFile {
23
23
  */
24
24
  oversized: boolean;
25
25
  at: number;
26
+ /** Seen, but changed on disk since (a resumed read whose file moved on): stale until read again. */
27
+ changed?: boolean;
26
28
  }
27
29
 
28
30
  /** What a stat says about a file on disk; equal stamps mean "no need to re-read". */
@@ -73,6 +75,16 @@ export class FileTracker {
73
75
  }
74
76
  }
75
77
 
78
+ /**
79
+ * Record a file the model saw in a form that is gone: it changed on disk
80
+ * since, and its old content is not kept. It counts as read, but stale.
81
+ */
82
+ observeChanged(path: string, at: number): void {
83
+ this.notified.delete(path);
84
+ this.stamps.delete(path);
85
+ this.files.set(path, { content: "", oversized: false, at, changed: true });
86
+ }
87
+
76
88
  forget(path: string): void {
77
89
  this.files.delete(path);
78
90
  this.notified.delete(path);
@@ -113,7 +125,7 @@ export class FileTracker {
113
125
  /** The content last seen, or undefined when untracked or tracked without content (oversized). */
114
126
  lastSeen(path: string): string | undefined {
115
127
  const known = this.files.get(path);
116
- return known && !known.oversized ? known.content : undefined;
128
+ return known && !known.oversized && !known.changed ? known.content : undefined;
117
129
  }
118
130
 
119
131
  get tracked(): string[] {
@@ -130,6 +142,7 @@ export class FileTracker {
130
142
  if (currentContent === undefined) return "absent";
131
143
  const known = this.files.get(path);
132
144
  if (!known) return "unread";
145
+ if (known.changed) return "stale";
133
146
  // Oversized files are tracked without content; treat them as fresh rather
134
147
  // than blocking edits we cannot reason about.
135
148
  if (known.oversized) return "fresh";
@@ -26,11 +26,17 @@
26
26
  * never as a message of its own: PreToolUse context is queued as a one-shot on
27
27
  * the reminder channel, so it lands INSIDE the tool result it belongs to,
28
28
  * persisted, at no extra turn; PostToolUse context is appended to the result
29
- * the same way; prompt/session/compaction context is returned from
30
- * `before_agent_start` as a custom message right AFTER the user's prompt. Until
29
+ * the same way; prompt, session and compaction context is a one-shot too,
30
+ * persisted as a `one-code:pending` entry when the hook answers, so it rides
31
+ * the next request whatever opens it (a prompt, a notification, the
32
+ * continuation after a mid-turn compaction) and counts as delivered only when
33
+ * that request's reply stays on the branch (lib/ledger/fold.ts). Until
31
34
  * 2026-09-05 each was a custom steer (one extra LLM turn per hook per tool,
32
35
  * pi draining one steer per call) and prompt context landed BEFORE the prompt
33
- * (STEERING-REVIEW-2026-09-05 M6).
36
+ * (STEERING-REVIEW-2026-09-05 M6); until 2026-10-21 prompt, session and
37
+ * compaction context was a `before_agent_start` message, taken off its queue
38
+ * before the request went out and held until a run started (round 3,
39
+ * R3-M14).
34
40
  *
35
41
  * Subagents: the tool hooks also run for a child's tool calls, through the
36
42
  * bridge published on `SUBAGENT_HOOK_CHANNEL` (subagent-bridge.ts): the child
@@ -38,6 +44,7 @@
38
44
  * (settings, consent, logging) and a payload naming the child.
39
45
  */
40
46
 
47
+ import { randomUUID } from "node:crypto";
41
48
  import { contentText } from "@earendil-works/pi-ai";
42
49
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
43
50
  import { RunOutcomeLatch } from "../lib/interrupt.ts";
@@ -60,14 +67,12 @@ import {
60
67
  } from "./protocol.ts";
61
68
  import { type HookCommand, type HooksSource, loadHookSettings } from "./settings.ts";
62
69
  import { persistIfLarge, sessionResultsDir } from "../lib/persisted-output.ts";
63
- import { REMINDER_CHANNEL, wrapReminder } from "../lib/reminders.ts";
70
+ import { REMINDER_CHANNEL, type ReminderPayload, wrapReminder } from "../lib/reminders.ts";
64
71
  import { type ChildHookCall, type ChildHookResult, type HookBridge, SUBAGENT_HOOK_CHANNEL, type SubagentHookPayload } from "./subagent-bridge.ts";
65
72
  import { projectHooksApproved } from "./trust.ts";
66
- import { QueuedDelivery } from "../lib/queued-delivery.ts";
73
+ import { PromptContextRouter } from "./prompt-context.ts";
67
74
  import { SKILL_INVOCATION_TYPE, type SkillInvocationDetails } from "../skill/invoke.ts";
68
- import { isNotificationDetails } from "../lib/notifications.ts";
69
75
  import { consentDialog } from "../lib/consent-dialogs.ts";
70
- import { isUserRun } from "../lib/prompt-options.ts";
71
76
  import { followWorkCwd } from "../lib/worktree-channel.ts";
72
77
 
73
78
  /** Claude Code's `hook_additional_context` attachment text (utils/messages.ts). */
@@ -84,11 +89,13 @@ export function hookContextText(event: CcHookEvent, text: string): string {
84
89
  * convention) and the model gets a preview plus the path — nothing is cut away.
85
90
  */
86
91
  const HOOK_MODEL_TEXT_CAP = 20_000;
87
- // A monotonic counter, not Date.now(), so two hooks finishing in the same
88
- // millisecond do not persist to the same file and clobber each other (review M2).
89
- let hookPersistSeq = 0;
92
+ // A random id, never a counter: the results directory is shared by every
93
+ // session of the project, and a counter restarts with each process and each
94
+ // /reload, so a later hook overwrote a file an earlier transcript points to
95
+ // (design.md section 14). Two hooks finishing in the same millisecond still get
96
+ // two files (review M2).
90
97
  function capHookText(text: string, ctx: HookDispatchCtx, label: string): string {
91
- return persistIfLarge(text, { dir: sessionResultsDir(ctx), id: `hook-${label}-${hookPersistSeq++}`, maxBytes: HOOK_MODEL_TEXT_CAP });
98
+ return persistIfLarge(text, { dir: sessionResultsDir(ctx), id: `hook-${label}-${randomUUID()}`, maxBytes: HOOK_MODEL_TEXT_CAP });
92
99
  }
93
100
 
94
101
  interface MatchedHook {
@@ -126,18 +133,12 @@ function isStopContinuation(event: unknown): boolean {
126
133
  export default function hooksExtension(pi: ExtensionAPI) {
127
134
  const workCwd = followWorkCwd(pi.events);
128
135
  let stopHookBlocks = 0;
129
- /** Context from UserPromptSubmit / SessionStart / PostCompact hooks, delivered with the next prompt. */
130
- let pendingPromptContext: Array<{ event: CcHookEvent; text: string }> = [];
131
- /**
132
- * UserPromptSubmit context for messages queued mid-turn, held until pi
133
- * delivers the message (lib/queued-delivery.ts): such a message never
134
- * reaches before_agent_start.
135
- */
136
- const queuedPromptContext = new QueuedDelivery<string>();
136
+ /** Prompt, session and compaction hook context, as a one-shot the next request carries (lib/reminders.ts). */
137
+ const deliverContext = (event: CcHookEvent, text: string) => pi.events.emit(REMINDER_CHANNEL, { text: hookContextText(event, text), placement: "last-append" });
138
+ /** UserPromptSubmit context, held until pi delivers its prompt (prompt-context.ts). */
139
+ const promptContext = new PromptContextRouter();
137
140
  /** The last context seen, for bridged child calls (dispatched parent-side). */
138
141
  let lastCtx: ExtensionContext | undefined;
139
- /** True once a prompt opened a turn in this session (before_agent_start). */
140
- let prompted = false;
141
142
  /**
142
143
  * The in-flight SessionStart hook dispatch. It runs OFF the `session_start`
143
144
  * path so the editor opens immediately (a slow hook used to hold the prompt
@@ -380,12 +381,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
380
381
 
381
382
  // ---- UserPromptSubmit ---------------------------------------------------
382
383
  pi.on("input", async (event, ctx) => {
384
+ // A new idle prompt means an earlier idle one was delivered or never will
385
+ // be (an extension after this one handled it): its context is stale. An
386
+ // extension's prompt (a command's sendUserMessage) counts too, or its
387
+ // message would take that context as its own.
388
+ if (!event.streamingBehavior) promptContext.dropIdle();
383
389
  if (event.source === "extension") return undefined;
384
390
  // Set for a message queued mid-turn (pi 0.86+ fires `input` for
385
391
  // steer/followUp). The Stop latch is reset in before_agent_start, where a
386
392
  // turn really starts: pi re-checks streaming after these handlers, so a
387
393
  // queued message whose turn ended during the hook opens a new turn.
388
- const queued = event.streamingBehavior;
389
394
  const gen = sessionGen;
390
395
  // Drain the backgrounded SessionStart dispatch before this prompt's own
391
396
  // UserPromptSubmit context, so their order in the first turn is unchanged
@@ -400,11 +405,13 @@ export default function hooksExtension(pi: ExtensionAPI) {
400
405
  notify(ctx, `Prompt blocked by UserPromptSubmit hook: ${outcome.block.reason}`);
401
406
  return { action: "handled" as const };
402
407
  }
403
- if (!outcome.additionalContext) return undefined;
404
- // A queued message's context waits for pi to deliver that message
405
- // (message_start below), so it rides the same request.
406
- if (queued) queuedPromptContext.hold(event.text, hookContextText("UserPromptSubmit", outcome.additionalContext));
407
- else pendingPromptContext.push({ event: "UserPromptSubmit", text: outcome.additionalContext });
408
+ // The context waits for pi to deliver this prompt (message_start below),
409
+ // queued mid-turn or not, so it rides the prompt's own request and never a
410
+ // run a notification opens first. A queued message is held without
411
+ // context too, so its delivery never takes an idle prompt's.
412
+ const text = outcome.additionalContext ? hookContextText("UserPromptSubmit", outcome.additionalContext) : undefined;
413
+ if (event.streamingBehavior) promptContext.holdQueued(event.text, text);
414
+ else if (text) promptContext.holdIdle(event.text, text);
408
415
  return undefined;
409
416
  });
410
417
 
@@ -414,41 +421,35 @@ export default function hooksExtension(pi: ExtensionAPI) {
414
421
  pi.on("message_start", async (event) => {
415
422
  const gen = sessionGen;
416
423
  const message = event.message;
417
- const emit = (text: string) => pi.events.emit(REMINDER_CHANNEL, { text, placement: "last-append" });
424
+ // Pinned to its prompt: when that prompt's request fails, the next prompt's
425
+ // request resends it with its own context, not with this one's.
426
+ const emit = (text: string) => pi.events.emit(REMINDER_CHANNEL, { text, placement: "last-append", promptTimestamp: message.timestamp } satisfies ReminderPayload);
418
427
  if (message.role === "user") {
419
- if (queuedPromptContext.isEmpty) return;
420
- const text = queuedPromptContext.release(contentText(message.content, ""));
428
+ if (promptContext.isEmpty) return;
429
+ // A queued message delivers as typed, so an exact match is its own; the
430
+ // idle prompt's message may carry its expansion instead.
431
+ const text = promptContext.release(contentText(message.content, ""));
421
432
  if (text) emit(text);
422
433
  return;
423
434
  }
424
- // A `/skill:` command the skill extension took over arrives as its hidden
425
- // message, carrying the typed text. It opens the turn when the command
426
- // was idle, through a sendMessage that never reaches before_agent_start,
427
- // so the prompt context waiting there rides this message too.
428
435
  if (message.role !== "custom") return;
429
- // A background completion that opens the session's first turn
430
- // (lib/prompt-options.ts) skips before_agent_start as well, so the
431
- // SessionStart context a typed first prompt would carry rides it.
432
- const opensFirstTurn = !prompted && isNotificationDetails(message.details);
433
- if (message.customType !== SKILL_INVOCATION_TYPE && !opensFirstTurn) return;
434
- if (opensFirstTurn) await drainSessionStart();
435
- if (gen !== sessionGen) return;
436
+ // A background completion that opens the first turn skips
437
+ // before_agent_start: the SessionStart context must be pending before its
438
+ // request is rendered.
439
+ await drainSessionStart();
440
+ // A `/skill:` command the skill extension took over arrives as its hidden
441
+ // message, carrying the typed text: that prompt's context rides it.
442
+ if (message.customType !== SKILL_INVOCATION_TYPE || promptContext.isEmpty) return;
436
443
  const input = (message.details as Partial<SkillInvocationDetails> | undefined)?.input;
437
- const queued = typeof input === "string" ? queuedPromptContext.release(input) : undefined;
438
- if (queued) emit(queued);
439
- const waiting = pendingPromptContext;
440
- pendingPromptContext = [];
441
- for (const { event: hookEvent, text } of waiting) emit(hookContextText(hookEvent, text));
444
+ const queued = typeof input === "string" ? promptContext.release(input, true) : undefined;
445
+ if (gen === sessionGen && queued) emit(queued);
442
446
  });
443
447
 
444
- // Prompt/session/compaction hook context rides the turn it belongs to as a
445
- // custom message pi appends right AFTER the user's prompt (the
446
- // before_agent_start `message` return). An idle `sendMessage` from here
447
- // landed it BEFORE the prompt (pi appends first, then adds the user message).
448
+ // The first turn waits for the backgrounded SessionStart dispatch, so its
449
+ // context is pending before the request is rendered.
448
450
  pi.on("before_agent_start", async (event, ctx) => {
449
451
  const gen = sessionGen;
450
452
  lastCtx = ctx;
451
- prompted = true;
452
453
  // The SessionStart dispatch runs in the background from session_start; the
453
454
  // first turn must wait for it so its additionalContext is present (and, on
454
455
  // first run, so its approval dialog gates execution before the model runs).
@@ -461,21 +462,6 @@ export default function hooksExtension(pi: ExtensionAPI) {
461
462
  // continuation keeps the cap counting: a notification or scheduled run
462
463
  // starts a fresh turn, as a prompt does.
463
464
  if (!isStopContinuation(event)) stopHookBlocks = 0;
464
- // UserPromptSubmit context belongs to the user's prompt; SessionStart and
465
- // PostCompact context belong to whichever run opens next, which on pi
466
- // 1.2.0 can be one a notification or scheduled fire opened. Its
467
- // message_start can no longer deliver them: `prompted` is set above.
468
- const userRun = isUserRun(event);
469
- const texts = pendingPromptContext.filter((entry) => userRun || entry.event !== "UserPromptSubmit");
470
- if (texts.length === 0) return;
471
- pendingPromptContext = pendingPromptContext.filter((entry) => !texts.includes(entry));
472
- return {
473
- message: {
474
- customType: "one-code:hook-context",
475
- content: texts.map(({ event, text }) => wrapReminder(hookContextText(event, text))).join("\n"),
476
- display: false,
477
- },
478
- };
479
465
  });
480
466
 
481
467
  // ---- SessionStart / SessionEnd -----------------------------------------
@@ -489,10 +475,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
489
475
  });
490
476
  // A later session_start (rapid /clear, resume) may have superseded this one
491
477
  // while its hook ran; discard the stale result instead of pushing it into
492
- // the new session's context (`pendingPromptContext` is shared and reset).
493
- if (gen === sessionGen && outcome.additionalContext) {
494
- pendingPromptContext.push({ event: "SessionStart", text: outcome.additionalContext });
495
- }
478
+ // the new session's context.
479
+ if (gen === sessionGen && outcome.additionalContext) deliverContext("SessionStart", outcome.additionalContext);
496
480
  };
497
481
 
498
482
  pi.on("session_start", (event, ctx) => {
@@ -500,10 +484,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
500
484
  sessionAbort = new AbortController();
501
485
  lastCtx = ctx;
502
486
  const gen = ++sessionGen;
503
- pendingPromptContext = [];
504
- queuedPromptContext.clear();
487
+ promptContext.clear();
505
488
  sessionStartPending = undefined;
506
- prompted = false;
507
489
  stopHookBlocks = 0;
508
490
  // Publish the child hook bridge (subagent-bridge.ts). The closures read
509
491
  // live parent state per call, so once per session start is enough.
@@ -632,8 +614,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
632
614
  pi.on("agent_settled", async (_event, ctx) => {
633
615
  const gen = sessionGen;
634
616
  // A queued message pi never delivered as typed (expanded, or taken over by
635
- // an extension) keeps no context for a later, unrelated prompt.
636
- queuedPromptContext.clear();
617
+ // an extension) keeps no context for a later, unrelated prompt; an idle
618
+ // prompt that opened no run of its own (one handled after this extension)
619
+ // keeps none either.
620
+ promptContext.clear();
637
621
  // CC skips Stop when the turn ended on an API error (a blocking hook would
638
622
  // spiral: error → block → retry → error) or on a user interrupt (its query
639
623
  // loop returns on the abort signal before the stop-hook step). An empty latch
@@ -711,7 +695,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
711
695
  };
712
696
  const outcome = await dispatch(ctx, "PostCompact", { candidates: [trigger] }, payload);
713
697
  if (gen !== sessionGen) return;
714
- if (outcome.additionalContext) pendingPromptContext.push({ event: "PostCompact", text: outcome.additionalContext });
698
+ if (outcome.additionalContext) deliverContext("PostCompact", outcome.additionalContext);
715
699
  // CC fires SessionStart(source: "compact") after compaction. Awaited inline
716
700
  // (not backgrounded), so the current generation is still in force.
717
701
  await dispatchSessionStart(ctx, "compact", sessionGen);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Which delivered message takes a UserPromptSubmit hook's context. Pure (no pi
3
+ * imports).
4
+ *
5
+ * A message queued mid-turn delivers as typed, so the user message with its
6
+ * text is its own (lib/queued-delivery.ts). A prompt typed while idle is
7
+ * expanded by pi after the input handlers (a prompt template, a `/skill:` no
8
+ * extension took), and a later handler may transform it, so its message
9
+ * rarely carries the typed text. It is the next prompt pi delivers, though:
10
+ * the first user message no queued message claims takes it.
11
+ *
12
+ * A queued message is held even when its hook added no context, so its
13
+ * delivery claims nothing instead of taking an idle prompt's context.
14
+ */
15
+
16
+ import { QueuedDelivery } from "../lib/queued-delivery.ts";
17
+
18
+ export class PromptContextRouter {
19
+ /** Context per queued message; null marks one whose hook added none. */
20
+ private readonly queued = new QueuedDelivery<string | null>();
21
+ private idle: Array<{ text: string; payload: string }> = [];
22
+
23
+ /** A message queued mid-turn, with its hook's context or none. */
24
+ holdQueued(text: string, payload: string | undefined): void {
25
+ this.queued.hold(text, payload ?? null);
26
+ }
27
+
28
+ /** A prompt typed while idle and its hook's context. */
29
+ holdIdle(text: string, payload: string): void {
30
+ this.idle.push({ text, payload });
31
+ }
32
+
33
+ /** A new idle prompt: an earlier idle one was delivered or never will be, so its context is stale. */
34
+ dropIdle(): void {
35
+ this.idle = [];
36
+ }
37
+
38
+ get isEmpty(): boolean {
39
+ return this.queued.isEmpty && this.idle.length === 0;
40
+ }
41
+
42
+ /**
43
+ * The context for a delivered user message with this text: a queued message
44
+ * typed so claims its own (none, when its hook added none); else the idle
45
+ * prompt typed so; else, unless `exact`, the oldest idle prompt. Released once.
46
+ */
47
+ release(text: string, exact = false): string | undefined {
48
+ const queued = this.queued.release(text);
49
+ if (queued !== undefined) return queued ?? undefined;
50
+ const at = this.idle.findIndex((entry) => entry.text === text);
51
+ const index = at === -1 && !exact && this.idle.length > 0 ? 0 : at;
52
+ return index === -1 ? undefined : this.idle.splice(index, 1)[0].payload;
53
+ }
54
+
55
+ clear(): void {
56
+ this.queued.clear();
57
+ this.idle = [];
58
+ }
59
+ }
@@ -20,6 +20,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { dirname } from "node:path";
21
21
  import { boundConsentItems } from "../lib/consent-preview.ts";
22
22
  import { consentStorePath } from "../lib/consent-stores.ts";
23
+ import { processState } from "../lib/process-state.ts";
23
24
  import type { HooksSource } from "./settings.ts";
24
25
 
25
26
  interface ApprovalStore {
@@ -107,8 +108,13 @@ export interface TrustDecisionDeps {
107
108
  noPrompt?: boolean;
108
109
  }
109
110
 
110
- /** Process-lifetime memory of declined/approved hashes per project, so a dispatch storm asks once. */
111
- const sessionDecisions = new Map<string, boolean>();
111
+ /**
112
+ * Process-lifetime memory of declined/approved hashes per project, so a
113
+ * dispatch storm asks once and a "No" holds until the process ends. Process
114
+ * state (lib/process-state.ts), not module scope: pi evaluates this module
115
+ * again at `/reload`, which asked again (lifecycle grid R3-L13).
116
+ */
117
+ const sessionDecisions = () => processState("hooks:trust-decisions", () => new Map<string, boolean>());
112
118
  /** Undefined: the dispatch that opened the prompt aborted, so nobody decided. */
113
119
  const pendingPrompts = new Map<string, Promise<boolean | undefined>>();
114
120
 
@@ -127,15 +133,15 @@ export async function projectHooksApproved(
127
133
  const configHash = hashProjectHooks(projectSources);
128
134
  const key = `${projectRoot}:${configHash}`;
129
135
 
130
- const remembered = sessionDecisions.get(key);
136
+ const remembered = sessionDecisions().get(key);
131
137
  if (remembered !== undefined) return remembered;
132
138
  if (readStoredApproval(projectRoot, deps.storePath) === configHash) {
133
- sessionDecisions.set(key, true);
139
+ sessionDecisions().set(key, true);
134
140
  return true;
135
141
  }
136
142
  if (deps.noPrompt) return false;
137
143
  if (!deps.hasUI) {
138
- sessionDecisions.set(key, false);
144
+ sessionDecisions().set(key, false);
139
145
  deps.notify("Project hooks skipped: not yet approved and no UI to ask (user/managed hooks still run).");
140
146
  return false;
141
147
  }
@@ -150,7 +156,7 @@ export async function projectHooksApproved(
150
156
  )) === true;
151
157
  // An interrupted dialog is no trust decision, even if a reply raced abort.
152
158
  if (deps.signal?.aborted) return undefined;
153
- sessionDecisions.set(key, approved);
159
+ sessionDecisions().set(key, approved);
154
160
  if (approved) persistApproval(projectRoot, configHash, deps.storePath);
155
161
  else deps.notify("Project hooks disabled for this session (user/managed hooks still run).");
156
162
  return approved;
@@ -165,6 +171,6 @@ export async function projectHooksApproved(
165
171
 
166
172
  /** Test seam. */
167
173
  export function resetTrustSessionState(): void {
168
- sessionDecisions.clear();
174
+ sessionDecisions().clear();
169
175
  pendingPrompts.clear();
170
176
  }