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.
- package/README.md +1 -1
- package/docs/guide/differences-from-claude-code.md +9 -6
- package/docs/guide/installation.md +3 -4
- package/docs/guide/troubleshooting.md +1 -1
- package/extensions/auto-mode/denials.ts +3 -2
- package/extensions/background/cron.ts +127 -0
- package/extensions/background/index.ts +135 -47
- package/extensions/background/loop-fire.ts +39 -2
- package/extensions/background/switch-guard.ts +10 -6
- package/extensions/background/wakeup.ts +21 -2
- package/extensions/bash/index.ts +8 -30
- package/extensions/btw/index.ts +32 -18
- package/extensions/cache-marks/index.ts +42 -0
- package/extensions/claude-context/index.ts +157 -172
- package/extensions/compaction/index.ts +11 -8
- package/extensions/context-budget/budget.ts +16 -2
- package/extensions/context-budget/index.ts +15 -14
- package/extensions/context-management/index.ts +11 -28
- package/extensions/effort/index.ts +70 -7
- package/extensions/file-tracker/index.ts +111 -44
- package/extensions/file-tracker/replay.ts +76 -49
- package/extensions/file-tracker/restore.ts +12 -9
- package/extensions/file-tracker/tracker.ts +14 -1
- package/extensions/hooks/index.ts +60 -76
- package/extensions/hooks/prompt-context.ts +59 -0
- package/extensions/hooks/trust.ts +13 -7
- package/extensions/ledger/index.ts +46 -0
- package/extensions/ledger/wire.ts +154 -0
- package/extensions/lib/agent-cron.ts +17 -1
- package/extensions/lib/agent-loader.ts +3 -2
- package/extensions/lib/anthropic-auth.ts +98 -0
- package/extensions/lib/child-extensions.ts +14 -4
- package/extensions/lib/context-facts.ts +75 -56
- package/extensions/lib/covered-messages.ts +23 -0
- package/extensions/lib/deferred.ts +152 -12
- package/extensions/lib/ledger/fold-memo.ts +49 -0
- package/extensions/lib/ledger/fold.ts +265 -0
- package/extensions/lib/ledger/layout.ts +72 -0
- package/extensions/lib/ledger/plan.ts +102 -0
- package/extensions/lib/ledger/records.ts +276 -0
- package/extensions/lib/ledger/state.ts +47 -0
- package/extensions/lib/ledger/validate.ts +141 -0
- package/extensions/lib/ledger/wire.ts +632 -0
- package/extensions/lib/local-command.ts +24 -6
- package/extensions/lib/model-catalog/huggingface.json +5 -1
- package/extensions/lib/model-catalog/models-dev.json +10535 -10299
- package/extensions/lib/model-catalog/openrouter.json +191 -393
- package/extensions/lib/notifications.ts +57 -17
- package/extensions/lib/pi-version.ts +25 -8
- package/extensions/lib/plan-mode-channels.ts +29 -7
- package/extensions/lib/previous-mark.ts +101 -0
- package/extensions/lib/process-state.ts +48 -0
- package/extensions/lib/process-tree.ts +3 -2
- package/extensions/lib/reminders.ts +327 -621
- package/extensions/lib/request-replay.ts +186 -78
- package/extensions/lib/side-call.ts +11 -0
- package/extensions/lib/subagent-handback.ts +0 -1
- package/extensions/lib/tool-additions.ts +2 -22
- package/extensions/lib/turn-budget-layout.ts +18 -2
- package/extensions/lsp/index.ts +6 -8
- package/extensions/mcp/announce.ts +63 -98
- package/extensions/mcp/index.ts +51 -90
- package/extensions/mcp/trust.ts +13 -6
- package/extensions/model-default/index.ts +86 -19
- package/extensions/permissions/index.ts +157 -48
- package/extensions/permissions/modes.ts +5 -0
- package/extensions/plan-mode/index.ts +142 -45
- package/extensions/plan-mode/run-state.ts +26 -0
- package/extensions/plugins/index.ts +4 -1
- package/extensions/powershell/index.ts +2 -0
- package/extensions/recap/index.ts +11 -14
- package/extensions/search-tools/index.ts +2 -0
- package/extensions/skill/announce.ts +30 -0
- package/extensions/skill/index.ts +65 -48
- package/extensions/skill/invoke.ts +0 -67
- package/extensions/subagents/agents.ts +30 -8
- package/extensions/subagents/fork-cache.ts +9 -3
- package/extensions/subagents/index.ts +180 -107
- package/extensions/subagents/outcome.ts +3 -0
- package/extensions/subagents/parent-mode.ts +50 -0
- package/extensions/subagents/pending-claim.ts +18 -1
- package/extensions/subagents/runner.ts +29 -2
- package/extensions/subagents/runs.ts +5 -0
- package/extensions/system-prompt/index.ts +40 -6
- package/extensions/system-prompt/template.ts +10 -1
- package/extensions/system-reminder/index.ts +174 -202
- package/extensions/tool-search/index.ts +241 -167
- package/extensions/workflow/agent-session.ts +4 -1
- package/extensions/workflow/index.ts +16 -4
- package/extensions/workflow/run-manager.ts +4 -0
- package/extensions/worktree/index.ts +50 -14
- package/package.json +5 -2
- package/extensions/lib/context-stack.ts +0 -81
- package/extensions/subagents/announce.ts +0 -69
- 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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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;
|
|
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
|
|
31
|
-
*
|
|
32
|
-
* result
|
|
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
|
-
|
|
35
|
-
const
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* the
|
|
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
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
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
|
|
79
|
-
* kept turns returned: those files are in context already, so
|
|
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 [...
|
|
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
|
|
38
|
-
*
|
|
39
|
-
* one, nothing for a file that could not be read. A file's blocks are kept
|
|
40
|
-
* while the running total stays within the overall cap. `restored` lists
|
|
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:
|
|
44
|
-
const blocks:
|
|
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
|
|
30
|
-
*
|
|
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 {
|
|
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
|
|
88
|
-
//
|
|
89
|
-
|
|
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}-${
|
|
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
|
-
/**
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
|
|
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
|
-
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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 (
|
|
420
|
-
|
|
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
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (
|
|
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" ?
|
|
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
|
-
//
|
|
445
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
/**
|
|
111
|
-
|
|
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
|
}
|