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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/doctor` panel (pure): a read-only, scrollable view of the rendered
|
|
3
|
+
* report inside `ctx.ui.custom`, with the key hints on screen (a hidden key is
|
|
4
|
+
* an undiscoverable key). The wiring owns repaint and the fix/close effects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { panelTopRule } from "../lib/tui-render.ts";
|
|
8
|
+
|
|
9
|
+
export interface DoctorViewerState {
|
|
10
|
+
offset: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DoctorViewerKey =
|
|
14
|
+
| { kind: "up" }
|
|
15
|
+
| { kind: "down" }
|
|
16
|
+
| { kind: "pageUp" }
|
|
17
|
+
| { kind: "pageDown" }
|
|
18
|
+
| { kind: "home" }
|
|
19
|
+
| { kind: "end" }
|
|
20
|
+
| { kind: "fix" }
|
|
21
|
+
| { kind: "close" };
|
|
22
|
+
|
|
23
|
+
export function decodeDoctorKey(data: string): DoctorViewerKey | undefined {
|
|
24
|
+
switch (data) {
|
|
25
|
+
case "\x1b[A":
|
|
26
|
+
case "\x1bOA":
|
|
27
|
+
case "k":
|
|
28
|
+
return { kind: "up" };
|
|
29
|
+
case "\x1b[B":
|
|
30
|
+
case "\x1bOB":
|
|
31
|
+
case "j":
|
|
32
|
+
return { kind: "down" };
|
|
33
|
+
case "\x1b[5~":
|
|
34
|
+
case "\x02": // ctrl+b
|
|
35
|
+
case "b":
|
|
36
|
+
return { kind: "pageUp" };
|
|
37
|
+
case "\x1b[6~":
|
|
38
|
+
case "\x06": // ctrl+f
|
|
39
|
+
case " ":
|
|
40
|
+
return { kind: "pageDown" };
|
|
41
|
+
case "\x1b[H":
|
|
42
|
+
case "\x1b[1~":
|
|
43
|
+
case "g":
|
|
44
|
+
return { kind: "home" };
|
|
45
|
+
case "\x1b[F":
|
|
46
|
+
case "\x1b[4~":
|
|
47
|
+
case "G":
|
|
48
|
+
return { kind: "end" };
|
|
49
|
+
case "f":
|
|
50
|
+
return { kind: "fix" };
|
|
51
|
+
case "\x1b":
|
|
52
|
+
case "\x03": // ctrl+c
|
|
53
|
+
case "q":
|
|
54
|
+
case "\r":
|
|
55
|
+
case "\n":
|
|
56
|
+
return { kind: "close" };
|
|
57
|
+
default:
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DoctorViewerEffect = { kind: "fix" } | { kind: "close" };
|
|
63
|
+
|
|
64
|
+
/** Move the window; returns an effect for keys the wiring must act on. */
|
|
65
|
+
export function applyDoctorKey(state: DoctorViewerState, key: DoctorViewerKey, total: number, visible: number): DoctorViewerEffect | undefined {
|
|
66
|
+
const max = Math.max(0, total - visible);
|
|
67
|
+
const page = Math.max(1, visible - 1);
|
|
68
|
+
switch (key.kind) {
|
|
69
|
+
case "up":
|
|
70
|
+
state.offset = Math.max(0, state.offset - 1);
|
|
71
|
+
return undefined;
|
|
72
|
+
case "down":
|
|
73
|
+
state.offset = Math.min(max, state.offset + 1);
|
|
74
|
+
return undefined;
|
|
75
|
+
case "pageUp":
|
|
76
|
+
state.offset = Math.max(0, state.offset - page);
|
|
77
|
+
return undefined;
|
|
78
|
+
case "pageDown":
|
|
79
|
+
state.offset = Math.min(max, state.offset + page);
|
|
80
|
+
return undefined;
|
|
81
|
+
case "home":
|
|
82
|
+
state.offset = 0;
|
|
83
|
+
return undefined;
|
|
84
|
+
case "end":
|
|
85
|
+
state.offset = max;
|
|
86
|
+
return undefined;
|
|
87
|
+
case "fix":
|
|
88
|
+
return { kind: "fix" };
|
|
89
|
+
case "close":
|
|
90
|
+
return { kind: "close" };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DoctorViewerInput {
|
|
95
|
+
/** Already rendered (painted, width-cut) report lines. */
|
|
96
|
+
lines: string[];
|
|
97
|
+
state: DoctorViewerState;
|
|
98
|
+
width: number;
|
|
99
|
+
/** Total rows the panel may occupy, chrome included. */
|
|
100
|
+
height: number;
|
|
101
|
+
/** Whether a model is available, so the `f` hint is shown. */
|
|
102
|
+
canFix: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Rows taken by the top rule and the footer hint. */
|
|
106
|
+
export const VIEWER_CHROME_ROWS = 3;
|
|
107
|
+
|
|
108
|
+
export function visibleBodyRows(height: number): number {
|
|
109
|
+
return Math.max(1, height - VIEWER_CHROME_ROWS);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function renderDoctorViewer(input: DoctorViewerInput, paint: (color: string, text: string) => string): string[] {
|
|
113
|
+
const { lines, state, width, height } = input;
|
|
114
|
+
const visible = visibleBodyRows(height);
|
|
115
|
+
const max = Math.max(0, lines.length - visible);
|
|
116
|
+
if (state.offset > max) state.offset = max;
|
|
117
|
+
const window = lines.slice(state.offset, state.offset + visible);
|
|
118
|
+
while (window.length < visible) window.push("");
|
|
119
|
+
const position = lines.length > visible ? ` · ${state.offset + 1}-${Math.min(lines.length, state.offset + visible)} of ${lines.length}` : "";
|
|
120
|
+
// PageUp/PageDown are pi-tui's own scrollback keys in fullscreen mode and never
|
|
121
|
+
// reach a component, so the hint names keys that do.
|
|
122
|
+
const hints = [`↑/↓ scroll · space/b page · g/G top/end${position}`, ...(input.canFix ? ["f run the checkup (the model fixes the findings)"] : []), "esc close"].join(" · ");
|
|
123
|
+
return [panelTopRule(paint, width), ...window, "", paint("dim", ` ${hints}`)];
|
|
124
|
+
}
|
|
@@ -21,13 +21,14 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
21
21
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
|
|
22
22
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
24
|
-
import { safeThemePaint } from "../lib/tui-render.ts";
|
|
24
|
+
import { dimRightAlignedWidget, safeThemePaint, truncateLine } from "../lib/tui-render.ts";
|
|
25
25
|
import {
|
|
26
26
|
acceptedEffortArgs,
|
|
27
27
|
choiceForState,
|
|
28
28
|
decodeKey,
|
|
29
29
|
type EffortChoice,
|
|
30
30
|
EFFORT_CHOICES,
|
|
31
|
+
effortNoteText,
|
|
31
32
|
enabledStops,
|
|
32
33
|
levelRank,
|
|
33
34
|
moveIndex,
|
|
@@ -38,6 +39,8 @@ import {
|
|
|
38
39
|
thinkingLevelFor,
|
|
39
40
|
ULTRACODE,
|
|
40
41
|
ULTRACODE_LEVEL,
|
|
42
|
+
ULTRACODE_MODE_CHANNEL,
|
|
43
|
+
ULTRACODE_STATUS_KEY,
|
|
41
44
|
} from "./slider.ts";
|
|
42
45
|
|
|
43
46
|
/** The levels pi says the active model can reach; the full ladder when unknown. */
|
|
@@ -51,7 +54,8 @@ function modelLabel(ctx: ExtensionContext): string {
|
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
const REMINDER_KEY = "ultracode-mode";
|
|
54
|
-
const
|
|
57
|
+
const NOTE_KEY = "effort-note";
|
|
58
|
+
const NOTE_MS = 5000;
|
|
55
59
|
|
|
56
60
|
/**
|
|
57
61
|
* The standing instruction for ultracode mode. Deliberately stronger than the
|
|
@@ -77,17 +81,44 @@ export default function effortExtension(pi: ExtensionAPI) {
|
|
|
77
81
|
|
|
78
82
|
const applyStatus = (ctx: ExtensionContext) => {
|
|
79
83
|
// No-op outside the TUI, so this is safe to call unconditionally.
|
|
80
|
-
ctx.ui.setStatus(
|
|
84
|
+
ctx.ui.setStatus(ULTRACODE_STATUS_KEY, ultracodeActive ? "✦ ultracode" : undefined);
|
|
81
85
|
};
|
|
82
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Claude Code's effort-changed note: "● high · /effort", right-aligned above
|
|
89
|
+
* the input for a few seconds after the level changes.
|
|
90
|
+
*/
|
|
91
|
+
let noteTimer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
const showEffortNote = (ctx: ExtensionContext, level: ThinkingLevel) => {
|
|
93
|
+
if (!ctx.hasUI) return;
|
|
94
|
+
ctx.ui.setWidget(NOTE_KEY, dimRightAlignedWidget(effortNoteText(level, ultracodeActive)), {
|
|
95
|
+
placement: "aboveEditor",
|
|
96
|
+
});
|
|
97
|
+
if (noteTimer) clearTimeout(noteTimer);
|
|
98
|
+
noteTimer = setTimeout(() => ctx.ui.setWidget(NOTE_KEY, undefined), NOTE_MS);
|
|
99
|
+
noteTimer.unref?.();
|
|
100
|
+
};
|
|
101
|
+
// The timer captured the ctx of the turn that changed the level; a /clear or
|
|
102
|
+
// shutdown inside the note's few seconds invalidates that ctx, and pi throws
|
|
103
|
+
// on a stale ctx use. Drop the timer with the session instead.
|
|
104
|
+
const cancelEffortNote = () => {
|
|
105
|
+
if (noteTimer) clearTimeout(noteTimer);
|
|
106
|
+
noteTimer = undefined;
|
|
107
|
+
};
|
|
108
|
+
pi.on("session_start", cancelEffortNote);
|
|
109
|
+
pi.on("session_shutdown", cancelEffortNote);
|
|
110
|
+
|
|
83
111
|
const setUltracode = (on: boolean, level?: ThinkingLevel) => {
|
|
112
|
+
const changed = ultracodeActive !== on;
|
|
84
113
|
ultracodeActive = on;
|
|
85
114
|
ultracodeLevel = on ? level : undefined;
|
|
115
|
+
if (changed) pi.events.emit(ULTRACODE_MODE_CHANNEL, { active: on });
|
|
86
116
|
if (on) {
|
|
87
117
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
88
118
|
text: ULTRACODE_STANDING_REMINDER,
|
|
89
119
|
scope: "every-turn",
|
|
90
120
|
key: REMINDER_KEY,
|
|
121
|
+
placement: "sticky-append",
|
|
91
122
|
});
|
|
92
123
|
} else {
|
|
93
124
|
pi.events.emit(REMINDER_CHANNEL, { remove: true, key: REMINDER_KEY });
|
|
@@ -102,6 +133,7 @@ export default function effortExtension(pi: ExtensionAPI) {
|
|
|
102
133
|
|
|
103
134
|
setUltracode(choice === ULTRACODE, actual);
|
|
104
135
|
applyStatus(ctx);
|
|
136
|
+
showEffortNote(ctx, actual);
|
|
105
137
|
|
|
106
138
|
// setThinkingLevel clamps to model capability silently. Only flag a genuine
|
|
107
139
|
// downward clamp (the model can't reach the requested level); an upward clamp
|
|
@@ -135,7 +167,11 @@ export default function effortExtension(pi: ExtensionAPI) {
|
|
|
135
167
|
const chosen = await ctx.ui.custom<EffortChoice | null>((tui, theme, _keybindings, done) => {
|
|
136
168
|
const paint = safeThemePaint(theme);
|
|
137
169
|
return {
|
|
138
|
-
|
|
170
|
+
// The slider's own layout is column-safe for its ASCII labels, but this
|
|
171
|
+
// dialog has no linesComponent wrap, so a final truncate is the safety
|
|
172
|
+
// net that guarantees no overwide line reaches pi-tui (TUI-REVIEW H1/H2).
|
|
173
|
+
render: (width: number) =>
|
|
174
|
+
["", ...renderEffortSlider({ index, enabled, width, modelLabel: label }, paint), ""].map((line) => truncateLine(line, width)),
|
|
139
175
|
handleInput: (data: string) => {
|
|
140
176
|
const key = decodeKey(data);
|
|
141
177
|
if (!key) return;
|
|
@@ -208,6 +244,7 @@ export default function effortExtension(pi: ExtensionAPI) {
|
|
|
208
244
|
applyStatus(ctx);
|
|
209
245
|
ctx.ui.notify(`Ultracode off — reasoning is now ${event.level}`, "info");
|
|
210
246
|
}
|
|
247
|
+
showEffortNote(ctx, event.level);
|
|
211
248
|
});
|
|
212
249
|
|
|
213
250
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -22,6 +22,21 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
22
22
|
/** The final stop on the track: top reasoning + workflows armed every turn. */
|
|
23
23
|
export const ULTRACODE = "ultracode";
|
|
24
24
|
|
|
25
|
+
/** UI status key the effort extension sets while ultracode is armed; the footer
|
|
26
|
+
* reads it to show "✦ ultracode" in place of the effort level. Shared via this
|
|
27
|
+
* pure module so both extensions reference one constant, not a bare string. */
|
|
28
|
+
export const ULTRACODE_STATUS_KEY = "ultracode";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Event-bus channel the effort extension emits `{ active: boolean }` on when
|
|
32
|
+
* ultracode mode switches. The workflow extension listens so the keyword
|
|
33
|
+
* one-shot ("the user included ultracode") is skipped while the standing block
|
|
34
|
+
* already says so — two instructions for one fact, the weaker one on top,
|
|
35
|
+
* steered worse than one. Emitted on change only (the bus does not replay);
|
|
36
|
+
* the mode starts off, so a listener registered at load is never behind.
|
|
37
|
+
*/
|
|
38
|
+
export const ULTRACODE_MODE_CHANNEL = "one-code:ultracode-mode";
|
|
39
|
+
|
|
25
40
|
/** The reasoning level ultracode pins, when the model can reach it. */
|
|
26
41
|
export const ULTRACODE_LEVEL: ThinkingLevel = "xhigh";
|
|
27
42
|
|
|
@@ -163,8 +178,10 @@ export interface SliderView {
|
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
const TITLE = "Effort";
|
|
166
|
-
const HINTS = "←/→ adjust · Enter confirm · Esc cancel · shift+tab cycles the plain levels";
|
|
167
|
-
|
|
181
|
+
const HINTS = "←/→ or h/l adjust · Enter confirm · Esc cancel · shift+tab cycles the plain levels";
|
|
182
|
+
/** Anthropic's cache hierarchy: a thinking/effort change invalidates the message cache once (CACHE-REVIEW-2026-09-04 M4). */
|
|
183
|
+
const CACHE_NOTE = "changing effort re-caches the conversation (one uncached request)";
|
|
184
|
+
const SHORT_HINTS = "←/→ h/l · Enter · Esc";
|
|
168
185
|
const ULTRACODE_SUBTITLE = `${ULTRACODE_LEVEL} + workflows`;
|
|
169
186
|
|
|
170
187
|
/**
|
|
@@ -236,6 +253,7 @@ export function renderEffortSlider(view: SliderView, paint: Paint): string[] {
|
|
|
236
253
|
const anyDisabled = stops.some((_, i) => !view.enabled[i]);
|
|
237
254
|
const note = anyDisabled ? `dimmed = unsupported by ${view.modelLabel ?? "this model"}` : "";
|
|
238
255
|
if (note) rows.push([note, paint("dim", note)]);
|
|
256
|
+
rows.push([CACHE_NOTE, paint("dim", CACHE_NOTE)]);
|
|
239
257
|
rows.push(["", ""], [HINTS, paint("dim", HINTS)]);
|
|
240
258
|
|
|
241
259
|
const fits = rows.every(([plainRow]) => [...plainRow].length <= view.width);
|
|
@@ -251,3 +269,16 @@ export function renderEffortSlider(view: SliderView, paint: Paint): string[] {
|
|
|
251
269
|
paint("dim", clip(SHORT_HINTS)),
|
|
252
270
|
];
|
|
253
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Claude Code's effort-changed note ("● high · /effort"), shown transiently
|
|
275
|
+
* right-aligned above the input after the level changes. Symbols are CC's
|
|
276
|
+
* (constants/figures.ts: ○ low, ◐ medium, ● high, ◉ max); pi's extra rungs
|
|
277
|
+
* map onto the nearest CC level, and ultracode keeps its own ✦ badge.
|
|
278
|
+
*/
|
|
279
|
+
export function effortNoteText(level: ThinkingLevel, ultracode: boolean): string {
|
|
280
|
+
if (ultracode) return "✦ ultracode · /effort";
|
|
281
|
+
const symbol =
|
|
282
|
+
level === "medium" ? "◐" : level === "high" ? "●" : level === "xhigh" || level === "max" ? "◉" : "○";
|
|
283
|
+
return `${symbol} ${level} · /effort`;
|
|
284
|
+
}
|
|
@@ -12,14 +12,29 @@
|
|
|
12
12
|
* that went through bash and never touched an intercepted tool.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
16
16
|
import { isAbsolute, resolve } from "node:path";
|
|
17
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { pathArgument } from "../auto-mode/paths.ts";
|
|
18
19
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
19
|
-
import {
|
|
20
|
+
import { pathsReadOnBranch } from "./replay.ts";
|
|
21
|
+
import {
|
|
22
|
+
describeChanges,
|
|
23
|
+
EXTERNAL_CHANGE_REMINDER,
|
|
24
|
+
type FileStamp,
|
|
25
|
+
FileTracker,
|
|
26
|
+
STALE_REASON,
|
|
27
|
+
UNREAD_REASON,
|
|
28
|
+
} from "./tracker.ts";
|
|
20
29
|
|
|
21
30
|
const GUARDED_TOOLS = new Set(["edit", "write", "notebook_edit"]);
|
|
22
31
|
const READ_TOOLS = new Set(["read", "notebook_edit"]);
|
|
32
|
+
/**
|
|
33
|
+
* At most this many changed files get the full excerpt reminder per turn; the
|
|
34
|
+
* rest are named in one line. A formatter pass or `git checkout` touching
|
|
35
|
+
* dozens of tracked files otherwise produced dozens of 40-line blocks (review T8).
|
|
36
|
+
*/
|
|
37
|
+
const DETAILED_CHANGE_REMINDERS_PER_TURN = 5;
|
|
23
38
|
|
|
24
39
|
function readIfPresent(path: string): string | undefined {
|
|
25
40
|
if (!existsSync(path)) return undefined;
|
|
@@ -31,14 +46,60 @@ function readIfPresent(path: string): string | undefined {
|
|
|
31
46
|
}
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
/** The file's disk stamp, or undefined when it is gone (or not a plain file we could stat). */
|
|
50
|
+
function statIfPresent(path: string): FileStamp | undefined {
|
|
51
|
+
try {
|
|
52
|
+
const stat = statSync(path);
|
|
53
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Observe a file's current content together with the stamp it was read at. */
|
|
60
|
+
function observeFromDisk(tracker: FileTracker, path: string): void {
|
|
61
|
+
const stamp = statIfPresent(path);
|
|
62
|
+
const current = readIfPresent(path);
|
|
63
|
+
if (current === undefined) tracker.forget(path);
|
|
64
|
+
else tracker.observe(path, current, Date.now(), stamp);
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
function pathOf(input: unknown, cwd: string): string | undefined {
|
|
35
|
-
|
|
68
|
+
// Share the field-name knowledge with the auto-mode gates: `path` for pi's
|
|
69
|
+
// built-ins, `file_path`/`notebook_path` for Claude Code-shaped calls. A
|
|
70
|
+
// notebook_edit using only `notebook_path` would otherwise bypass the
|
|
71
|
+
// read-before-write guard (code-review).
|
|
72
|
+
const raw = pathArgument(input as Record<string, unknown> | undefined);
|
|
36
73
|
if (typeof raw !== "string" || !raw.trim()) return undefined;
|
|
37
74
|
return isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
38
75
|
}
|
|
39
76
|
|
|
40
77
|
export default function fileTrackerExtension(pi: ExtensionAPI) {
|
|
41
|
-
|
|
78
|
+
let tracker = new FileTracker();
|
|
79
|
+
let sessionId: string | undefined;
|
|
80
|
+
|
|
81
|
+
// A resumed session (or a session_tree switch) carries reads the model did
|
|
82
|
+
// earlier in the transcript; rebuild the tracker from the branch so post-
|
|
83
|
+
// resume edits are not refused as "never read" (replay.ts). A new session id
|
|
84
|
+
// starts from an empty tracker.
|
|
85
|
+
const reconstruct = (ctx: ExtensionContext) => {
|
|
86
|
+
const id = ctx.sessionManager.getSessionId?.() ?? undefined;
|
|
87
|
+
if (id !== sessionId) {
|
|
88
|
+
sessionId = id;
|
|
89
|
+
tracker = new FileTracker();
|
|
90
|
+
}
|
|
91
|
+
let entries: unknown[] = [];
|
|
92
|
+
try {
|
|
93
|
+
entries = ctx.sessionManager.getBranch() as unknown[];
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const raw of pathsReadOnBranch(entries)) {
|
|
98
|
+
observeFromDisk(tracker, isAbsolute(raw) ? raw : resolve(ctx.cwd, raw));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
pi.on("session_start", (_event, ctx) => reconstruct(ctx));
|
|
102
|
+
pi.on("session_tree", (_event, ctx) => reconstruct(ctx));
|
|
42
103
|
|
|
43
104
|
pi.on("tool_call", (event, ctx) => {
|
|
44
105
|
if (!GUARDED_TOOLS.has(event.toolName)) return undefined;
|
|
@@ -63,41 +124,103 @@ export default function fileTrackerExtension(pi: ExtensionAPI) {
|
|
|
63
124
|
|
|
64
125
|
// After a read we know the file; after our own write we know it again, so a
|
|
65
126
|
// successful edit does not make the file look stale to the next edit.
|
|
66
|
-
|
|
67
|
-
if (current === undefined) tracker.forget(path);
|
|
68
|
-
else tracker.observe(path, current, Date.now());
|
|
127
|
+
observeFromDisk(tracker, path);
|
|
69
128
|
return undefined;
|
|
70
129
|
});
|
|
71
130
|
|
|
72
131
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
132
|
+
* Report anything that changed under us. Costs one stat per tracked file
|
|
133
|
+
* (only files the model actually touched) and a read only for a file whose
|
|
134
|
+
* stamp moved since we last read it; `alreadyNotified` keeps a repeat scan
|
|
135
|
+
* from repeating a warning.
|
|
136
|
+
*
|
|
137
|
+
* Two triggers:
|
|
138
|
+
* - `agent_start`, for changes made between turns. Not `before_agent_start`:
|
|
139
|
+
* pi emits that only from `prompt()`, so a run opened by a harness
|
|
140
|
+
* notification (a `/loop` tick, an agent report arriving while idle) never
|
|
141
|
+
* fired it and changes went unreported until the next user prompt
|
|
142
|
+
* (STEERING-REVIEW-2026-09-05 H1). `agent_start` fires for every run.
|
|
143
|
+
* - `tool_execution_end`, for changes made DURING a turn — a formatter hook,
|
|
144
|
+
* a watcher, format-on-save in the user's editor, a `git checkout` by a
|
|
145
|
+
* background command. Until 2026-09-05 these waited for the next prompt;
|
|
146
|
+
* mid-turn the model discovered them only by hitting the stale-edit guard
|
|
147
|
+
* (one blocked call plus a re-read per edit for anyone with an asynchronous
|
|
148
|
+
* formatter). Claude Code computes its `edited_text_file` attachments on
|
|
149
|
+
* every tool round, so the model reads the change before its next edit
|
|
150
|
+
* (review M5). The one-shot is pending when the next request goes out and
|
|
151
|
+
* the injector pins it to that tool result — pi runs `tool_result` hooks
|
|
152
|
+
* BEFORE it emits `tool_execution_end` (agent-loop.js
|
|
153
|
+
* finalizeExecutedToolCall → emitToolExecutionEnd), so it cannot be written
|
|
154
|
+
* into the stored result; pinned is the same mechanism tool-search's
|
|
155
|
+
* deferred-tool miss uses. Our own edit/write/read of a file was observed by
|
|
156
|
+
* the `tool_result` handler above before this fires, so it is fresh here
|
|
157
|
+
* and never reported as external — but only for a call that ran alone. pi
|
|
158
|
+
* executes a batch of parallel tool calls CONCURRENTLY, so the first edit's
|
|
159
|
+
* end fires while a sibling edit has already written its file and not yet
|
|
160
|
+
* had its `tool_result` observation, and the scan reported the model's own
|
|
161
|
+
* edit as "modified by the user, a linter, or a command"
|
|
162
|
+
* (WEAK-MODEL-REVIEW-2026-09-06 M2). Hence `executing`: mid-turn the scan
|
|
163
|
+
* runs only once the batch has fully drained.
|
|
76
164
|
*/
|
|
77
|
-
|
|
165
|
+
const reportExternalChanges = () => {
|
|
166
|
+
const detailed: string[] = [];
|
|
167
|
+
const overflow: string[] = [];
|
|
78
168
|
for (const path of tracker.tracked) {
|
|
79
169
|
const previous = tracker.lastSeen(path);
|
|
80
170
|
if (previous === undefined || previous === "") continue;
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
171
|
+
const stamp = statIfPresent(path);
|
|
172
|
+
if (stamp && tracker.unchangedOnDisk(path, stamp)) continue;
|
|
173
|
+
const current = stamp ? readIfPresent(path) : undefined;
|
|
174
|
+
if (stamp === undefined || current === undefined) {
|
|
83
175
|
tracker.forget(path);
|
|
84
|
-
|
|
85
|
-
text: `${path} no longer exists; it was deleted or moved after you read it.`,
|
|
86
|
-
});
|
|
176
|
+
detailed.push(`${path} no longer exists; it was deleted or moved after you read it.`);
|
|
87
177
|
continue;
|
|
88
178
|
}
|
|
179
|
+
tracker.recordStamp(path, stamp);
|
|
89
180
|
if (current === previous) continue;
|
|
90
181
|
// Don't repeat the same warning every turn…
|
|
91
182
|
if (tracker.alreadyNotified(path, current)) continue;
|
|
92
183
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
184
|
+
if (detailed.length < DETAILED_CHANGE_REMINDERS_PER_TURN) {
|
|
185
|
+
const excerpt = describeChanges(previous, current);
|
|
186
|
+
if (excerpt) detailed.push(EXTERNAL_CHANGE_REMINDER(path, excerpt));
|
|
187
|
+
} else {
|
|
188
|
+
overflow.push(path);
|
|
96
189
|
}
|
|
97
190
|
// …but deliberately do NOT record the new content as seen: the file must
|
|
98
191
|
// stay stale so the edit guard still forces a re-read. Marking it read
|
|
99
192
|
// here would announce the change and then permit the clobbering edit.
|
|
100
193
|
tracker.markNotified(path, current);
|
|
101
194
|
}
|
|
195
|
+
for (const text of detailed) pi.events.emit(REMINDER_CHANNEL, { text });
|
|
196
|
+
if (overflow.length > 0) {
|
|
197
|
+
pi.events.emit(REMINDER_CHANNEL, {
|
|
198
|
+
text: `${overflow.length} more file(s) you read earlier changed on disk since: ${overflow.join(", ")}. Re-read any of them before editing it.`,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Tool calls currently executing, by call id. A batch issued in one assistant
|
|
204
|
+
* message runs concurrently, and only the last call to finish sees every
|
|
205
|
+
* sibling's write already observed.
|
|
206
|
+
*/
|
|
207
|
+
const executing = new Set<string>();
|
|
208
|
+
pi.on("tool_execution_start", (event) => {
|
|
209
|
+
executing.add(event.toolCallId);
|
|
210
|
+
return undefined;
|
|
211
|
+
});
|
|
212
|
+
pi.on("agent_start", () => {
|
|
213
|
+
// A turn aborted mid-batch can leave ids behind; each turn starts from a
|
|
214
|
+
// clean set so the mid-turn scan cannot be wedged off for the rest of the
|
|
215
|
+
// session.
|
|
216
|
+
executing.clear();
|
|
217
|
+
reportExternalChanges();
|
|
218
|
+
return undefined;
|
|
219
|
+
});
|
|
220
|
+
pi.on("tool_execution_end", (event) => {
|
|
221
|
+
executing.delete(event.toolCallId);
|
|
222
|
+
if (executing.size > 0) return undefined;
|
|
223
|
+
reportExternalChanges();
|
|
224
|
+
return undefined;
|
|
102
225
|
});
|
|
103
226
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
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).
|
|
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.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const READ_TOOLS = new Set(["read", "notebook_edit"]);
|
|
15
|
+
const WRITE_TOOLS = new Set(["edit", "write", "notebook_edit"]);
|
|
16
|
+
|
|
17
|
+
interface ToolCallBlock {
|
|
18
|
+
type: "toolCall";
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
arguments?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Paths (as written in the tool input) of every successful read/edit/write on the branch, in order, deduplicated. */
|
|
25
|
+
export function pathsReadOnBranch(entries: unknown[]): string[] {
|
|
26
|
+
const calls = new Map<string, { name: string; path: string }>();
|
|
27
|
+
const seen: string[] = [];
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
const e = entry as { type?: string; message?: { role?: string; content?: unknown; toolCallId?: string; toolName?: string; isError?: boolean } };
|
|
30
|
+
if (e?.type !== "message" || !e.message) continue;
|
|
31
|
+
const m = e.message;
|
|
32
|
+
if (m.role === "assistant" && Array.isArray(m.content)) {
|
|
33
|
+
for (const block of m.content as ToolCallBlock[]) {
|
|
34
|
+
if (block?.type !== "toolCall" || !block.id) continue;
|
|
35
|
+
const path = block.arguments?.path;
|
|
36
|
+
if (typeof path === "string" && path.trim()) calls.set(block.id, { name: block.name, path });
|
|
37
|
+
}
|
|
38
|
+
} else if (m.role === "toolResult" && m.toolCallId && !m.isError) {
|
|
39
|
+
const call = calls.get(m.toolCallId);
|
|
40
|
+
if (!call) continue;
|
|
41
|
+
if (!READ_TOOLS.has(call.name) && !WRITE_TOOLS.has(call.name)) continue;
|
|
42
|
+
if (!seen.includes(call.path)) seen.push(call.path);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return seen;
|
|
46
|
+
}
|
|
@@ -18,12 +18,28 @@ export interface TrackedFile {
|
|
|
18
18
|
at: number;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** What a stat says about a file on disk; equal stamps mean "no need to re-read". */
|
|
22
|
+
export interface FileStamp {
|
|
23
|
+
mtimeMs: number;
|
|
24
|
+
size: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
/** Keeps memory bounded on long sessions with large files. */
|
|
22
28
|
const MAX_TRACKED_FILES = 300;
|
|
23
29
|
const MAX_TRACKED_BYTES = 512 * 1024;
|
|
30
|
+
/** Stamps younger than this are re-read regardless (coarse-mtime filesystems, same-tick rewrites). */
|
|
31
|
+
export const RACY_STAMP_MS = 2000;
|
|
24
32
|
|
|
25
33
|
export class FileTracker {
|
|
26
34
|
private files = new Map<string, TrackedFile>();
|
|
35
|
+
/**
|
|
36
|
+
* The disk stamp of the content last compared per path — recorded when we
|
|
37
|
+
* observe a file and again each time the change scan reads it. The scan runs
|
|
38
|
+
* after every tool call, so a full read of every tracked file each time would
|
|
39
|
+
* cost up to 300 reads per call; a stat per file and a read only when the
|
|
40
|
+
* stamp moved keeps the common "nothing changed" case cheap.
|
|
41
|
+
*/
|
|
42
|
+
private stamps = new Map<string, FileStamp>();
|
|
27
43
|
/**
|
|
28
44
|
* Content we have already warned about, per path. Kept separate from `files`
|
|
29
45
|
* because telling the model a file changed must NOT make the file count as
|
|
@@ -33,8 +49,8 @@ export class FileTracker {
|
|
|
33
49
|
*/
|
|
34
50
|
private notified = new Map<string, string>();
|
|
35
51
|
|
|
36
|
-
/** Record what we currently believe a file contains (after a read or a write). */
|
|
37
|
-
observe(path: string, content: string, at: number): void {
|
|
52
|
+
/** Record what we currently believe a file contains (after a read or a write), and its disk stamp when known. */
|
|
53
|
+
observe(path: string, content: string, at: number, stamp?: FileStamp): void {
|
|
38
54
|
this.notified.delete(path);
|
|
39
55
|
if (content.length > MAX_TRACKED_BYTES) {
|
|
40
56
|
// Too large to diff usefully; remember that we saw it, not its content.
|
|
@@ -42,15 +58,36 @@ export class FileTracker {
|
|
|
42
58
|
} else {
|
|
43
59
|
this.files.set(path, { content, at });
|
|
44
60
|
}
|
|
61
|
+
if (stamp) this.stamps.set(path, stamp);
|
|
62
|
+
else this.stamps.delete(path);
|
|
45
63
|
if (this.files.size > MAX_TRACKED_FILES) {
|
|
46
64
|
const oldest = [...this.files.entries()].sort((a, b) => a[1].at - b[1].at)[0];
|
|
47
|
-
if (oldest) this.
|
|
65
|
+
if (oldest) this.forget(oldest[0]);
|
|
48
66
|
}
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
forget(path: string): void {
|
|
52
70
|
this.files.delete(path);
|
|
53
71
|
this.notified.delete(path);
|
|
72
|
+
this.stamps.delete(path);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True when the disk stamp matches the one recorded at the last read of this
|
|
77
|
+
* path: nothing to re-read. A stamp younger than `RACY_STAMP_MS` is never
|
|
78
|
+
* trusted: a same-size rewrite within the filesystem's mtime granularity of
|
|
79
|
+
* our read would leave the stamp unchanged (git's "racy" rule), so recent
|
|
80
|
+
* files take the full read until they age past the window.
|
|
81
|
+
*/
|
|
82
|
+
unchangedOnDisk(path: string, stamp: FileStamp, now = Date.now()): boolean {
|
|
83
|
+
if (now - stamp.mtimeMs < RACY_STAMP_MS) return false;
|
|
84
|
+
const known = this.stamps.get(path);
|
|
85
|
+
return known !== undefined && known.mtimeMs === stamp.mtimeMs && known.size === stamp.size;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The change scan read the file at this stamp; the next scan skips it until the stamp moves. */
|
|
89
|
+
recordStamp(path: string, stamp: FileStamp): void {
|
|
90
|
+
this.stamps.set(path, stamp);
|
|
54
91
|
}
|
|
55
92
|
|
|
56
93
|
/** True when this exact content has already been reported for this path. */
|
|
@@ -141,5 +178,15 @@ export const UNREAD_REASON = (path: string, tool: string) =>
|
|
|
141
178
|
export const STALE_REASON = (path: string) =>
|
|
142
179
|
`${path} has changed on disk since you last read it — someone else, a formatter, or a command may have modified it. Read it again before editing, or your change would overwrite theirs.`;
|
|
143
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Claude Code's `edited_text_file` attachment text, with two additions: the
|
|
183
|
+
* cause list also names a command (our tracker sees bash writes too), and the
|
|
184
|
+
* closing clause tells the model to re-read before editing — CC marks the file
|
|
185
|
+
* as read when it attaches the snippet, we deliberately do not (the stale-edit
|
|
186
|
+
* guard keeps firing), so without that clause the model's next edit is blocked
|
|
187
|
+
* and costs a round trip. The "intentional / don't revert / don't tell the
|
|
188
|
+
* user" steer is CC's: without it a model mid-edit tends to restore its own
|
|
189
|
+
* version and narrate linter noise (STEERING-REVIEW-2026-09-05 M5).
|
|
190
|
+
*/
|
|
144
191
|
export const EXTERNAL_CHANGE_REMINDER = (path: string, excerpt: ChangeExcerpt) =>
|
|
145
|
-
|
|
192
|
+
`Note: ${path} was modified, either by the user, a linter, or a command, after you last read it. This change was intentional, so make sure to take it into account as you proceed (ie. don't revert it unless the user asks you to). Don't tell the user this, since they are already aware. Here are the relevant changes (shown with line numbers); re-read the file before editing it:\n${excerpt.text}`;
|