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
|
@@ -1,41 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plan-mode extension — Claude Code's EnterPlanMode / ExitPlanMode tools,
|
|
3
|
-
* file-based: the plan lives at ~/.
|
|
3
|
+
* file-based: the plan lives at ~/.onecode/plans/<slug>.md, the one path plan
|
|
4
4
|
* mode may write. The path is announced to the model in an every-turn reminder
|
|
5
5
|
* and to the permissions extension over the event bus, and exit_plan_mode
|
|
6
6
|
* reads the file rather than taking the plan as a parameter.
|
|
7
7
|
*
|
|
8
8
|
* Mode state is owned by the permissions extension; this extension requests
|
|
9
9
|
* changes over MODE_CHANNEL and reacts to transitions it observes on
|
|
10
|
-
* PERMISSION_STATUS_CHANNEL (which fires on every setMode).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* PERMISSION_STATUS_CHANNEL (which fires on every setMode). The plan block is
|
|
11
|
+
* (re)installed at two points: synchronously on the transition INTO plan mode
|
|
12
|
+
* (the status handler, with the last context seen), so a ctrl+q during a long
|
|
13
|
+
* turn puts the block on the queue before the turn's next request — until
|
|
14
|
+
* 2026-09-05 the mid-turn requests ran with no plan reminder while every edit
|
|
15
|
+
* was denied "see the plan-mode reminder" (STEERING-REVIEW-2026-09-05 M3) —
|
|
16
|
+
* and on before_agent_start, which covers a session that STARTS in plan mode
|
|
17
|
+
* before this extension has seen a context (defaultMode: "plan"; permissions'
|
|
18
|
+
* session_start runs first) and runs after every extension's session_start,
|
|
19
|
+
* so restoring a previous path from the session branch never races a fresh
|
|
20
|
+
* allocation.
|
|
15
21
|
*/
|
|
16
22
|
|
|
17
23
|
import { existsSync, readFileSync } from "node:fs";
|
|
18
24
|
import { join } from "node:path";
|
|
19
25
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
26
|
import { Type } from "typebox";
|
|
27
|
+
import { DEFER_CHANNEL } from "../lib/deferred.ts";
|
|
21
28
|
import { oneCodeStateDir } from "../lib/paths.ts";
|
|
22
29
|
import { REMINDER_CHANNEL } from "../lib/reminders.ts";
|
|
23
30
|
import { ccToolRenderers, safeThemePaint } from "../lib/tui-render.ts";
|
|
31
|
+
import type { PermissionMode } from "../permissions/matcher.ts";
|
|
24
32
|
import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
|
|
25
33
|
import { buildPlanModeReminder } from "./reminder.ts";
|
|
26
34
|
import { randomSlug } from "./slug.ts";
|
|
27
|
-
import { clampOffset, decodeViewerKey, type PlanChoice, renderPlanViewer, wrapPlanText } from "./viewer.ts";
|
|
35
|
+
import { clampOffset, decodeViewerKey, initialPlanChoice, type PlanChoice, renderPlanViewer, wrapPlanText } from "./viewer.ts";
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
export const PLAN_FILE_CHANNEL = "one-code:plan-file-path";
|
|
37
|
+
import { MODE_CHANNEL, PLAN_FILE_CHANNEL } from "../lib/plan-mode-channels.ts";
|
|
38
|
+
export { MODE_CHANNEL, PLAN_FILE_CHANNEL };
|
|
32
39
|
/** Session entry type persisting the allocated path across resume/branch. */
|
|
33
|
-
const PLAN_FILE_ENTRY = "plan-mode-file";
|
|
40
|
+
const PLAN_FILE_ENTRY = "one-code:plan-mode-file";
|
|
34
41
|
|
|
35
42
|
const PLANS_DIR = () => join(oneCodeStateDir(), "plans");
|
|
36
43
|
|
|
44
|
+
/** How each mode is named in the approval choices. */
|
|
45
|
+
const MODE_NAMES: Record<PermissionMode, string> = {
|
|
46
|
+
default: "manual approvals",
|
|
47
|
+
acceptEdits: "auto-accept edits",
|
|
48
|
+
plan: "plan mode",
|
|
49
|
+
auto: "auto mode",
|
|
50
|
+
bypassPermissions: "bypass permissions",
|
|
51
|
+
dontAsk: "dontAsk",
|
|
52
|
+
};
|
|
53
|
+
|
|
37
54
|
export default function planModeExtension(pi: ExtensionAPI) {
|
|
38
|
-
|
|
55
|
+
/** The last mode broadcast by permissions; undefined until the first status arrives. */
|
|
56
|
+
let currentMode: string | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* The mode the session was in when it entered plan mode, offered first on
|
|
59
|
+
* exit (Claude Code's ExitPlanMode restores `prePlanMode`). Unknown for a
|
|
60
|
+
* session that STARTED in plan mode — the first status seen is plan itself —
|
|
61
|
+
* so that session gets the fixed list. Until 2026-09-05 the exit could only
|
|
62
|
+
* escalate: a default-mode session that planned was offered auto
|
|
63
|
+
* (PERMISSIONS-REVIEW-2026-09-05 M4).
|
|
64
|
+
*/
|
|
65
|
+
let modeBeforePlan: PermissionMode | undefined;
|
|
39
66
|
let planFilePath: string | undefined;
|
|
40
67
|
|
|
41
68
|
/** Restore the branch's plan file, else allocate a fresh slug. */
|
|
@@ -63,18 +90,37 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
63
90
|
const path = ensurePlanFile(ctx);
|
|
64
91
|
pi.events.emit(PLAN_FILE_CHANNEL, { path });
|
|
65
92
|
pi.events.emit(REMINDER_CHANNEL, {
|
|
66
|
-
text: buildPlanModeReminder(
|
|
93
|
+
text: buildPlanModeReminder(path),
|
|
67
94
|
scope: "every-turn",
|
|
68
95
|
key: "permission-mode",
|
|
96
|
+
placement: "sticky-append",
|
|
69
97
|
});
|
|
70
98
|
};
|
|
71
99
|
|
|
100
|
+
/** The last context seen, for a refresh triggered off the bus (no ctx of its own). */
|
|
101
|
+
let lastCtx: ExtensionContext | undefined;
|
|
102
|
+
|
|
72
103
|
pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
|
|
73
104
|
const status = data as PermissionStatus;
|
|
74
|
-
if (typeof status?.mode
|
|
105
|
+
if (typeof status?.mode !== "string") return;
|
|
106
|
+
const previous = currentMode;
|
|
107
|
+
currentMode = status.mode;
|
|
108
|
+
if (currentMode === "plan" && previous !== "plan") modeBeforePlan = previous as PermissionMode | undefined;
|
|
109
|
+
// Entering plan mode mid-turn: permissions' setMode has just dropped the
|
|
110
|
+
// shared "permission-mode" key (before broadcasting this status, so this
|
|
111
|
+
// re-add is not undone) and will announce the change on the next tool
|
|
112
|
+
// result; the standing block has to be back on the queue for that same
|
|
113
|
+
// request. The path is announced over PLAN_FILE_CHANNEL inside refresh,
|
|
114
|
+
// synchronously, so setMode's announcement can name it.
|
|
115
|
+
if (currentMode === "plan" && previous !== "plan" && lastCtx) refresh(lastCtx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
pi.on("session_start", (_event, ctx) => {
|
|
119
|
+
lastCtx = ctx;
|
|
75
120
|
});
|
|
76
121
|
|
|
77
122
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
123
|
+
lastCtx = ctx;
|
|
78
124
|
if (currentMode === "plan") refresh(ctx);
|
|
79
125
|
});
|
|
80
126
|
|
|
@@ -83,16 +129,16 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
83
129
|
label: "Enter plan mode",
|
|
84
130
|
...ccToolRenderers("Enter plan mode"),
|
|
85
131
|
description:
|
|
86
|
-
"Enter plan mode
|
|
87
|
-
|
|
132
|
+
"Enter plan mode: read-only investigation to design an approach before changing anything. Most tasks do not need it.\n" +
|
|
133
|
+
"For a small or clearly-scoped change, act directly instead. Enter plan mode only for multi-file work whose design is genuinely unclear, or when the user asks for a plan. In plan mode only read-only tools are available, plus one writable file: the plan file whose path you are told, where you build the plan incrementally.",
|
|
88
134
|
parameters: Type.Object({}),
|
|
89
135
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
90
136
|
pi.events.emit(MODE_CHANNEL, { mode: "plan" });
|
|
91
|
-
// setMode
|
|
92
|
-
//
|
|
93
|
-
//
|
|
137
|
+
// setMode broadcasts synchronously over the status channel, so the
|
|
138
|
+
// listener above has already installed the block and announced the
|
|
139
|
+
// path (or the mode was plan already and both stand); name the file in
|
|
140
|
+
// the tool result too so the model can start writing this same turn.
|
|
94
141
|
const path = ensurePlanFile(ctx);
|
|
95
|
-
refresh(ctx);
|
|
96
142
|
return {
|
|
97
143
|
content: [
|
|
98
144
|
{
|
|
@@ -110,8 +156,8 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
110
156
|
label: "Exit plan mode",
|
|
111
157
|
...ccToolRenderers("Exit plan mode"),
|
|
112
158
|
description:
|
|
113
|
-
"Signal that planning is complete and ask the user to approve the plan
|
|
114
|
-
|
|
159
|
+
"Signal that planning is complete and ask the user to approve the plan.\n" +
|
|
160
|
+
"Takes no parameters: the plan is read from the plan file named in the plan-mode reminder, which you must have written before calling this. The user reviews that file's contents.",
|
|
115
161
|
parameters: Type.Object({}),
|
|
116
162
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
117
163
|
// Guard the out-of-sequence call: without this, a cold exit_plan_mode
|
|
@@ -145,45 +191,93 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
145
191
|
};
|
|
146
192
|
}
|
|
147
193
|
|
|
194
|
+
// Plan approval is the user's: with no UI there is no one to approve, and
|
|
195
|
+
// until 2026-09-05 the model exited plan mode by itself here — a
|
|
196
|
+
// `-p --permission-mode plan` run was read-only in name only, since the
|
|
197
|
+
// user's allow rules applied the moment the mode became default
|
|
198
|
+
// (PERMISSIONS-REVIEW-2026-09-05 M4, measured: `git add -A` ran). The
|
|
199
|
+
// session stays in plan mode; the plan file is the run's product.
|
|
148
200
|
if (!ctx.hasUI) {
|
|
149
|
-
pi.events.emit(MODE_CHANNEL, { mode: "default" });
|
|
150
201
|
return {
|
|
151
202
|
content: [
|
|
152
|
-
{
|
|
203
|
+
{
|
|
204
|
+
type: "text",
|
|
205
|
+
text: `Plan approval needs an interactive session, and this one has no user to approve it. The session stays in plan mode; the plan is recorded at ${path} for the user to review. Do not try to implement it here.`,
|
|
206
|
+
},
|
|
153
207
|
],
|
|
154
|
-
details: { plan, approved:
|
|
208
|
+
details: { planFilePath: path, plan, approved: false },
|
|
209
|
+
isError: true,
|
|
155
210
|
};
|
|
156
211
|
}
|
|
157
212
|
|
|
213
|
+
// The mode the session was in before planning leads the list (Claude
|
|
214
|
+
// Code restores it), then auto mode when a classifier model is reachable —
|
|
215
|
+
// the same gate the mode cycle uses (permissions/index.ts autoInCycle) —
|
|
216
|
+
// and the fixed choices. Each approving choice carries the mode it
|
|
217
|
+
// switches to; the final "Keep planning" choice has none and leaves the
|
|
218
|
+
// session in plan mode.
|
|
219
|
+
const autoAvailable = ctx.modelRegistry.getAvailable().length > 0;
|
|
220
|
+
const restore = modeBeforePlan && (modeBeforePlan !== "auto" || autoAvailable) ? modeBeforePlan : undefined;
|
|
221
|
+
const fixedModes: PermissionMode[] = [...(autoAvailable ? (["auto"] as const) : []), "acceptEdits", "default"];
|
|
222
|
+
const options: { label: string; mode?: PermissionMode; restore?: boolean }[] = [
|
|
223
|
+
...(restore ? [{ label: `Approve — back to ${MODE_NAMES[restore]} (the mode before planning)`, mode: restore, restore: true }] : []),
|
|
224
|
+
...fixedModes.filter((mode) => mode !== restore).map((mode) => ({ label: `Approve — ${MODE_NAMES[mode]}`, mode })),
|
|
225
|
+
{ label: "Keep planning" },
|
|
226
|
+
];
|
|
227
|
+
const choices = options.map((o) => o.label);
|
|
228
|
+
|
|
158
229
|
const choice = await ctx.ui.custom<PlanChoice | null>((tui, theme, _keybindings, done) => {
|
|
159
230
|
const paint = safeThemePaint(theme);
|
|
160
231
|
const maxVisible = 12;
|
|
161
232
|
let offset = 0;
|
|
162
|
-
let selected: PlanChoice =
|
|
233
|
+
let selected: PlanChoice = initialPlanChoice(options);
|
|
163
234
|
let lineCount = 0;
|
|
235
|
+
// The dialog stays open while exit_plan_mode runs, so pi's working
|
|
236
|
+
// spinner drives ~8 frames a second — and re-wrapping the whole plan
|
|
237
|
+
// each frame burned 5–17 ms on a long plan (TUI-REVIEW M1). The plan
|
|
238
|
+
// is immutable for the dialog's life: wrap once per width, and cache
|
|
239
|
+
// the rendered frame by (width, offset, selected) so a steady frame
|
|
240
|
+
// costs nothing. invalidate() (theme swap) drops both.
|
|
241
|
+
// Only one width is ever live at a time (a resize is the only thing that
|
|
242
|
+
// changes it), so a single-slot memo is enough — like the model picker's.
|
|
243
|
+
let wrapped: { width: number; lines: string[] } | undefined;
|
|
244
|
+
let frame: { key: string; lines: string[] } | undefined;
|
|
245
|
+
const wrapAt = (wrapWidth: number): string[] => {
|
|
246
|
+
if (wrapped?.width !== wrapWidth) wrapped = { width: wrapWidth, lines: wrapPlanText(plan, wrapWidth) };
|
|
247
|
+
return wrapped.lines;
|
|
248
|
+
};
|
|
164
249
|
return {
|
|
165
250
|
render: (width: number) => {
|
|
166
|
-
const lines =
|
|
251
|
+
const lines = wrapAt(Math.max(10, width - 1));
|
|
167
252
|
lineCount = lines.length;
|
|
168
253
|
offset = clampOffset(offset, lineCount, maxVisible);
|
|
169
|
-
|
|
254
|
+
const key = `${width}:${offset}:${selected}`;
|
|
255
|
+
if (frame?.key === key) return frame.lines;
|
|
256
|
+
const rendered = renderPlanViewer({ lines, offset, choice: selected, choices, maxVisible }, paint, width);
|
|
257
|
+
frame = { key, lines: rendered };
|
|
258
|
+
return rendered;
|
|
170
259
|
},
|
|
171
260
|
handleInput: (data: string) => {
|
|
172
261
|
const key = decodeViewerKey(data, maxVisible);
|
|
173
262
|
if (!key) return;
|
|
174
263
|
if (key.kind === "cancel") return done(null);
|
|
175
264
|
if (key.kind === "confirm") return done(selected);
|
|
176
|
-
if (key.kind === "pick") return done(key.index);
|
|
265
|
+
if (key.kind === "pick") return key.index < options.length ? done(key.index) : undefined;
|
|
177
266
|
if (key.kind === "scroll") offset = clampOffset(offset + key.delta, lineCount, maxVisible);
|
|
178
|
-
else if (key.kind === "choice")
|
|
267
|
+
else if (key.kind === "choice")
|
|
268
|
+
selected = (((selected + key.delta) % options.length) + options.length) % options.length;
|
|
179
269
|
tui.requestRender();
|
|
180
270
|
},
|
|
181
|
-
invalidate: () => {
|
|
271
|
+
invalidate: () => {
|
|
272
|
+
wrapped = undefined;
|
|
273
|
+
frame = undefined;
|
|
274
|
+
},
|
|
182
275
|
};
|
|
183
276
|
});
|
|
184
277
|
|
|
185
|
-
|
|
186
|
-
|
|
278
|
+
const picked = choice != null ? options[choice] : undefined;
|
|
279
|
+
if (picked?.mode) {
|
|
280
|
+
pi.events.emit(MODE_CHANNEL, { mode: picked.mode });
|
|
187
281
|
return {
|
|
188
282
|
content: [
|
|
189
283
|
{
|
|
@@ -206,4 +300,20 @@ export default function planModeExtension(pi: ExtensionAPI) {
|
|
|
206
300
|
};
|
|
207
301
|
},
|
|
208
302
|
});
|
|
303
|
+
|
|
304
|
+
// Claude Code defers both plan-mode tools behind ToolSearch: they are absent
|
|
305
|
+
// from the wire tool list and its system prompt never mentions plan mode, so
|
|
306
|
+
// the model must deliberately load them before planning. Keeping them
|
|
307
|
+
// always-active (with a prompt snippet) read as standing "plan first" policy
|
|
308
|
+
// to instruction-eager third-party models, which then entered plan mode for
|
|
309
|
+
// trivial tasks. User-initiated plan mode (mode cycling, defaultMode) is
|
|
310
|
+
// unaffected — that flows over MODE_CHANNEL, not through these tools.
|
|
311
|
+
pi.events.emit(DEFER_CHANNEL, {
|
|
312
|
+
name: "enter_plan_mode",
|
|
313
|
+
keywords: ["plan", "planning", "design", "approach", "architecture", "investigate", "read-only"],
|
|
314
|
+
});
|
|
315
|
+
pi.events.emit(DEFER_CHANNEL, {
|
|
316
|
+
name: "exit_plan_mode",
|
|
317
|
+
keywords: ["plan", "approve", "approval", "present", "finish planning"],
|
|
318
|
+
});
|
|
209
319
|
}
|
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plan-mode reminder text (pure). Byte-identical for a given
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Plan-mode reminder text (pure). Byte-identical for a given plan-file path,
|
|
3
|
+
* whether or not the file exists yet: the block is a sticky-append reminder
|
|
4
|
+
* carried by every user message since plan mode came on, and the queue keeps
|
|
5
|
+
* that anchor only while the re-emitted text is unchanged. A text that flipped
|
|
6
|
+
* when the file appeared re-anchored the block and re-cached every message
|
|
7
|
+
* between entering plan mode and the first write (CACHE-REVIEW-2026-09-04 M2).
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
* model builds incrementally — the one writable path in plan mode —
|
|
8
|
-
* exit_plan_mode reads that file rather than taking the plan as a
|
|
9
|
+
* Adapted from Claude Code's plan-mode system message: the plan lives in a
|
|
10
|
+
* file the model builds incrementally — the one writable path in plan mode —
|
|
11
|
+
* and exit_plan_mode reads that file rather than taking the plan as a
|
|
12
|
+
* parameter. Claude Code's own text does vary with the file's existence; ours
|
|
13
|
+
* folds both states into one sentence so the cached prefix survives.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
|
-
export
|
|
12
|
-
filePath
|
|
13
|
-
fileExists: boolean;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function buildPlanModeReminder({ filePath, fileExists }: PlanReminderState): string {
|
|
17
|
-
const fileLine = fileExists
|
|
18
|
-
? `Continue building your plan at ${filePath} with the write/edit tools — edit it incrementally rather than rewriting it from scratch.`
|
|
19
|
-
: `No plan file exists yet — create it at ${filePath} with the write tool. Build the plan there incrementally as you investigate; do not present the plan as chat text.`;
|
|
20
|
-
|
|
16
|
+
export function buildPlanModeReminder(filePath: string): string {
|
|
17
|
+
const fileLine = `Build your plan at ${filePath} with the write/edit tools — create it if it does not exist yet, then edit it incrementally rather than rewriting it from scratch. Do not present the plan as chat text.`;
|
|
21
18
|
return [
|
|
22
19
|
`Plan mode is active. You may only use read-only tools; edit and write are blocked everywhere except one file. ${fileLine}`,
|
|
23
20
|
"",
|
|
24
21
|
"Workflow:",
|
|
25
|
-
'1. Explore: delegate broad reconnaissance to `
|
|
26
|
-
'2. Design: delegate the implementation strategy to `
|
|
22
|
+
'1. Explore: delegate broad reconnaissance to the `Agent` tool with `subagent_type: "explore"` (up to 3 in parallel) rather than reading everything yourself.',
|
|
23
|
+
'2. Design: delegate the implementation strategy to the `Agent` tool with `subagent_type: "plan"` once exploration has mapped the ground.',
|
|
27
24
|
"3. Review the critical files yourself, and call `ask_user_question` for any decision you cannot make on the user's behalf.",
|
|
28
25
|
`4. Write the final plan to ${filePath}: a Context section explaining why, your recommended approach (not several options), the critical files named, existing utilities to reuse, and a verification section.`,
|
|
29
|
-
"5. Call `exit_plan_mode` to ask the user to approve the plan. It reads the plan file directly — you do not pass the plan as a parameter.",
|
|
26
|
+
"5. Call `exit_plan_mode` to ask the user to approve the plan. It reads the plan file directly — you do not pass the plan as a parameter. If `exit_plan_mode` is not in your active tools, load it first with tool_search (`select:exit_plan_mode`).",
|
|
30
27
|
"",
|
|
31
28
|
"End every plan-mode turn with either `ask_user_question` or `exit_plan_mode`. Never ask for plan approval in chat text.",
|
|
32
29
|
].join("\n");
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* painting so ANSI escapes never confuse the width accounting.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
import { cutPlainText } from "../lib/tui-render.ts";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
export
|
|
15
|
+
/** Hard-wrap plain text to `width` columns, preserving blank lines. */
|
|
16
|
+
export { wrapPlainText as wrapPlanText } from "../lib/tui-render.ts";
|
|
17
|
+
|
|
18
|
+
/** What the dialog resolves to; index into the choices list, or null for cancel. */
|
|
19
|
+
export type PlanChoice = number;
|
|
17
20
|
|
|
18
21
|
export type ViewerKey =
|
|
19
22
|
| { kind: "scroll"; delta: number }
|
|
@@ -22,7 +25,12 @@ export type ViewerKey =
|
|
|
22
25
|
| { kind: "confirm" }
|
|
23
26
|
| { kind: "cancel" };
|
|
24
27
|
|
|
25
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Decode a raw terminal chunk; `page` is the scroll step for PgUp/PgDn. Digit
|
|
30
|
+
* keys resolve to a raw 0-based `pick` index; the caller clamps it to the number
|
|
31
|
+
* of choices actually shown (the list is dynamic — auto mode joins only when a
|
|
32
|
+
* classifier model is reachable).
|
|
33
|
+
*/
|
|
26
34
|
export function decodeViewerKey(data: string, page: number): ViewerKey | undefined {
|
|
27
35
|
switch (data) {
|
|
28
36
|
case "\x1b[A":
|
|
@@ -48,36 +56,37 @@ export function decodeViewerKey(data: string, page: number): ViewerKey | undefin
|
|
|
48
56
|
case "\x1b":
|
|
49
57
|
case "\x03": // ctrl+c — same intent as escape while the dialog is focused
|
|
50
58
|
return { kind: "cancel" };
|
|
51
|
-
case "1":
|
|
52
|
-
case "2":
|
|
53
|
-
case "3":
|
|
54
|
-
return { kind: "pick", index: (Number(data) - 1) as PlanChoice };
|
|
55
59
|
default:
|
|
60
|
+
if (data.length === 1 && data >= "1" && data <= "9") {
|
|
61
|
+
return { kind: "pick", index: Number(data) - 1 };
|
|
62
|
+
}
|
|
56
63
|
return undefined;
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
/** Hard-wrap plain text to `width` columns, preserving blank lines. */
|
|
61
|
-
export function wrapPlanText(text: string, width: number): string[] {
|
|
62
|
-
const columns = Math.max(1, width);
|
|
63
|
-
const out: string[] = [];
|
|
64
|
-
for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
|
|
65
|
-
const chars = [...raw];
|
|
66
|
-
if (chars.length === 0) {
|
|
67
|
-
out.push("");
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
for (let i = 0; i < chars.length; i += columns) {
|
|
71
|
-
out.push(chars.slice(i, i + columns).join(""));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return out;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
67
|
export function clampOffset(offset: number, total: number, visible: number): number {
|
|
78
68
|
return Math.max(0, Math.min(offset, Math.max(0, total - visible)));
|
|
79
69
|
}
|
|
80
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The initially highlighted choice: the safest approving option ("manual
|
|
73
|
+
* approvals", mode "default"). Auto mode may lead the list so it is one
|
|
74
|
+
* keypress away, but Enter without arrowing must keep approving into manual
|
|
75
|
+
* mode — the pre-auto-option behavior — never silently switch the session
|
|
76
|
+
* into a more permissive mode.
|
|
77
|
+
*/
|
|
78
|
+
/**
|
|
79
|
+
* The pre-selected choice: the option that restores the mode the session was
|
|
80
|
+
* in before planning when one is offered (Claude Code's ExitPlanMode restores
|
|
81
|
+
* `prePlanMode`), else "manual approvals", else the first option.
|
|
82
|
+
*/
|
|
83
|
+
export function initialPlanChoice(options: readonly { mode?: string; restore?: boolean }[]): PlanChoice {
|
|
84
|
+
const restore = options.findIndex((o) => o.restore);
|
|
85
|
+
if (restore >= 0) return restore;
|
|
86
|
+
const index = options.findIndex((o) => o.mode === "default");
|
|
87
|
+
return index >= 0 ? index : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
export type Paint = (color: string, text: string) => string;
|
|
82
91
|
|
|
83
92
|
export interface PlanViewerView {
|
|
@@ -85,22 +94,22 @@ export interface PlanViewerView {
|
|
|
85
94
|
lines: string[];
|
|
86
95
|
offset: number;
|
|
87
96
|
choice: PlanChoice;
|
|
97
|
+
/** Labels for the numbered choice list; the last is conventionally "Keep planning". */
|
|
98
|
+
choices: readonly string[];
|
|
88
99
|
maxVisible?: number;
|
|
89
100
|
}
|
|
90
101
|
|
|
91
102
|
export function renderPlanViewer(view: PlanViewerView, paint: Paint, width: number): string[] {
|
|
92
103
|
const maxVisible = view.maxVisible ?? 12;
|
|
93
104
|
// Cut plain text before painting so escapes never enter the width math.
|
|
94
|
-
const cut = (line: string) =>
|
|
95
|
-
const chars = [...line];
|
|
96
|
-
return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join("")}…` : line;
|
|
97
|
-
};
|
|
105
|
+
const cut = (line: string) => cutPlainText(line, width);
|
|
98
106
|
const rule = paint("dim", "─".repeat(Math.max(0, Math.min(width, 72))));
|
|
107
|
+
const pickHint = view.choices.length > 1 ? `1-${view.choices.length} pick · ` : "";
|
|
99
108
|
|
|
100
109
|
const out: string[] = [];
|
|
101
110
|
out.push("");
|
|
102
111
|
out.push(paint("accent", cut("Approve this plan?")));
|
|
103
|
-
out.push(paint("dim", cut(
|
|
112
|
+
out.push(paint("dim", cut(`↑/↓ scroll · ←/→ switch choice · ${pickHint}enter confirm · esc keep planning`)));
|
|
104
113
|
out.push(rule);
|
|
105
114
|
|
|
106
115
|
const offset = clampOffset(view.offset, view.lines.length, maxVisible);
|
|
@@ -113,7 +122,7 @@ export function renderPlanViewer(view: PlanViewerView, paint: Paint, width: numb
|
|
|
113
122
|
}
|
|
114
123
|
out.push(rule);
|
|
115
124
|
|
|
116
|
-
for (const [index, label] of
|
|
125
|
+
for (const [index, label] of view.choices.entries()) {
|
|
117
126
|
const numbered = `${index + 1}. ${label}`;
|
|
118
127
|
out.push(
|
|
119
128
|
index === view.choice ? paint("accent", cut(`❯ ${numbered}`)) : cut(` ${numbered}`),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install counts for the Discover tab.
|
|
3
|
+
*
|
|
4
|
+
* Public stats JSON from the official marketplace repository
|
|
5
|
+
* (`[{plugin: "name@marketplace", unique_installs}]`), cached under the plugin
|
|
6
|
+
* root for 24h. A fetch failure hides counts (undefined) — never shows zeros.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { readJsonFile, writeJsonAtomic } from "../lib/atomic-write.ts";
|
|
11
|
+
import { fetchWithTimeout } from "../lib/fetch-timeout.ts";
|
|
12
|
+
|
|
13
|
+
export const INSTALL_COUNTS_URL =
|
|
14
|
+
"https://raw.githubusercontent.com/anthropics/claude-plugins-official/refs/heads/stats/stats/plugin-installs.json";
|
|
15
|
+
|
|
16
|
+
const CACHE_FILE = "install-counts-cache.json";
|
|
17
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
interface CountsCache {
|
|
21
|
+
version: 1;
|
|
22
|
+
fetchedAt: string;
|
|
23
|
+
counts: Array<{ plugin: string; unique_installs: number }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toMap(counts: CountsCache["counts"]): Map<string, number> {
|
|
27
|
+
const map = new Map<string, number>();
|
|
28
|
+
for (const entry of counts) {
|
|
29
|
+
if (typeof entry?.plugin === "string" && typeof entry?.unique_installs === "number") {
|
|
30
|
+
map.set(entry.plugin, entry.unique_installs);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return map;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fresh cached counts, or undefined when absent/expired/malformed. */
|
|
37
|
+
export function readCachedCounts(root: string, now: Date = new Date()): Map<string, number> | undefined {
|
|
38
|
+
const cache = readJsonFile<CountsCache>(join(root, CACHE_FILE));
|
|
39
|
+
if (!cache || !Array.isArray(cache.counts) || typeof cache.fetchedAt !== "string") return undefined;
|
|
40
|
+
const fetchedAt = Date.parse(cache.fetchedAt);
|
|
41
|
+
if (Number.isNaN(fetchedAt) || now.getTime() - fetchedAt > CACHE_TTL_MS) return undefined;
|
|
42
|
+
return toMap(cache.counts);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Cached counts when fresh, else fetch + cache; undefined on failure. */
|
|
46
|
+
export async function fetchInstallCounts(root: string, now: Date = new Date()): Promise<Map<string, number> | undefined> {
|
|
47
|
+
const cached = readCachedCounts(root, now);
|
|
48
|
+
if (cached) return cached;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetchWithTimeout(INSTALL_COUNTS_URL, FETCH_TIMEOUT_MS);
|
|
52
|
+
if (!response.ok) return undefined;
|
|
53
|
+
const counts = (await response.json()) as CountsCache["counts"];
|
|
54
|
+
if (!Array.isArray(counts)) return undefined;
|
|
55
|
+
writeJsonAtomic(join(root, CACHE_FILE), { version: 1, fetchedAt: now.toISOString(), counts } satisfies CountsCache);
|
|
56
|
+
return toMap(counts);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 999 → "999", 36400 → "36.4K", 1_200_000 → "1.2M" (trailing .0 stripped). */
|
|
63
|
+
export function formatInstallCount(count: number): string {
|
|
64
|
+
if (count < 1000) return String(count);
|
|
65
|
+
const scaled = count < 1_000_000 ? count / 1000 : count / 1_000_000;
|
|
66
|
+
const suffix = count < 1_000_000 ? "K" : "M";
|
|
67
|
+
return `${scaled.toFixed(1).replace(/\.0$/, "")}${suffix}`;
|
|
68
|
+
}
|