one-code-extension 0.1.3 → 0.3.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/LICENSE +20 -0
- package/README.md +200 -172
- package/agents/explore.md +9 -6
- package/agents/general-purpose.md +1 -1
- package/agents/plan.md +6 -4
- package/docs/guide/README.md +52 -0
- package/docs/guide/bring-your-claude-code-setup.md +126 -0
- package/docs/guide/configuration.md +171 -0
- package/docs/guide/differences-from-claude-code.md +150 -0
- package/docs/guide/doctor.md +86 -0
- package/docs/guide/hooks.md +109 -0
- package/docs/guide/installation.md +124 -0
- package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
- package/docs/guide/providers-and-models.md +194 -0
- package/docs/guide/reference.md +224 -0
- package/docs/guide/sessions-and-context.md +190 -0
- package/docs/guide/skills-plugins-and-mcp.md +176 -0
- package/docs/guide/subagents-and-workflows.md +193 -0
- package/docs/guide/tasks-and-background-work.md +111 -0
- package/docs/guide/terminal-interface.md +155 -0
- package/docs/guide/tools.md +175 -0
- package/docs/guide/troubleshooting.md +175 -0
- package/extensions/ask-user/index.ts +65 -71
- package/extensions/ask-user/questions.ts +15 -28
- package/extensions/ask-user/widget.ts +533 -0
- package/extensions/auto-mode/actions.ts +9 -26
- package/extensions/auto-mode/classifier-prompt.ts +127 -0
- package/extensions/auto-mode/classifier.ts +305 -227
- package/extensions/auto-mode/config.ts +279 -77
- package/extensions/auto-mode/defaults.ts +17 -60
- package/extensions/auto-mode/instructions.ts +2 -1
- package/extensions/auto-mode/model-picker.ts +53 -31
- package/extensions/auto-mode/model-select.ts +153 -82
- package/extensions/auto-mode/paths.ts +13 -1
- package/extensions/auto-mode/pause.ts +8 -0
- package/extensions/auto-mode/prompt.ts +205 -238
- package/extensions/auto-mode/recoverability.ts +229 -0
- package/extensions/auto-mode/rules.ts +92 -75
- package/extensions/auto-mode/safety-floor.ts +43 -12
- package/extensions/auto-mode/sensitive.ts +11 -0
- package/extensions/auto-mode/setup-run.ts +245 -0
- package/extensions/auto-mode/setup.ts +235 -0
- package/extensions/auto-mode/shell-analysis.ts +332 -26
- package/extensions/auto-mode/transcript.ts +102 -0
- package/extensions/background/index.ts +256 -43
- package/extensions/background/monitor-batch.ts +58 -0
- package/extensions/background/registry.ts +9 -0
- package/extensions/background/wakeup.ts +45 -0
- package/extensions/bash/background.ts +47 -22
- package/extensions/bash/guards.ts +229 -0
- package/extensions/bash/index.ts +81 -19
- package/extensions/branding/index.ts +44 -18
- package/extensions/branding/startup.ts +23 -8
- package/extensions/claude-compat/index.ts +17 -2
- package/extensions/claude-context/index.ts +41 -12
- package/extensions/clear/index.ts +27 -0
- package/extensions/compaction/fit.ts +121 -0
- package/extensions/compaction/index.ts +176 -71
- package/extensions/compaction/prompt.ts +21 -1
- package/extensions/context-budget/budget.ts +61 -0
- package/extensions/context-budget/index.ts +66 -0
- package/extensions/context-management/index.ts +40 -16
- package/extensions/doctor/build.ts +226 -0
- package/extensions/doctor/cli.ts +99 -0
- package/extensions/doctor/compat.ts +532 -0
- package/extensions/doctor/dependencies.ts +186 -0
- package/extensions/doctor/fix-prompt.ts +125 -0
- package/extensions/doctor/index.ts +308 -0
- package/extensions/doctor/models.ts +231 -0
- package/extensions/doctor/presets.ts +208 -0
- package/extensions/doctor/report.ts +189 -0
- package/extensions/doctor/update-lookup.ts +46 -0
- package/extensions/doctor/viewer.ts +124 -0
- package/extensions/effort/index.ts +41 -4
- package/extensions/effort/slider.ts +33 -2
- package/extensions/file-tracker/index.ts +143 -20
- package/extensions/file-tracker/replay.ts +46 -0
- package/extensions/file-tracker/tracker.ts +51 -4
- package/extensions/footer/footer-line.ts +207 -0
- package/extensions/footer/index.ts +152 -0
- package/extensions/footer/pr.ts +53 -0
- package/extensions/hooks/executor.ts +57 -20
- package/extensions/hooks/formatter-notice.ts +76 -0
- package/extensions/hooks/index.ts +314 -73
- package/extensions/hooks/matcher.ts +35 -1
- package/extensions/hooks/plugin-hooks.ts +3 -3
- package/extensions/hooks/protocol.ts +58 -0
- package/extensions/hooks/subagent-bridge.ts +77 -0
- package/extensions/hooks/trust.ts +15 -4
- package/extensions/init/index.ts +26 -0
- package/extensions/init/prompt.ts +40 -0
- package/extensions/interrupted/index.ts +35 -0
- package/extensions/interrupted/line.ts +9 -0
- package/extensions/lib/abort.ts +15 -0
- package/extensions/lib/agent-loader.ts +183 -0
- package/extensions/lib/anthropic-payload.ts +15 -0
- package/extensions/lib/atomic-write.ts +56 -0
- package/extensions/lib/bridge-watch.ts +20 -0
- package/extensions/lib/cache-retention.ts +60 -0
- package/extensions/lib/capability-index.ts +466 -0
- package/extensions/lib/claude-context.ts +318 -27
- package/extensions/lib/claude-settings.ts +64 -0
- package/extensions/lib/consent-preview.ts +30 -0
- package/extensions/lib/deferred.ts +144 -13
- package/extensions/lib/favorites.ts +43 -0
- package/extensions/lib/fetch-timeout.ts +15 -0
- package/extensions/lib/frontmatter.ts +31 -0
- package/extensions/lib/git.ts +44 -2
- package/extensions/lib/hook-gate.ts +90 -0
- package/extensions/lib/interrupt.ts +94 -0
- package/extensions/lib/mcp-overrides.ts +61 -0
- package/extensions/lib/mcp-share.ts +25 -0
- package/extensions/lib/mcp-status.ts +34 -0
- package/extensions/lib/memory.ts +41 -6
- package/extensions/lib/model-facts.generated.json +2480 -0
- package/extensions/lib/model-facts.ts +162 -0
- package/extensions/lib/model-policy.ts +145 -189
- package/extensions/lib/model-tier.ts +480 -64
- package/extensions/lib/model-unusable.ts +37 -0
- package/extensions/lib/notifications.ts +320 -1
- package/extensions/lib/one-code-settings.ts +63 -0
- package/extensions/lib/original-command.ts +71 -0
- package/extensions/lib/package-version.ts +31 -0
- package/extensions/lib/paths.ts +39 -4
- package/extensions/lib/permission-gate.ts +192 -0
- package/extensions/lib/persisted-output.ts +18 -0
- package/extensions/lib/pi-ai-estimate.ts +160 -0
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/plan-mode-channels.ts +11 -0
- package/extensions/lib/plugin-overrides.ts +24 -0
- package/extensions/lib/plugin-root.ts +44 -0
- package/extensions/lib/plugins.ts +233 -54
- package/extensions/lib/prefix-warm-gate.ts +157 -0
- package/extensions/lib/process-tree.ts +56 -0
- package/extensions/lib/reminders.ts +321 -68
- package/extensions/lib/scratchpad.ts +11 -8
- package/extensions/lib/session-lifecycle.ts +28 -0
- package/extensions/lib/settings-channels.ts +14 -0
- package/extensions/lib/shell-tasks.ts +54 -0
- package/extensions/lib/skill-overrides.ts +86 -0
- package/extensions/lib/skill-scan.ts +110 -0
- package/extensions/lib/text-width.ts +267 -0
- package/extensions/lib/tui-render.ts +281 -54
- package/extensions/lib/usage-bus.ts +75 -0
- package/extensions/lib/usage-tracker.ts +67 -0
- package/extensions/lib/worktree-isolation.ts +170 -0
- package/extensions/lsp/client.ts +69 -12
- package/extensions/lsp/format.ts +12 -2
- package/extensions/lsp/index.ts +273 -84
- package/extensions/lsp/install-hints.ts +43 -0
- package/extensions/lsp/keep-alive.ts +19 -0
- package/extensions/lsp/plugin-servers.ts +331 -0
- package/extensions/lsp/watcher.ts +157 -0
- package/extensions/mcp/announce.ts +99 -0
- package/extensions/mcp/client.ts +181 -22
- package/extensions/mcp/config.ts +55 -24
- package/extensions/mcp/index.ts +586 -86
- package/extensions/mcp/oauth/browser.ts +26 -0
- package/extensions/mcp/oauth/callback.ts +104 -0
- package/extensions/mcp/oauth/flow.ts +68 -0
- package/extensions/mcp/oauth/provider.ts +147 -0
- package/extensions/mcp/oauth/store.ts +86 -0
- package/extensions/mcp/panel/keys.ts +34 -0
- package/extensions/mcp/panel/model.ts +62 -0
- package/extensions/mcp/panel/render.ts +141 -0
- package/extensions/mcp/panel/state.ts +108 -0
- package/extensions/mcp/schema.ts +83 -6
- package/extensions/mcp/trust.ts +340 -0
- package/extensions/memory/entries.ts +163 -0
- package/extensions/memory/index.ts +119 -2
- package/extensions/memory/open-external.ts +97 -0
- package/extensions/memory/panel.ts +124 -0
- package/extensions/model-default/index.ts +74 -0
- package/extensions/notebook/index.ts +36 -18
- package/extensions/notebook/notebook.ts +110 -17
- package/extensions/permissions/index.ts +1012 -247
- package/extensions/permissions/matcher.ts +488 -57
- package/extensions/permissions/modes.ts +55 -7
- package/extensions/permissions/project-trust.ts +81 -0
- package/extensions/permissions/protected-paths.ts +16 -3
- package/extensions/permissions/rule-prose.ts +79 -0
- package/extensions/permissions/session-grant.ts +91 -0
- package/extensions/permissions/settings.ts +112 -33
- package/extensions/permissions/subagent-gate.ts +57 -0
- package/extensions/plan-mode/index.ts +143 -33
- package/extensions/plan-mode/reminder.ts +16 -19
- package/extensions/plan-mode/viewer.ts +40 -31
- package/extensions/plugins/counts.ts +68 -0
- package/extensions/plugins/index.ts +496 -32
- package/extensions/plugins/install/install.ts +167 -0
- package/extensions/plugins/install/paths.ts +23 -0
- package/extensions/plugins/install/registry.ts +72 -0
- package/extensions/plugins/marketplace/git.ts +103 -0
- package/extensions/plugins/marketplace/official.ts +36 -0
- package/extensions/plugins/marketplace/parse.ts +94 -0
- package/extensions/plugins/marketplace/registry.ts +60 -0
- package/extensions/plugins/marketplace/sync.ts +146 -0
- package/extensions/plugins/marketplace/types.ts +128 -0
- package/extensions/plugins/panel/keys.ts +52 -0
- package/extensions/plugins/panel/render.ts +315 -0
- package/extensions/plugins/panel/rows.ts +230 -0
- package/extensions/plugins/panel/state.ts +333 -0
- package/extensions/recap/index.ts +215 -0
- package/extensions/recap/prompt.ts +44 -0
- package/extensions/recap/scheduler.ts +87 -0
- package/extensions/search-tools/index.ts +0 -0
- package/extensions/search-tools/tools.ts +7 -5
- package/extensions/skill/index.ts +360 -34
- package/extensions/skill/invoke.ts +185 -0
- package/extensions/skill/listing.ts +21 -0
- package/extensions/skill/panel/keys.ts +41 -0
- package/extensions/skill/panel/render.ts +91 -0
- package/extensions/skill/panel/state.ts +115 -0
- package/extensions/spinner/index.ts +110 -0
- package/extensions/spinner/line.ts +51 -0
- package/extensions/spinner/verbs.ts +201 -0
- package/extensions/subagents/agents.ts +88 -44
- package/extensions/subagents/default-model.ts +62 -32
- package/extensions/subagents/delegation-steer.ts +23 -0
- package/extensions/subagents/hand-back-review.ts +67 -0
- package/extensions/subagents/index.ts +1545 -346
- package/extensions/subagents/live-runs.ts +293 -0
- package/extensions/subagents/model-select.ts +252 -82
- package/extensions/subagents/outcome.ts +90 -0
- package/extensions/subagents/panel-keys.ts +41 -0
- package/extensions/subagents/panel-render.ts +357 -0
- package/extensions/subagents/panel-widget.ts +330 -0
- package/extensions/subagents/pending-claim.ts +45 -0
- package/extensions/subagents/prose.ts +105 -0
- package/extensions/subagents/runner.ts +674 -0
- package/extensions/subagents/runs.ts +68 -1
- package/extensions/subagents/send-to-main-tool.ts +55 -0
- package/extensions/subagents/session-turns.ts +138 -0
- package/extensions/subagents/shell-panel.ts +198 -0
- package/extensions/subagents/usage.ts +16 -4
- package/extensions/subagents/worktree.ts +13 -0
- package/extensions/system-prompt/environment.ts +5 -3
- package/extensions/system-prompt/git-status.ts +93 -0
- package/extensions/system-prompt/index.ts +23 -1
- package/extensions/system-prompt/template.ts +34 -4
- package/extensions/system-prompt/tiers/common.ts +31 -6
- package/extensions/system-prompt/tiers/low.ts +13 -3
- package/extensions/system-prompt/tiers/mid.ts +21 -8
- package/extensions/system-reminder/index.ts +53 -9
- package/extensions/tasks/index.ts +65 -16
- package/extensions/tasks/store.ts +76 -3
- package/extensions/tool-search/announce.ts +42 -0
- package/extensions/tool-search/index.ts +136 -13
- package/extensions/turn-duration/index.ts +60 -0
- package/extensions/turn-duration/line.ts +22 -0
- package/extensions/turn-duration/span.ts +41 -0
- package/extensions/turn-duration/verbs.ts +24 -0
- package/extensions/web/backends.ts +421 -0
- package/extensions/web/index.ts +178 -21
- package/extensions/web-fetch/index.ts +74 -18
- package/extensions/web-fetch/summarize.ts +1 -1
- package/extensions/workflow/agent-session.ts +182 -112
- package/extensions/workflow/globals.ts +129 -106
- package/extensions/workflow/index.ts +343 -28
- package/extensions/workflow/records.ts +118 -0
- package/extensions/workflow/run-manager.ts +80 -31
- package/extensions/workflow/saved-workflows.ts +2 -1
- package/extensions/workflow/script-worker.mjs +110 -0
- package/extensions/workflow/types.ts +74 -8
- package/extensions/workflow/viewer.ts +562 -0
- package/extensions/workflow/vm-runtime.ts +118 -22
- package/extensions/workflow/widget.ts +126 -18
- package/extensions/worktree/guards.ts +240 -0
- package/extensions/worktree/index.ts +30 -5
- package/extensions/worktree/rewrite.ts +32 -16
- package/package.json +20 -10
- package/skills/code-review/SKILL.md +165 -0
- package/skills/fewer-permission-prompts/SKILL.md +61 -0
- package/skills/security-review/SKILL.md +178 -0
- package/skills/simplify/SKILL.md +70 -0
- package/themes/{one-code-light.json → onecode-light.json} +6 -1
- package/themes/{one-code.json → onecode.json} +6 -1
- package/types/app.d.ts +8 -1
- package/types/community-packages.d.ts +45 -2
- package/docs/decisions/auto-mode.md +0 -526
- package/docs/decisions/branding.md +0 -127
- package/docs/decisions/code-review-remediation.md +0 -106
- package/docs/decisions/compaction.md +0 -140
- package/docs/decisions/distribution.md +0 -168
- package/docs/decisions/hooks.md +0 -64
- package/docs/decisions/lsp.md +0 -58
- package/docs/decisions/mcp.md +0 -104
- package/docs/decisions/memory-state.md +0 -170
- package/docs/decisions/model-policy.md +0 -126
- package/docs/decisions/modes.md +0 -178
- package/docs/decisions/skills-plugins.md +0 -56
- package/docs/decisions/subagents-workflows.md +0 -225
- package/docs/decisions/system-prompt.md +0 -98
- package/docs/decisions/tools.md +0 -407
- package/docs/decisions/tui.md +0 -202
- package/docs/decisions.md +0 -148
- package/extensions/subagents/child.ts +0 -414
- package/extensions/subagents/rpc-turns.ts +0 -123
- package/extensions/todo/index.ts +0 -133
- package/extensions/workflow/permission-gate.ts +0 -71
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting for the One Code footer — the single status line that
|
|
3
|
+
* replaces pi's built-in footer (`ctx.ui.setFooter`). It surfaces what a user
|
|
4
|
+
* actually watches — context fill, total cost, cache-hit health, the PR, the
|
|
5
|
+
* model, and the effort level — and drops pi's raw ↑/↓/R/W token counters.
|
|
6
|
+
*
|
|
7
|
+
* Everything here is pure and paint-injected so it unit-tests with an identity
|
|
8
|
+
* paint. `paint(color, text)` matches `safeThemePaint`; in tests it returns the
|
|
9
|
+
* text unchanged, so assertions see the plain line. Inclusion decisions use
|
|
10
|
+
* plain lengths only, so the painted result (ANSI adds zero display width)
|
|
11
|
+
* always fits the width it was built for.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { costOf, usageEntryCost } from "../lib/usage-bus.ts";
|
|
15
|
+
|
|
16
|
+
export type Paint = (color: string, text: string) => string;
|
|
17
|
+
|
|
18
|
+
export interface FooterData {
|
|
19
|
+
/** Absolute cwd; `home` is folded to `~` for display. */
|
|
20
|
+
cwd: string;
|
|
21
|
+
home: string;
|
|
22
|
+
branch?: string;
|
|
23
|
+
/** Context tokens in use and the model's window; both needed to show fill. */
|
|
24
|
+
contextTokens?: number;
|
|
25
|
+
contextWindow?: number;
|
|
26
|
+
/** Context percentage, shown in parens after the fill; `null`/undefined omits it
|
|
27
|
+
* (e.g. right after compaction, unknown until the next reply). */
|
|
28
|
+
contextPercent?: number | null;
|
|
29
|
+
/** All-in cost across every LLM call this session (main + subagents + one-shots). */
|
|
30
|
+
cost: number;
|
|
31
|
+
/** Latest turn's cache-hit rate; a per-turn health signal, not cumulative. */
|
|
32
|
+
cacheHitPercent?: number;
|
|
33
|
+
/** Open PR number for the current branch, when a `gh` lookup found one. */
|
|
34
|
+
pr?: number;
|
|
35
|
+
/** Short model name (already abbreviated by the caller). */
|
|
36
|
+
model?: string;
|
|
37
|
+
/** Effort label to show after the model — the level, or "✦ ultracode". */
|
|
38
|
+
effort?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** pi's token formatter, replicated so a "1.1M" here reads like pi's own. */
|
|
42
|
+
export function formatTokens(count: number): string {
|
|
43
|
+
if (count < 1000) return count.toString();
|
|
44
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
45
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
46
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
47
|
+
return `${Math.round(count / 1000000)}M`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cost with cent-fraction precision for small spends, coarser once it grows. */
|
|
51
|
+
export function formatCost(cost: number): string {
|
|
52
|
+
return `$${cost.toFixed(cost < 100 ? 3 : 2)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fold the home prefix to `~`. */
|
|
56
|
+
function tilde(cwd: string, home: string): string {
|
|
57
|
+
if (home && (cwd === home || cwd.startsWith(`${home}/`))) return `~${cwd.slice(home.length)}`;
|
|
58
|
+
return cwd;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fit a path into `max` columns, keeping the tail (the deepest, most specific
|
|
63
|
+
* folders) with a leading `…`. `max <= 1` collapses to just `…`.
|
|
64
|
+
*/
|
|
65
|
+
function truncatePath(path: string, max: number): string {
|
|
66
|
+
if (path.length <= max) return path;
|
|
67
|
+
if (max <= 1) return "…";
|
|
68
|
+
return `…${path.slice(path.length - (max - 1))}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Part {
|
|
72
|
+
plain: string;
|
|
73
|
+
painted: string;
|
|
74
|
+
/** Higher drops first when the line is too narrow; 0 = never dropped. */
|
|
75
|
+
dropPriority: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SEP = " · ";
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the footer as a single line fit to `width`. When too narrow, the
|
|
82
|
+
* cache-hit rate drops first, then the PR (with the path/branch on the left),
|
|
83
|
+
* then the path itself; context, cost, model, and effort are the core and are
|
|
84
|
+
* kept (truncation is the last resort).
|
|
85
|
+
*/
|
|
86
|
+
export function buildFooterLines(data: FooterData, width: number, paint: Paint): string[] {
|
|
87
|
+
if (width <= 0) return [""];
|
|
88
|
+
|
|
89
|
+
const parts: Part[] = [];
|
|
90
|
+
// A labelled metric: the label is muted, the value takes `color`. The plain
|
|
91
|
+
// form (label + value) drives the width/drop math; the painted form colours
|
|
92
|
+
// the two halves separately.
|
|
93
|
+
const push = (label: string, value: string, color: string, dropPriority = 0) => {
|
|
94
|
+
if (!value) return;
|
|
95
|
+
const plain = label ? `${label} ${value}` : value;
|
|
96
|
+
const painted = label ? `${paint("muted", label)} ${paint(color, value)}` : paint(color, value);
|
|
97
|
+
parts.push({ plain, painted, dropPriority });
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Context fill as "usage: tokens/window (pct%)".
|
|
101
|
+
if (typeof data.contextTokens === "number" && typeof data.contextWindow === "number" && data.contextWindow > 0) {
|
|
102
|
+
const pct = typeof data.contextPercent === "number" ? ` (${data.contextPercent.toFixed(0)}%)` : "";
|
|
103
|
+
push("usage:", `${formatTokens(data.contextTokens)}/${formatTokens(data.contextWindow)}${pct}`, "dim");
|
|
104
|
+
}
|
|
105
|
+
// Total cost, always shown once known.
|
|
106
|
+
push("cost:", formatCost(data.cost), "dim");
|
|
107
|
+
// Cache-hit: a warning only when it drops low, otherwise dim; droppable.
|
|
108
|
+
if (typeof data.cacheHitPercent === "number") {
|
|
109
|
+
const color = data.cacheHitPercent < 20 ? "error" : data.cacheHitPercent < 50 ? "warning" : "dim";
|
|
110
|
+
push("cache-hit:", `${data.cacheHitPercent.toFixed(0)}%`, color, 2);
|
|
111
|
+
}
|
|
112
|
+
// Model, then effort — no labels; both self-describing.
|
|
113
|
+
if (data.model) push("", data.model, "dim");
|
|
114
|
+
if (data.effort) push("", data.effort, "accent");
|
|
115
|
+
|
|
116
|
+
// Left chunk: path + branch, with the open PR attached to the branch as
|
|
117
|
+
// "⎇ branch ← PR #123". The branch (short, useful) is kept and the path is
|
|
118
|
+
// head-truncated to fit; when the PR would not fit, it is dropped before the
|
|
119
|
+
// branch (bare-branch fallback), and only when there is no room even for the
|
|
120
|
+
// bare branch does the whole chunk go, leaving metrics alone. The three
|
|
121
|
+
// segments paint separately — path dim, branch in accent, PR in the link
|
|
122
|
+
// colour — but every width/gap decision uses their plain lengths.
|
|
123
|
+
const path = tilde(data.cwd, data.home);
|
|
124
|
+
const branchSeg = data.branch ? ` ⎇ ${data.branch}` : "";
|
|
125
|
+
const prSeg = data.branch && typeof data.pr === "number" ? ` ← PR #${data.pr}` : "";
|
|
126
|
+
|
|
127
|
+
// Drop optional right parts (highest priority first) until the metrics fit.
|
|
128
|
+
let kept = parts.slice();
|
|
129
|
+
const rightPlain = () => kept.map((p) => p.plain).join(SEP);
|
|
130
|
+
while (rightPlain().length > width) {
|
|
131
|
+
const droppable = kept
|
|
132
|
+
.map((p, i) => ({ p, i }))
|
|
133
|
+
.filter((x) => x.p.dropPriority > 0)
|
|
134
|
+
.sort((a, b) => b.p.dropPriority - a.p.dropPriority)[0];
|
|
135
|
+
if (!droppable) break;
|
|
136
|
+
kept = kept.filter((_, i) => i !== droppable.i);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const rightLen = rightPlain().length;
|
|
140
|
+
|
|
141
|
+
// Even the core overflows: last resort, truncate the plain metrics to width.
|
|
142
|
+
if (rightLen > width) {
|
|
143
|
+
return [paint("dim", rightPlain().slice(0, width))];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const rightPaintedStr = kept.map((p) => p.painted).join(paint("dim", SEP));
|
|
147
|
+
|
|
148
|
+
// Room for the left chunk after the metrics and at least one space of gap.
|
|
149
|
+
// Keep the PR only if the branch+PR both fit; otherwise drop it and keep the
|
|
150
|
+
// bare branch.
|
|
151
|
+
const room = width - rightLen - 1;
|
|
152
|
+
const keptPr = room >= branchSeg.length + prSeg.length + 1 ? prSeg : "";
|
|
153
|
+
const suffixLen = branchSeg.length + keptPr.length;
|
|
154
|
+
if (room >= suffixLen + 1) {
|
|
155
|
+
const shownPath = truncatePath(path, room - suffixLen);
|
|
156
|
+
const leftPlain = `${shownPath}${branchSeg}${keptPr}`;
|
|
157
|
+
const gap = " ".repeat(width - leftPlain.length - rightLen);
|
|
158
|
+
const leftPainted = `${paint("dim", shownPath)}${paint("accent", branchSeg)}${paint("mdLink", keptPr)}`;
|
|
159
|
+
return [`${leftPainted}${gap}${rightPaintedStr}`];
|
|
160
|
+
}
|
|
161
|
+
// No room for even the branch: metrics only, right-aligned.
|
|
162
|
+
const pad = " ".repeat(Math.max(0, width - rightLen));
|
|
163
|
+
return [`${pad}${rightPaintedStr}`];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* All-in session cost and latest cache-hit rate, computed from the session
|
|
168
|
+
* entries: the main session exactly as pi's own footer does (assistant
|
|
169
|
+
* messages, tool-result and branch-summary/compaction usage) plus every
|
|
170
|
+
* persisted out-of-band usage entry (`recordUsage`). Reading the ledger from
|
|
171
|
+
* the entries, not process memory, is what keeps the figure honest across a
|
|
172
|
+
* `--continue`/`--session` restart. Kept pure and loosely typed so it tests
|
|
173
|
+
* with plain fixtures.
|
|
174
|
+
*/
|
|
175
|
+
export function computeMainUsage(entries: readonly unknown[]): { cost: number; cacheHitPercent?: number } {
|
|
176
|
+
let cost = 0;
|
|
177
|
+
let cacheHitPercent: number | undefined;
|
|
178
|
+
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
179
|
+
|
|
180
|
+
for (const raw of entries) {
|
|
181
|
+
const entry = raw as {
|
|
182
|
+
type?: string;
|
|
183
|
+
message?: { role?: string; usage?: Record<string, unknown> };
|
|
184
|
+
usage?: Record<string, unknown>;
|
|
185
|
+
};
|
|
186
|
+
if (entry.type === "message" && entry.message?.role === "assistant" && entry.message.usage) {
|
|
187
|
+
const u = entry.message.usage;
|
|
188
|
+
cost += costOf(u);
|
|
189
|
+
// Only meaningful when the provider actually reported cache activity —
|
|
190
|
+
// otherwise leave it unset so the footer omits the field, matching pi
|
|
191
|
+
// (a non-caching provider must not show a permanent "cache-hit: 0%").
|
|
192
|
+
const cacheRead = num(u.cacheRead);
|
|
193
|
+
const hasCacheActivity = cacheRead > 0 || num(u.cacheWrite) > 0;
|
|
194
|
+
const prompt = num(u.input) + cacheRead + num(u.cacheWrite);
|
|
195
|
+
cacheHitPercent = hasCacheActivity && prompt > 0 ? (cacheRead / prompt) * 100 : undefined;
|
|
196
|
+
} else if (entry.type === "message" && entry.message?.role === "toolResult" && entry.message.usage) {
|
|
197
|
+
cost += costOf(entry.message.usage);
|
|
198
|
+
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
199
|
+
cost += costOf(entry.usage);
|
|
200
|
+
} else {
|
|
201
|
+
// Out-of-band calls (subagents, classifier, readers, recap, setup)
|
|
202
|
+
// persisted by recordUsage — the all-in half of the total.
|
|
203
|
+
cost += usageEntryCost(entry);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { cost, cacheHitPercent };
|
|
207
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* footer extension — replaces pi's built-in status line with One Code's own via
|
|
3
|
+
* `ctx.ui.setFooter`. A single line: path + branch on the left; context fill,
|
|
4
|
+
* all-in cost, cache-hit health, PR number, model, and effort on the right.
|
|
5
|
+
*
|
|
6
|
+
* pi's footer only ever sees the main session's usage. Here the cost figure is a
|
|
7
|
+
* true total: the main session (from its entries, exactly as pi computes it)
|
|
8
|
+
* plus every out-of-band LLM call (in-process subagents, the auto-mode
|
|
9
|
+
* classifier, and the reader-style one-shots — web-fetch, recap, and auto-mode
|
|
10
|
+
* setup — via the shared withReasoningFallback wrapper). Those are persisted as
|
|
11
|
+
* `one-code:usage` session entries by `recordUsage`, so the total survives a
|
|
12
|
+
* `--continue`/`--session` restart; the usage bus is only the live repaint
|
|
13
|
+
* signal. The effort label
|
|
14
|
+
* after the model reads the live thinking level, and swaps to "✦ ultracode" when
|
|
15
|
+
* the effort extension has published that status.
|
|
16
|
+
*
|
|
17
|
+
* Set CC_FOOTER=0 to keep pi's built-in footer instead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { linesComponent, safeThemePaint } from "../lib/tui-render.ts";
|
|
22
|
+
import { formatModel, isRealModel } from "../permissions/modes.ts";
|
|
23
|
+
import { ULTRACODE_STATUS_KEY } from "../effort/slider.ts";
|
|
24
|
+
import { USAGE_CHANNEL } from "../lib/usage-bus.ts";
|
|
25
|
+
import { buildFooterLines, computeMainUsage, type FooterData } from "./footer-line.ts";
|
|
26
|
+
import { fetchPrNumber } from "./pr.ts";
|
|
27
|
+
|
|
28
|
+
export default function footerExtension(pi: ExtensionAPI) {
|
|
29
|
+
if (process.env.CC_FOOTER === "0") return;
|
|
30
|
+
|
|
31
|
+
// All-in cost + latest cache-hit, recomputed only when the session's entries
|
|
32
|
+
// change (message_end/agent_end, and a usage entry landing) — an O(n)
|
|
33
|
+
// transcript scan that must NOT run on every repaint (model/effort events
|
|
34
|
+
// change no entry).
|
|
35
|
+
let mainUsage: { cost: number; cacheHitPercent?: number } = { cost: 0 };
|
|
36
|
+
let lastCtx: ExtensionContext | undefined;
|
|
37
|
+
let pr: number | undefined;
|
|
38
|
+
/** Bumped per branch change so a slow gh lookup for an old branch is ignored. */
|
|
39
|
+
let prToken = 0;
|
|
40
|
+
|
|
41
|
+
/** Invalidate the memoized line and repaint; set once the footer mounts. */
|
|
42
|
+
let repaint = () => {};
|
|
43
|
+
|
|
44
|
+
const recomputeMain = (ctx: ExtensionContext) => {
|
|
45
|
+
lastCtx = ctx;
|
|
46
|
+
mainUsage = computeMainUsage(ctx.sessionManager.getEntries());
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// A usage entry was just appended (recordUsage persists before it emits):
|
|
50
|
+
// re-sum the ledger. Known limitation: a background subagent spawned before
|
|
51
|
+
// a /clear (newSession) keeps reporting after the switch, so its late cost
|
|
52
|
+
// is persisted into — and shown for — the new session. Rare (only a
|
|
53
|
+
// *background* subagent outlives a turn — classifier/reader/recap/setup all
|
|
54
|
+
// finish synchronously), and it only nudges a display figure, so we accept
|
|
55
|
+
// it rather than thread session identity through the bus.
|
|
56
|
+
pi.events.on(USAGE_CHANNEL, () => {
|
|
57
|
+
if (lastCtx) recomputeMain(lastCtx);
|
|
58
|
+
repaint();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const refreshPr = (cwd: string, branch: string | null) => {
|
|
62
|
+
const token = ++prToken;
|
|
63
|
+
if (!branch) {
|
|
64
|
+
pr = undefined;
|
|
65
|
+
repaint();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
void fetchPrNumber(cwd, branch).then((found) => {
|
|
69
|
+
if (token !== prToken) return; // a newer branch superseded this lookup
|
|
70
|
+
pr = found;
|
|
71
|
+
repaint();
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
pi.on("session_start", (_event, ctx) => {
|
|
76
|
+
recomputeMain(ctx);
|
|
77
|
+
if (!ctx.hasUI) return;
|
|
78
|
+
|
|
79
|
+
ctx.ui.setFooter((tui: unknown, theme: unknown, footerData: unknown) => {
|
|
80
|
+
const paint = safeThemePaint(theme);
|
|
81
|
+
const fd = footerData as {
|
|
82
|
+
getGitBranch(): string | null;
|
|
83
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
84
|
+
/** Returns the unsubscribe (pi keeps callbacks in a Set for the process lifetime). */
|
|
85
|
+
onBranchChange(cb: () => void): () => void;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const snapshot = (): FooterData => {
|
|
89
|
+
const usage = ctx.getContextUsage();
|
|
90
|
+
const ultracode = fd.getExtensionStatuses().get(ULTRACODE_STATUS_KEY);
|
|
91
|
+
const effort = ultracode ?? safeThinkingLevel(ctx);
|
|
92
|
+
const model = ctx.model;
|
|
93
|
+
return {
|
|
94
|
+
cwd: ctx.cwd,
|
|
95
|
+
home: process.env.HOME || process.env.USERPROFILE || "",
|
|
96
|
+
branch: fd.getGitBranch() ?? undefined,
|
|
97
|
+
contextTokens: usage?.tokens ?? undefined,
|
|
98
|
+
contextWindow: usage?.contextWindow,
|
|
99
|
+
contextPercent: usage?.percent,
|
|
100
|
+
cost: mainUsage.cost,
|
|
101
|
+
cacheHitPercent: mainUsage.cacheHitPercent,
|
|
102
|
+
pr,
|
|
103
|
+
model: model && isRealModel(model.id) ? formatModel(model.provider, model.id) : "none",
|
|
104
|
+
effort,
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const component = linesComponent((width) => buildFooterLines(snapshot(), width, paint));
|
|
109
|
+
repaint = () => {
|
|
110
|
+
component.invalidate();
|
|
111
|
+
(tui as { requestRender?: () => void } | undefined)?.requestRender?.();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// The branch drives the PR lookup; seed it now and follow changes. The
|
|
115
|
+
// factory re-runs on every session_start (/clear, /new, resume) and pi
|
|
116
|
+
// only disposes the component, so the subscription must be released
|
|
117
|
+
// here — otherwise each replaced footer keeps firing its `gh pr list`
|
|
118
|
+
// against a stale cwd on every branch change.
|
|
119
|
+
refreshPr(ctx.cwd, fd.getGitBranch());
|
|
120
|
+
const stopFollowingBranch = fd.onBranchChange(() => {
|
|
121
|
+
refreshPr(ctx.cwd, fd.getGitBranch());
|
|
122
|
+
repaint();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return Object.assign(component, {
|
|
126
|
+
dispose: () => {
|
|
127
|
+
stopFollowingBranch();
|
|
128
|
+
repaint = () => {};
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// The main session's own entries changed: refresh the cached main-usage (the
|
|
135
|
+
// only place the O(n) transcript scan runs), then repaint.
|
|
136
|
+
pi.on("message_end", (_event, ctx) => {
|
|
137
|
+
recomputeMain(ctx);
|
|
138
|
+
repaint();
|
|
139
|
+
});
|
|
140
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
141
|
+
recomputeMain(ctx);
|
|
142
|
+
repaint();
|
|
143
|
+
});
|
|
144
|
+
// Model/effort switches change only the label; no main-usage recompute needed.
|
|
145
|
+
pi.on("model_select", () => repaint());
|
|
146
|
+
pi.on("thinking_level_select", () => repaint());
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Current effort/thinking level as a short label, or undefined if unavailable. */
|
|
150
|
+
function safeThinkingLevel(ctx: ExtensionContext): string | undefined {
|
|
151
|
+
return ctx.thinkingLevel;
|
|
152
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open-PR lookup for the footer, mirroring Claude Code's PR indicator. Uses the
|
|
3
|
+
* GitHub CLI (`gh`) so it inherits the user's existing auth; when `gh` is absent
|
|
4
|
+
* or the branch has no open PR it resolves to `undefined` and the footer simply
|
|
5
|
+
* omits the field — same graceful degrade as pi's git-branch watcher.
|
|
6
|
+
*
|
|
7
|
+
* `gh pr list --head <branch>`, not `gh pr view <branch>`: the latter only finds
|
|
8
|
+
* a PR whose head lives in the base repo, so it misses the common case of a PR
|
|
9
|
+
* opened from a fork (`isCrossRepository`), even though `list` on the base repo
|
|
10
|
+
* knows about it. `--state open` keeps a merged/closed PR for the same branch
|
|
11
|
+
* name from showing.
|
|
12
|
+
*
|
|
13
|
+
* The exec lives here but the parse is split out so it unit-tests without a
|
|
14
|
+
* subprocess.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pull the PR number out of `gh pr list --json number` output — a JSON array of
|
|
21
|
+
* PRs (possibly empty). The first open PR for the branch wins.
|
|
22
|
+
*/
|
|
23
|
+
export function parsePrNumber(stdout: string): number | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const data = JSON.parse(stdout) as Array<{ number?: unknown }>;
|
|
26
|
+
if (!Array.isArray(data)) return undefined;
|
|
27
|
+
for (const pr of data) {
|
|
28
|
+
if (pr && typeof pr.number === "number" && Number.isFinite(pr.number)) return pr.number;
|
|
29
|
+
}
|
|
30
|
+
return undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LOOKUP_TIMEOUT_MS = 4000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the open PR number for `branch` in `cwd`, or `undefined`. Never
|
|
40
|
+
* rejects: any failure (no gh, no PR, detached HEAD, timeout) is `undefined`.
|
|
41
|
+
*/
|
|
42
|
+
export function fetchPrNumber(cwd: string, branch: string): Promise<number | undefined> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
execFile(
|
|
45
|
+
"gh",
|
|
46
|
+
["pr", "list", "--head", branch, "--state", "open", "--json", "number"],
|
|
47
|
+
{ cwd, timeout: LOOKUP_TIMEOUT_MS, encoding: "utf8" },
|
|
48
|
+
(error, stdout) => {
|
|
49
|
+
resolve(error ? undefined : parsePrNumber(stdout));
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -18,14 +18,41 @@
|
|
|
18
18
|
* - output capped at 1MB per stream
|
|
19
19
|
* - stdin errors ignored (a hook that exits without reading stdin — `exit 2`
|
|
20
20
|
* — would otherwise EPIPE-crash the write)
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
21
|
+
* - stdin JSON is newline-terminated: without a trailing "\n" a hook doing
|
|
22
|
+
* `read -r line` sees EOF-before-delimiter and `read` exits 1, so the
|
|
23
|
+
* `if read -r line; then …` branch is silently skipped even though the
|
|
24
|
+
* variable was populated (Claude Code carries the same fix — bug CC-161)
|
|
25
|
+
* - timeout clamped under Node's 2^31-1 ms timer overflow, timer unref'd (it
|
|
26
|
+
* still fires while the process lives; it must not be what keeps it alive)
|
|
27
|
+
* - the child process handle stays REF'd while the hook runs, unless the caller
|
|
28
|
+
* passes `detached` (SessionEnd at shutdown, fire-and-forget). Until
|
|
29
|
+
* 2026-09-05 every hook child was `unref()`d, and in a one-shot run
|
|
30
|
+
* (`pi -p` / `--mode json`) a PreToolUse hook was often the only pending
|
|
31
|
+
* work: the provider's keep-alive socket is idle between the response and
|
|
32
|
+
* the tool's spawn, so nothing ref'd remained but the hook's stdio pipes.
|
|
33
|
+
* When the child exited, the pipe-close callbacks could run before its
|
|
34
|
+
* SIGCHLD-driven exit callback; with the process handle unref'd the loop
|
|
35
|
+
* counted itself empty and node exited 0 mid-tool, silently, before the
|
|
36
|
+
* hook's `close` ever fired — measured at ~55% of runs with a trivial
|
|
37
|
+
* `exit 0` hook (docs/one-shot-lsp-event-loop-drain.md has the same
|
|
38
|
+
* mechanism for the LSP client). A ref'd child holds the loop until `close`.
|
|
23
39
|
*/
|
|
24
40
|
|
|
25
41
|
import { spawn } from "node:child_process";
|
|
42
|
+
import { killProcessTree } from "../lib/process-tree.ts";
|
|
26
43
|
|
|
27
44
|
export interface HookRunResult {
|
|
28
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* null when the process was killed (timeout) or never spawned. Normalized on
|
|
47
|
+
* the timeout path rather than taken from `waitpid`: a group SIGKILL is not
|
|
48
|
+
* atomic, so the shell can be scheduled after its foreground child is killed
|
|
49
|
+
* and before its own signal lands, reap the child and exit(128+9) itself —
|
|
50
|
+
* `close` then reports a normal exit of 137 instead of death by signal
|
|
51
|
+
* (roughly 1% of timeouts under load, and never when the shell had exec'd
|
|
52
|
+
* away, leaving no shell to reap; findings §10.20). Callers should still
|
|
53
|
+
* prefer `timedOut`, which says what happened rather than what it looked
|
|
54
|
+
* like, but they no longer have to.
|
|
55
|
+
*/
|
|
29
56
|
exitCode: number | null;
|
|
30
57
|
timedOut: boolean;
|
|
31
58
|
/** Set when the child could not be spawned at all. */
|
|
@@ -41,6 +68,18 @@ export interface HookRunOptions {
|
|
|
41
68
|
timeoutSeconds?: number;
|
|
42
69
|
/** Exposed to the hook as CLAUDE_PROJECT_DIR; defaults to cwd. */
|
|
43
70
|
projectDir?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Let the process exit without waiting for this hook. Only for fire-and-forget
|
|
73
|
+
* dispatches at shutdown; an awaited hook must keep the event loop alive or a
|
|
74
|
+
* one-shot run drains mid-await (header). The child is `unref()`d AND spawned
|
|
75
|
+
* without stdout/stderr pipes: `unref` releases the process handle only, and
|
|
76
|
+
* each inherited pipe is a ref'd handle of its own that stays open until the
|
|
77
|
+
* child exits — a `sleep 20` SessionEnd hook held a `-p` run open for the
|
|
78
|
+
* full 20 s (LIFECYCLE-REVIEW-2026-09-06 M4, measured). stdin is still piped
|
|
79
|
+
* for the payload, written and closed at once, and unref'd too. The result's
|
|
80
|
+
* stdout/stderr are therefore empty for a detached hook — nothing reads them.
|
|
81
|
+
*/
|
|
82
|
+
detached?: boolean;
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
const MAX_OUTPUT_BYTES = 1_000_000;
|
|
@@ -58,7 +97,7 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
58
97
|
child = spawn("/bin/sh", ["-c", command], {
|
|
59
98
|
cwd: opts.cwd,
|
|
60
99
|
detached: true,
|
|
61
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
+
stdio: opts.detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
|
|
62
101
|
env: { ...process.env, CLAUDE_PROJECT_DIR: opts.projectDir ?? opts.cwd },
|
|
63
102
|
});
|
|
64
103
|
} catch (error) {
|
|
@@ -72,7 +111,12 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
72
111
|
});
|
|
73
112
|
return;
|
|
74
113
|
}
|
|
75
|
-
|
|
114
|
+
if (opts.detached) {
|
|
115
|
+
child.unref();
|
|
116
|
+
// The stdin pipe is the one handle left; it closes as soon as the
|
|
117
|
+
// payload is flushed below, and must not hold the loop meanwhile.
|
|
118
|
+
(child.stdin as { unref?: () => void } | null)?.unref?.();
|
|
119
|
+
}
|
|
76
120
|
|
|
77
121
|
let stdout = "";
|
|
78
122
|
let stderr = "";
|
|
@@ -91,21 +135,10 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
91
135
|
child.stdout?.on("data", capture("stdout"));
|
|
92
136
|
child.stderr?.on("data", capture("stderr"));
|
|
93
137
|
|
|
94
|
-
const killTree = () => {
|
|
95
|
-
// Negative pid = the whole process group the detached shell leads.
|
|
96
|
-
try {
|
|
97
|
-
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
|
98
|
-
} catch {
|
|
99
|
-
try {
|
|
100
|
-
child.kill("SIGKILL");
|
|
101
|
-
} catch {
|
|
102
|
-
// Already gone.
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
138
|
const timer = setTimeout(() => {
|
|
107
139
|
timedOut = true;
|
|
108
|
-
|
|
140
|
+
// The whole process group the detached shell leads (lib/process-tree.ts).
|
|
141
|
+
killProcessTree(child, "SIGKILL");
|
|
109
142
|
}, timeoutMs);
|
|
110
143
|
timer.unref();
|
|
111
144
|
|
|
@@ -120,13 +153,17 @@ export function runHookCommand(command: string, stdinJson: string, opts: HookRun
|
|
|
120
153
|
finish({ exitCode: null, timedOut, spawnError: error.message, stdout, stderr });
|
|
121
154
|
});
|
|
122
155
|
child.on("close", (code) => {
|
|
123
|
-
|
|
156
|
+
// A killed process reports null here, EXCEPT when the shell outlived the
|
|
157
|
+
// group kill just long enough to reap its child and exit 128+9 itself
|
|
158
|
+
// (see exitCode's contract). Normalizing keeps "we killed it" from ever
|
|
159
|
+
// looking like an ordinary non-zero exit, which fails OPEN downstream.
|
|
160
|
+
finish({ exitCode: timedOut ? null : code, timedOut, stdout, stderr });
|
|
124
161
|
});
|
|
125
162
|
|
|
126
163
|
// A hook that never reads stdin (e.g. plain `exit 2`) closes the pipe
|
|
127
164
|
// early; the resulting EPIPE must not take the extension down.
|
|
128
165
|
child.stdin?.on("error", () => {});
|
|
129
|
-
child.stdin?.write(stdinJson);
|
|
166
|
+
child.stdin?.write(stdinJson.endsWith("\n") ? stdinJson : `${stdinJson}\n`);
|
|
130
167
|
child.stdin?.end();
|
|
131
168
|
});
|
|
132
169
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telling the model when a PostToolUse hook rewrote the file it just edited.
|
|
3
|
+
*
|
|
4
|
+
* A "formatter" hook is the common shape of PostToolUse, and its rewrite was
|
|
5
|
+
* invisible: this extension loads before `file-tracker` and AWAITS the hook, so
|
|
6
|
+
* by the time the tracker observes the file the hook's version is already on
|
|
7
|
+
* disk and gets recorded as the model's own write. With sequential edits the
|
|
8
|
+
* model was told nothing at all (3/3 runs, zero reminders); with parallel edits
|
|
9
|
+
* it was told by a race, about the wrong file. A tiny-tier model then showed
|
|
10
|
+
* the user its pre-hook text from memory (WEAK-MODEL-REVIEW-2026-09-06 M1).
|
|
11
|
+
*
|
|
12
|
+
* Claude Code has a dedicated notice for exactly this, delivered as hook
|
|
13
|
+
* context on the edit's own result. This module is the deterministic detector
|
|
14
|
+
* behind ours: snapshot the target before the hooks run, compare after.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFileSync, statSync } from "node:fs";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
import { isWritingTool, pathArgument } from "../auto-mode/paths.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reading the whole file twice per edit is fine for source files and wrong for
|
|
23
|
+
* a large generated one; past this size the comparison falls back to the disk
|
|
24
|
+
* stamp, which a rewrite moves anyway.
|
|
25
|
+
*/
|
|
26
|
+
const MAX_COMPARE_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a file looked like at snapshot time: its content, or its stamp when too
|
|
30
|
+
* large. `undefined` is "nothing comparable" — missing, binary or unreadable —
|
|
31
|
+
* which every caller already treats as "make no claim".
|
|
32
|
+
*/
|
|
33
|
+
export type FileSnapshot = { kind: "content"; value: string } | { kind: "stamp"; value: string };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The absolute path a file tool is acting on, or undefined for any other tool.
|
|
37
|
+
* The tool set and the `path`/`file_path` field names come from `auto-mode/paths.ts`,
|
|
38
|
+
* the one place that knows them, so a new writing tool is added once.
|
|
39
|
+
*/
|
|
40
|
+
export function fileToolTarget(toolName: string, input: unknown, cwd: string): string | undefined {
|
|
41
|
+
if (!isWritingTool(toolName)) return undefined;
|
|
42
|
+
const raw = pathArgument(input as Record<string, unknown> | undefined);
|
|
43
|
+
if (!raw?.trim()) return undefined;
|
|
44
|
+
return resolve(cwd, raw);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function snapshotFile(path: string): FileSnapshot | undefined {
|
|
48
|
+
try {
|
|
49
|
+
const stat = statSync(path);
|
|
50
|
+
if (stat.size > MAX_COMPARE_BYTES) return { kind: "stamp", value: `${stat.mtimeMs}:${stat.size}` };
|
|
51
|
+
return { kind: "content", value: readFileSync(path, "utf-8") };
|
|
52
|
+
} catch {
|
|
53
|
+
// Missing, binary, or unreadable: nothing we could compare meaningfully.
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when the file differs from the snapshot. An unreadable file compares equal (nothing to claim). */
|
|
59
|
+
export function changedSince(path: string, before: FileSnapshot | undefined): boolean {
|
|
60
|
+
if (!before) return false;
|
|
61
|
+
const after = snapshotFile(path);
|
|
62
|
+
if (!after) return false;
|
|
63
|
+
// A kind change means the file crossed the MAX_COMPARE_BYTES boundary between
|
|
64
|
+
// snapshots — the size changed, so the file changed. (Comparing values across
|
|
65
|
+
// kinds is meaningless: a stamp is never equal to file content.)
|
|
66
|
+
if (after.kind !== before.kind) return true;
|
|
67
|
+
return after.value !== before.value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Claude Code's wording, with our tool names: the model must know the bytes on
|
|
72
|
+
* disk are no longer the bytes it wrote, without being sent into a revert (the
|
|
73
|
+
* hook's change is intentional).
|
|
74
|
+
*/
|
|
75
|
+
export const FORMATTER_NOTICE = (path: string) =>
|
|
76
|
+
`PostToolUse hook modified ${path} after your edit (likely a formatter). The change was intentional — do not revert it. Your next edit will not fail with a stale-file error, but if its old text targets a region the hook reformatted, read the file first, and read it before reporting the file's contents to the user.`;
|