one-code-extension 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +202 -0
  3. package/agents/explore.md +24 -0
  4. package/agents/general-purpose.md +21 -0
  5. package/agents/plan.md +22 -0
  6. package/docs/decisions/auto-mode.md +526 -0
  7. package/docs/decisions/branding.md +127 -0
  8. package/docs/decisions/code-review-remediation.md +106 -0
  9. package/docs/decisions/compaction.md +140 -0
  10. package/docs/decisions/distribution.md +133 -0
  11. package/docs/decisions/hooks.md +64 -0
  12. package/docs/decisions/lsp.md +58 -0
  13. package/docs/decisions/mcp.md +104 -0
  14. package/docs/decisions/memory-state.md +170 -0
  15. package/docs/decisions/model-policy.md +126 -0
  16. package/docs/decisions/modes.md +178 -0
  17. package/docs/decisions/skills-plugins.md +56 -0
  18. package/docs/decisions/subagents-workflows.md +225 -0
  19. package/docs/decisions/system-prompt.md +98 -0
  20. package/docs/decisions/tools.md +407 -0
  21. package/docs/decisions/tui.md +202 -0
  22. package/docs/decisions.md +148 -0
  23. package/extensions/ask-user/index.ts +124 -0
  24. package/extensions/ask-user/questions.ts +60 -0
  25. package/extensions/auto-mode/actions.ts +78 -0
  26. package/extensions/auto-mode/classifier.ts +388 -0
  27. package/extensions/auto-mode/config.ts +237 -0
  28. package/extensions/auto-mode/decision-log.ts +60 -0
  29. package/extensions/auto-mode/defaults.ts +65 -0
  30. package/extensions/auto-mode/instructions.ts +74 -0
  31. package/extensions/auto-mode/model-picker.ts +225 -0
  32. package/extensions/auto-mode/model-select.ts +178 -0
  33. package/extensions/auto-mode/paths.ts +109 -0
  34. package/extensions/auto-mode/pause.ts +96 -0
  35. package/extensions/auto-mode/prompt.ts +307 -0
  36. package/extensions/auto-mode/rules.ts +96 -0
  37. package/extensions/auto-mode/safety-floor.ts +100 -0
  38. package/extensions/auto-mode/sensitive.ts +137 -0
  39. package/extensions/auto-mode/shell-analysis.ts +702 -0
  40. package/extensions/background/index.ts +405 -0
  41. package/extensions/background/registry.ts +81 -0
  42. package/extensions/background/wakeup.ts +41 -0
  43. package/extensions/bash/background.ts +136 -0
  44. package/extensions/bash/index.ts +149 -0
  45. package/extensions/branding/assistant-marker.ts +53 -0
  46. package/extensions/branding/index.ts +366 -0
  47. package/extensions/branding/prompt-editor.ts +42 -0
  48. package/extensions/branding/prompt-marker.ts +29 -0
  49. package/extensions/branding/startup.ts +152 -0
  50. package/extensions/claude-compat/index.ts +50 -0
  51. package/extensions/claude-context/index.ts +79 -0
  52. package/extensions/compaction/index.ts +231 -0
  53. package/extensions/compaction/prompt.ts +191 -0
  54. package/extensions/context-management/index.ts +147 -0
  55. package/extensions/effort/index.ts +216 -0
  56. package/extensions/effort/slider.ts +253 -0
  57. package/extensions/exit/index.ts +28 -0
  58. package/extensions/file-tracker/index.ts +103 -0
  59. package/extensions/file-tracker/tracker.ts +145 -0
  60. package/extensions/hooks/debug.ts +53 -0
  61. package/extensions/hooks/executor.ts +132 -0
  62. package/extensions/hooks/index.ts +310 -0
  63. package/extensions/hooks/matcher.ts +72 -0
  64. package/extensions/hooks/plugin-hooks.ts +79 -0
  65. package/extensions/hooks/protocol.ts +151 -0
  66. package/extensions/hooks/settings.ts +163 -0
  67. package/extensions/hooks/trust.ts +151 -0
  68. package/extensions/lib/branch-restore.ts +35 -0
  69. package/extensions/lib/claude-context.ts +148 -0
  70. package/extensions/lib/deferred.ts +119 -0
  71. package/extensions/lib/git.ts +14 -0
  72. package/extensions/lib/memory.ts +219 -0
  73. package/extensions/lib/model-policy.ts +436 -0
  74. package/extensions/lib/model-tier.ts +99 -0
  75. package/extensions/lib/notifications.ts +18 -0
  76. package/extensions/lib/paths.ts +26 -0
  77. package/extensions/lib/per-cwd.ts +21 -0
  78. package/extensions/lib/persisted-output.ts +70 -0
  79. package/extensions/lib/pi-version.ts +54 -0
  80. package/extensions/lib/plugins.ts +227 -0
  81. package/extensions/lib/reminders.ts +212 -0
  82. package/extensions/lib/scratchpad.ts +68 -0
  83. package/extensions/lib/tui-render.ts +522 -0
  84. package/extensions/lsp/client.ts +278 -0
  85. package/extensions/lsp/format.ts +46 -0
  86. package/extensions/lsp/index.ts +193 -0
  87. package/extensions/lsp/protocol.ts +64 -0
  88. package/extensions/lsp/servers.ts +79 -0
  89. package/extensions/mcp/client.ts +205 -0
  90. package/extensions/mcp/config.ts +199 -0
  91. package/extensions/mcp/index.ts +373 -0
  92. package/extensions/mcp/schema.ts +161 -0
  93. package/extensions/memory/index.ts +90 -0
  94. package/extensions/notebook/index.ts +77 -0
  95. package/extensions/notebook/notebook.ts +108 -0
  96. package/extensions/permissions/index.ts +876 -0
  97. package/extensions/permissions/matcher.ts +455 -0
  98. package/extensions/permissions/modes.ts +107 -0
  99. package/extensions/permissions/protected-paths.ts +125 -0
  100. package/extensions/permissions/settings.ts +98 -0
  101. package/extensions/plan-mode/index.ts +209 -0
  102. package/extensions/plan-mode/reminder.ts +33 -0
  103. package/extensions/plan-mode/slug.ts +28 -0
  104. package/extensions/plan-mode/viewer.ts +124 -0
  105. package/extensions/plugins/index.ts +115 -0
  106. package/extensions/plugins/template.ts +42 -0
  107. package/extensions/search-tools/index.ts +0 -0
  108. package/extensions/search-tools/tools.ts +24 -0
  109. package/extensions/skill/index.ts +170 -0
  110. package/extensions/subagents/agents.ts +126 -0
  111. package/extensions/subagents/child.ts +414 -0
  112. package/extensions/subagents/default-model.ts +122 -0
  113. package/extensions/subagents/index.ts +1119 -0
  114. package/extensions/subagents/model-select.ts +346 -0
  115. package/extensions/subagents/rpc-turns.ts +123 -0
  116. package/extensions/subagents/runs.ts +93 -0
  117. package/extensions/subagents/usage.ts +53 -0
  118. package/extensions/subagents/worktree.ts +69 -0
  119. package/extensions/system-prompt/environment.ts +35 -0
  120. package/extensions/system-prompt/index.ts +54 -0
  121. package/extensions/system-prompt/template.ts +103 -0
  122. package/extensions/system-prompt/tiers/common.ts +63 -0
  123. package/extensions/system-prompt/tiers/frontier.ts +24 -0
  124. package/extensions/system-prompt/tiers/low.ts +71 -0
  125. package/extensions/system-prompt/tiers/mid.ts +56 -0
  126. package/extensions/system-reminder/index.ts +37 -0
  127. package/extensions/tasks/index.ts +140 -0
  128. package/extensions/tasks/store.ts +174 -0
  129. package/extensions/todo/index.ts +133 -0
  130. package/extensions/tool-search/index.ts +168 -0
  131. package/extensions/tool-style/index.ts +84 -0
  132. package/extensions/web/index.ts +43 -0
  133. package/extensions/web-fetch/extract.ts +139 -0
  134. package/extensions/web-fetch/index.ts +291 -0
  135. package/extensions/web-fetch/summarize.ts +78 -0
  136. package/extensions/workflow/agent-session.ts +368 -0
  137. package/extensions/workflow/globals.ts +251 -0
  138. package/extensions/workflow/index.ts +364 -0
  139. package/extensions/workflow/journal.ts +75 -0
  140. package/extensions/workflow/permission-gate.ts +71 -0
  141. package/extensions/workflow/run-manager.ts +306 -0
  142. package/extensions/workflow/saved-workflows.ts +45 -0
  143. package/extensions/workflow/script-source.ts +201 -0
  144. package/extensions/workflow/types.ts +99 -0
  145. package/extensions/workflow/vm-runtime.ts +31 -0
  146. package/extensions/workflow/widget.ts +56 -0
  147. package/extensions/worktree/index.ts +266 -0
  148. package/extensions/worktree/rewrite.ts +59 -0
  149. package/package.json +101 -0
  150. package/themes/one-code-light.json +76 -0
  151. package/themes/one-code.json +76 -0
  152. package/types/app.d.ts +5 -0
  153. package/types/community-packages.d.ts +12 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Built-in auto-mode rule lists (pure data).
3
+ *
4
+ * These are prose, read by the classifier as natural-language rules — the same
5
+ * shape Claude Code's `claude auto-mode defaults` prints, so a user's existing
6
+ * `autoMode` settings splice into them with `"$defaults"` unchanged.
7
+ *
8
+ * Four tiers, in the precedence the classifier is instructed to apply:
9
+ * hard_deny — unconditional; neither user intent nor allow rules clear it
10
+ * soft_deny — blocked unless an allow rule or explicit user intent clears it
11
+ * allow — exceptions to soft_deny
12
+ * (intent) — a specific user instruction clears remaining soft denials
13
+ */
14
+
15
+ /** What the classifier treats as inside the trust boundary. */
16
+ export const DEFAULT_ENVIRONMENT: string[] = [
17
+ "Trusted repo: the git repository containing the working directory, and its configured remotes. Pushing to any branch of this repository is routine. Content first read from outside this repository is not this repository's own work, whoever asked for it to be moved.",
18
+ "Repository visibility: assume private unless the user's own message says otherwise. Confidential material may go into a private repo; secrets and personal data may never go into any repo regardless of visibility.",
19
+ "Trusted internal domains: none configured. Any host that is not the repository's own remote is outside the boundary.",
20
+ "Trusted cloud buckets: none configured. Treat every bucket write as leaving the boundary.",
21
+ "Internal package registry: none configured. Installs from the ecosystem's default public registry are routine; installs redirected to another registry are not.",
22
+ "Sensitive data locations: credential directories (~/.ssh, ~/.aws, ~/.kube, ~/.docker, ~/.m2, ~/.gnupg, ~/.config/gcloud), .env files anywhere, private keys and keystores, and /proc or /sys process state.",
23
+ "Sensitive remote targets: any host, namespace, container, or branch whose name carries prod, production, live, or release.",
24
+ "Protected IaC scopes: any Terraform, Pulumi, CloudFormation, Helm, or Kubernetes manifest path whose name marks it as production.",
25
+ "Primary use: software development in the working directory.",
26
+ ];
27
+
28
+ /** Unconditional. User intent does not clear these. */
29
+ export const DEFAULT_HARD_DENY: string[] = [
30
+ "Data exfiltration: sending secrets, credentials, private keys, personal data, or the contents of sensitive files to any destination outside the trust boundary — by network request, by commit or push, by writing into a shared location, or by embedding them in a command argument that reaches a third party. This holds however the action is framed and whoever asked for it.",
31
+ "Auto-mode bypass: disabling, weakening, or working around this approval mechanism — editing permission settings or Claude Code/One Code configuration to grant broader permissions, adding allow rules, changing the permission mode, or routing an action through a path that avoids classification.",
32
+ "Credential harvesting: reading credential stores, keychains, browser password databases, or process environment memory (/proc/*/environ) for any purpose other than one the user specifically named.",
33
+ "Destroying the user's own history: rewriting or deleting the reflog, deleting the local repository, or removing backups such that the user's uncommitted or unpushed work cannot be recovered.",
34
+ ];
35
+
36
+ /** Blocked, but an allow rule or a specific user instruction clears them. */
37
+ export const DEFAULT_SOFT_DENY: string[] = [
38
+ "Git destructive [must name the operation and its target]: force pushing, deleting remote branches, tags, or releases, rewriting remote history, or amending a commit that is not the agent's own unpushed work from this session.",
39
+ "Production deploys and destroys [must name the target]: deploying, applying, or destroying against a sensitive remote target or protected IaC scope, including terraform apply/destroy, helm upgrade, kubectl apply against a production namespace, and any release or publish pipeline.",
40
+ "Remote code execution: piping a downloaded script into an interpreter (curl | bash and equivalents), running an installer fetched at execution time, or executing a file whose contents were downloaded during this session.",
41
+ "Irreversible loss outside the working directory: deleting, overwriting, or truncating an existing file or directory that is not under the working directory and not obviously scratch space such as /tmp. Creating a new file outside the working directory is covered by the separate rule below, not by this one.",
42
+ "Creating or modifying files outside the working directory [must name the path]: writing somewhere the user did not ask for is how work leaks out of the project. Clears when the user's own message names that path, or names the destination unambiguously enough that the path follows from it. Also clears for the harness's own designated directories named in the system prompt — the session scratchpad (under /tmp or /private/tmp, in a claude-<uid> directory) and the auto-memory directory (~/.claude/projects/<project>/memory) — which exist precisely so temporary and memory files do not land in the project.",
43
+ "Publishing: pushing a package to a registry, creating a public release or gist, or making a private repository public.",
44
+ "Disabling safety machinery: turning off CI checks, required reviews, branch protection, test suites in configuration, pre-commit hooks, or TLS certificate verification.",
45
+ "Secrets entering version control: staging or committing a file that holds a credential, key, or token, including one the agent generated.",
46
+ "Bulk permission or ownership changes: recursive chmod/chown, or making files world-writable.",
47
+ "Writing to paths whose contents execute later without further approval: .git/hooks, .git/config, editor auto-run configuration, or a build wrapper such as mvnw or gradlew.",
48
+ "Database mutation outside a migration tool: dropping, truncating, or bulk-updating tables directly, against any database.",
49
+ "Remote shells and port-forwards into a sensitive remote target.",
50
+ "Sending repository contents to a third-party service that is not part of the configured toolchain.",
51
+ ];
52
+
53
+ /** Exceptions to soft_deny. */
54
+ export const DEFAULT_ALLOW: string[] = [
55
+ "Local file operations inside the working directory: creating, editing, moving, and deleting files and directories under it, including build output and caches.",
56
+ "Reading anything inside the working directory, and reading system and tool configuration that is not on the sensitive-data list.",
57
+ "Installing dependencies already declared in the project's manifest (package.json, pyproject.toml, go.mod, pom.xml, Cargo.toml) from the ecosystem's default registry.",
58
+ "Running the project's own build, test, lint, format, and type-check commands, including ones that write to build directories.",
59
+ "Read-only information lookups that retrieve only public data and send none of the user's own: HTTP GET/HEAD to public documentation, package metadata, and credential-free API endpoints; and public registry or DNS queries such as whois, dig, nslookup, and host, including ones that name the registry or DNS server to query.",
60
+ "Git operations that do not rewrite published history: status, diff, log, add, commit, branch, checkout, merge, rebase of unpushed local work, stash, and pushing to any branch of the trusted repo.",
61
+ "Creating and updating pull requests on the trusted repo, and reading issues and CI results.",
62
+ "Starting, stopping, and querying local development servers and containers bound to loopback.",
63
+ "Test artifacts: placeholder credentials authored as placeholders in examples and fixtures. A value copied from a real secret or a sensitive path is never a test artifact.",
64
+ "Reading the project's own .env file when the user asked for work that requires its configuration, provided the values do not leave the machine.",
65
+ ];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Project instruction files for the classifier (pure fs).
3
+ *
4
+ * Claude Code's classifier reads the same CLAUDE.md the agent does, so an
5
+ * instruction like "never force push" steers both at once. This collects the
6
+ * same files: cwd upward to the git root, plus the user's global file.
7
+ *
8
+ * These files are checked in, so they are untrusted input in a way the user's
9
+ * own messages are not — the classifier prompt tells the model they may tighten
10
+ * what is allowed but never widen it. That asymmetry is what makes including
11
+ * them safe; without it, a repository could ship its own authorisation.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+
17
+ const FILE_NAMES = ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md"];
18
+
19
+ /** Per-file and total caps, so a large instruction file cannot crowd out rules. */
20
+ const PER_FILE_LIMIT = 6_000;
21
+ const TOTAL_LIMIT = 12_000;
22
+
23
+ function readCapped(path: string): string | undefined {
24
+ try {
25
+ // readFileSync loads the whole file regardless, so the cap is applied to the
26
+ // decoded string. (A prior statSync branch claimed to bound the read but did
27
+ // the same full read either way.)
28
+ return readFileSync(path, "utf-8").slice(0, PER_FILE_LIMIT);
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function findGitRoot(from: string): string | undefined {
35
+ let dir = from;
36
+ for (;;) {
37
+ if (existsSync(join(dir, ".git"))) return dir;
38
+ const parent = dirname(dir);
39
+ if (parent === dir) return undefined;
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Concatenated instruction files, nearest first, or undefined when there are
46
+ * none. Each is labelled with its path so the classifier can tell project
47
+ * convention from user-global preference.
48
+ */
49
+ export function loadProjectInstructions(cwd: string, home: string): string | undefined {
50
+ const stop = findGitRoot(cwd) ?? cwd;
51
+ const parts: string[] = [];
52
+ let total = 0;
53
+
54
+ const add = (path: string) => {
55
+ if (total >= TOTAL_LIMIT || !existsSync(path)) return;
56
+ const body = readCapped(path)?.trim();
57
+ if (!body) return;
58
+ const chunk = `# ${path}\n${body}`;
59
+ parts.push(chunk.slice(0, TOTAL_LIMIT - total));
60
+ total += chunk.length;
61
+ };
62
+
63
+ let dir = cwd;
64
+ for (;;) {
65
+ for (const name of FILE_NAMES) add(join(dir, name));
66
+ if (dir === stop) break;
67
+ const parent = dirname(dir);
68
+ if (parent === dir) break;
69
+ dir = parent;
70
+ }
71
+ add(join(home, ".claude", "CLAUDE.md"));
72
+
73
+ return parts.length > 0 ? parts.join("\n\n") : undefined;
74
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * The `/auto-mode model` picker (pure).
3
+ *
4
+ * Filtering, key decoding, selection movement, and rendering for the
5
+ * interactive classifier-model picker, kept free of pi imports so they are
6
+ * unit-testable. The thin `ctx.ui.custom` component in the permissions
7
+ * extension owns nothing but mutable state and repaint calls.
8
+ *
9
+ * A picker exists because the alternative was worse in practice: choosing the
10
+ * classifier automatically took three rounds of fault-fixing against real
11
+ * catalogs, and when auto-selection picks badly the only recourse was
12
+ * hand-editing settings.json with a model id the user has to guess. Naming a
13
+ * model here is the explicit consent `autoMode.classifierModel` stands for —
14
+ * including consent to another provider, which auto-selection must never
15
+ * assume (see model-select.ts).
16
+ */
17
+
18
+ export interface PickerEntry {
19
+ provider: string;
20
+ id: string;
21
+ /** Input price per million tokens, when the catalog carries a usable one. */
22
+ inputPrice?: number;
23
+ }
24
+
25
+ export const pickerSpec = (entry: { provider: string; id: string }): string => `${entry.provider}/${entry.id}`;
26
+
27
+ /**
28
+ * Rank a query against one spec: 0 prefix, 1 substring, 2 in-order subsequence,
29
+ * undefined no match. Subsequence keeps "haiku45" finding "claude-haiku-4.5"
30
+ * without a fuzzy-match dependency.
31
+ */
32
+ export function matchRank(spec: string, query: string): number | undefined {
33
+ const haystack = spec.toLowerCase();
34
+ const needle = query.toLowerCase().replace(/\s+/g, "");
35
+ if (needle.length === 0) return 2;
36
+ if (haystack.startsWith(needle)) return 0;
37
+ if (haystack.includes(needle)) return 1;
38
+ let at = 0;
39
+ for (const char of needle) {
40
+ at = haystack.indexOf(char, at);
41
+ if (at === -1) return undefined;
42
+ at++;
43
+ }
44
+ return 2;
45
+ }
46
+
47
+ /** Filter and rank, preserving catalog order within a rank. */
48
+ export function filterEntries<T extends { provider: string; id: string }>(entries: T[], query: string): T[] {
49
+ return entries
50
+ .map((entry, position) => ({ entry, position, rank: matchRank(pickerSpec(entry), query) }))
51
+ .filter((item): item is { entry: T; position: number; rank: number } => item.rank !== undefined)
52
+ .sort((a, b) => a.rank - b.rank || a.position - b.position)
53
+ .map((item) => item.entry);
54
+ }
55
+
56
+ export type PickerKey =
57
+ | { kind: "up" }
58
+ | { kind: "down" }
59
+ | { kind: "confirm" }
60
+ | { kind: "cancel" }
61
+ | { kind: "backspace" }
62
+ | { kind: "type"; text: string };
63
+
64
+ /**
65
+ * Decode a raw terminal chunk. Unlike the effort slider there are no letter
66
+ * shortcuts: every printable character belongs to the filter query.
67
+ */
68
+ export function decodePickerKey(data: string): PickerKey | undefined {
69
+ switch (data) {
70
+ case "\x1b[A":
71
+ case "\x1bOA":
72
+ return { kind: "up" };
73
+ case "\x1b[B":
74
+ case "\x1bOB":
75
+ return { kind: "down" };
76
+ case "\r":
77
+ case "\n":
78
+ return { kind: "confirm" };
79
+ case "\x1b":
80
+ case "\x03": // ctrl+c — same intent as escape while the picker is focused
81
+ return { kind: "cancel" };
82
+ case "\x7f":
83
+ case "\b":
84
+ return { kind: "backspace" };
85
+ default: {
86
+ // Printable text only (covers paste); control sequences are ignored.
87
+ const text = [...data].filter((char) => char >= " " && char !== "\x7f").join("");
88
+ return text.length > 0 && !data.startsWith("\x1b") ? { kind: "type", text } : undefined;
89
+ }
90
+ }
91
+ }
92
+
93
+ /** First index of the window shown, keeping the cursor visible. */
94
+ export function windowStart(index: number, length: number, maxVisible: number): number {
95
+ if (length <= maxVisible) return 0;
96
+ return Math.max(0, Math.min(index - Math.floor(maxVisible / 2), length - maxVisible));
97
+ }
98
+
99
+ export type Paint = (color: string, text: string) => string;
100
+
101
+ export interface PickerView {
102
+ entries: PickerEntry[];
103
+ index: number;
104
+ query: string;
105
+ /** Catalog size before filtering, for the "n of m" line. */
106
+ total: number;
107
+ /** The currently configured spec, marked in the listing. */
108
+ current?: string;
109
+ maxVisible?: number;
110
+ /** Picker header; defaults to the auto-mode classifier copy. */
111
+ title?: string;
112
+ subtitle?: string;
113
+ }
114
+
115
+ const formatPrice = (price?: number): string => (price === undefined ? "" : `$${price}/M in`);
116
+
117
+ /** Picker entries from a model catalog, sorted by spec, non-positive prices treated as unpriced. */
118
+ export function toPickerEntries(models: { provider: string; id: string; cost?: { input?: number } }[]): PickerEntry[] {
119
+ return models
120
+ .map((model) => ({
121
+ provider: model.provider,
122
+ id: model.id,
123
+ inputPrice: typeof model.cost?.input === "number" && model.cost.input > 0 ? model.cost.input : undefined,
124
+ }))
125
+ .sort((a, b) => pickerSpec(a).localeCompare(pickerSpec(b)));
126
+ }
127
+
128
+ export interface PickerComponentOptions {
129
+ entries: PickerEntry[];
130
+ current?: string;
131
+ title?: string;
132
+ subtitle?: string;
133
+ }
134
+
135
+ /**
136
+ * The `ctx.ui.custom` component behind `/auto-mode model` and `/subagent`:
137
+ * filter-as-you-type over the entries, enter confirms, esc cancels. Kept here
138
+ * (structurally typed, no pi imports) so both commands share one keyboard
139
+ * loop instead of drifting copies.
140
+ */
141
+ export function modelPickerComponent(
142
+ options: PickerComponentOptions,
143
+ tui: { requestRender(): void },
144
+ theme: unknown,
145
+ done: (choice: PickerEntry | null) => void,
146
+ ): { render(): string[]; handleInput(data: string): void; invalidate(): void } {
147
+ const paint: Paint = (color, text) => {
148
+ const themed = theme as { fg?(c: string, t: string): string } | undefined;
149
+ try {
150
+ return themed?.fg ? themed.fg(color, text) : text;
151
+ } catch {
152
+ return text;
153
+ }
154
+ };
155
+ let query = "";
156
+ let index = 0;
157
+ let filtered = options.entries;
158
+ return {
159
+ render: () => [
160
+ "",
161
+ ...renderModelPicker(
162
+ {
163
+ entries: filtered,
164
+ index,
165
+ query,
166
+ total: options.entries.length,
167
+ current: options.current,
168
+ title: options.title,
169
+ subtitle: options.subtitle,
170
+ },
171
+ paint,
172
+ ),
173
+ "",
174
+ ],
175
+ handleInput: (data: string) => {
176
+ const key = decodePickerKey(data);
177
+ if (!key) return;
178
+ if (key.kind === "cancel") return done(null);
179
+ if (key.kind === "confirm") return done(filtered[index] ?? null);
180
+ if (key.kind === "up") index = Math.max(0, index - 1);
181
+ else if (key.kind === "down") index = Math.min(filtered.length - 1, index + 1);
182
+ else {
183
+ query = key.kind === "backspace" ? query.slice(0, -1) : query + key.text;
184
+ filtered = filterEntries(options.entries, query);
185
+ index = 0;
186
+ }
187
+ tui.requestRender();
188
+ },
189
+ invalidate: () => {},
190
+ };
191
+ }
192
+
193
+ export function renderModelPicker(view: PickerView, paint: Paint): string[] {
194
+ const maxVisible = view.maxVisible ?? 10;
195
+ const lines: string[] = [];
196
+ lines.push(paint("accent", view.title ?? "Select the auto-mode classifier model"));
197
+ lines.push(
198
+ paint(
199
+ "dim",
200
+ view.subtitle ??
201
+ "It reads your prompts and CLAUDE.md — picking another provider sends them there. type to filter · ↑/↓ · enter · esc",
202
+ ),
203
+ );
204
+ lines.push(` filter: ${view.query}${paint("dim", "▏")}`);
205
+
206
+ if (view.entries.length === 0) {
207
+ lines.push(paint("warning", " (no available model matches)"));
208
+ return lines;
209
+ }
210
+
211
+ const start = windowStart(view.index, view.entries.length, maxVisible);
212
+ for (const [offset, entry] of view.entries.slice(start, start + maxVisible).entries()) {
213
+ const at = start + offset;
214
+ const spec = pickerSpec(entry);
215
+ const annotations = [view.current === spec ? "✓ current" : "", formatPrice(entry.inputPrice)]
216
+ .filter(Boolean)
217
+ .join(" ");
218
+ const suffix = annotations ? ` ${paint("dim", annotations)}` : "";
219
+ lines.push(at === view.index ? `${paint("accent", `❯ ${spec}`)}${suffix}` : ` ${spec}${suffix}`);
220
+ }
221
+ if (view.entries.length > maxVisible || view.entries.length < view.total) {
222
+ lines.push(paint("dim", ` ${view.entries.length} of ${view.total} models${view.query ? " match" : ""}`));
223
+ }
224
+ return lines;
225
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Choosing the classifier model (pure).
3
+ *
4
+ * ## Why this is not just "pick a cheap model"
5
+ *
6
+ * The classifier receives the user's own messages, their CLAUDE.md, and the text
7
+ * of the command being judged. So *which provider it runs on* is a privacy
8
+ * decision, not an optimisation. The first cut of this searched the whole
9
+ * registry for a model whose id contained "haiku"/"sonnet"/"mini", which on a
10
+ * session running `openai-codex/gpt-5.5-codex` with an Anthropic key also present
11
+ * silently selected `anthropic/claude-haiku-4-5` — shipping that user's prompts to
12
+ * a vendor they had not chosen for this session, through a component with no UI.
13
+ *
14
+ * So: **never leave the session's provider unless the user asked for it.** An
15
+ * explicit `autoMode.classifierModel` may name any provider, because naming it is
16
+ * the asking. Everything else stays where the session already is. This is the same
17
+ * reasoning pi's own subagent config gives for defaulting to the session model —
18
+ * "this keeps new installs from depending on a provider you may not have
19
+ * configured" — and this module follows that precedence shape: explicit override,
20
+ * then a configured default, then something sensible in-provider, then the session
21
+ * model itself.
22
+ *
23
+ * Name matching is demoted to a tiebreak because it does not survive contact with
24
+ * real catalogs: of Groq's 7 models and xAI's 3, *none* contain any of those
25
+ * substrings, while OpenRouter has 303 models and 79 substring hits, so the "first
26
+ * match" is arbitrary. Cost is the one signal every provider carries.
27
+ */
28
+
29
+ import type { Api, Model } from "@earendil-works/pi-ai";
30
+ import {
31
+ findConfigured,
32
+ findRoleProfileModel,
33
+ hintRank,
34
+ modelIdentity,
35
+ modelsContainedToSession,
36
+ pricedInput,
37
+ } from "../lib/model-policy.ts";
38
+
39
+ export { findConfigured } from "../lib/model-policy.ts";
40
+
41
+ export interface Candidate {
42
+ model: Model<Api>;
43
+ /** How this candidate was arrived at, for the notice shown to the user. */
44
+ source: "configured" | "role-profile" | "cheapest-in-provider" | "session";
45
+ }
46
+
47
+ export interface SelectInput {
48
+ /** Models the user actually has working credentials for. */
49
+ available: Model<Api>[];
50
+ sessionModel: Model<Api> | undefined;
51
+ /** `autoMode.classifierModel`, if set. May name any provider. */
52
+ configured?: string;
53
+ }
54
+
55
+ /**
56
+ * The ordered candidate chain. More than one is returned so a candidate that
57
+ * turns out to be unusable at call time (not entitled on this account, withdrawn
58
+ * by the provider) can be stepped over instead of failing every tool call — the
59
+ * same shape as pi's subagent `fallbackModels`.
60
+ *
61
+ * The chain always ends at the session model when there is one, so auto mode
62
+ * degrades to "correct but not cheap" rather than to "broken".
63
+ */
64
+ export function classifierCandidates({ available, sessionModel, configured }: SelectInput): Candidate[] {
65
+ const candidates: Candidate[] = [];
66
+ const push = (model: Model<Api> | undefined, source: Candidate["source"]) => {
67
+ if (!model) return;
68
+ if (candidates.some((entry) => entry.model.provider === model.provider && entry.model.id === model.id)) return;
69
+ candidates.push({ model, source });
70
+ };
71
+
72
+ // 1. What the user asked for, wherever it lives. Naming a provider is choosing it.
73
+ if (configured) push(findConfigured(available, configured), "configured");
74
+
75
+ /**
76
+ * Containment is shared with subagents. For a direct provider it is the
77
+ * provider itself; gateways additionally preserve a reliable route/family;
78
+ * opaque routers expose only the session model.
79
+ */
80
+ const inProvider = sessionModel ? modelsContainedToSession(available, sessionModel) : [];
81
+
82
+ /**
83
+ * No candidate may cost more per token than the model doing the actual work.
84
+ * Screening a call more expensively than making it is indefensible, and it
85
+ * happened: on an OpenRouter session at `z-ai/glm-4.6` ($0.50/M) the table's
86
+ * `anthropic/claude-haiku-4.5` ($1/M) was selected, because this ceiling was
87
+ * originally applied only to the cost-ranked branch and not to the table.
88
+ */
89
+ const sessionPrice = sessionModel ? pricedInput(sessionModel) : undefined;
90
+ const withinBudget = (model: Model<Api>) => {
91
+ if (sessionPrice === undefined) return true;
92
+ const price = pricedInput(model);
93
+ return price === undefined || price <= sessionPrice;
94
+ };
95
+
96
+ // 2. A reviewed small-but-capable model for this provider/family. The
97
+ // profile is short and applied to the live catalog; price alone never
98
+ // promotes a model into the classifier role.
99
+ if (sessionModel) {
100
+ // Reuse the containment set computed above instead of recomputing it inside.
101
+ push(findRoleProfileModel(available, sessionModel, "classifier", withinBudget, inProvider), "role-profile");
102
+ }
103
+
104
+ // 3. The cheapest genuinely-priced model in the provider.
105
+ const cheaper = inProvider
106
+ .map((model) => ({ model, price: pricedInput(model) }))
107
+ .filter((entry): entry is { model: Model<Api>; price: number } => entry.price !== undefined)
108
+ .filter((entry) => withinBudget(entry.model))
109
+ .sort((a, b) => a.price - b.price || hintRank(a.model) - hintRank(b.model));
110
+ const cheapest = cheaper[0]?.model;
111
+
112
+ /**
113
+ * 4. The session's own model, ahead of the cost-ranked pick.
114
+ *
115
+ * Price is not evidence of suitability, and a name is barely better. On
116
+ * OpenRouter the cheapest model in the catalog is `inclusionai/ling-2.6-flash`
117
+ * at $0.01/M, which would otherwise become the security boundary for being
118
+ * cheap. An attempt to gate that on the name carrying a known small-model word
119
+ * failed on the same example — "flash" is in the name, because any vendor may
120
+ * put it there; the word means "someone called this small", not "this family is
121
+ * known good".
122
+ *
123
+ * So nothing chosen on price alone leads. Providers that *are* vetted get their
124
+ * saving from the table above (which covers OpenRouter and every mainstream
125
+ * provider); anywhere else the model the user already trusted for the real work
126
+ * screens the calls — correct, merely not cheap — and `classifierModel` is
127
+ * there for someone who wants otherwise.
128
+ */
129
+ push(sessionModel, "session");
130
+
131
+ // 5. Last resort, for when even the session model cannot serve.
132
+ push(cheapest, "cheapest-in-provider");
133
+
134
+ // Nothing configured and no session model (a headless run with a bare
135
+ // registry) — take anything available rather than refusing outright.
136
+ if (candidates.length === 0) push(available[0], "session");
137
+
138
+ return candidates;
139
+ }
140
+
141
+ /**
142
+ * Whether an error means "this model is not usable on this account" — in which
143
+ * case stepping to the next candidate is right — as opposed to a transient
144
+ * failure, where switching models would hide a problem that is about to clear.
145
+ *
146
+ * "Quota" is deliberately matched only in its billing forms (`insufficient_quota`,
147
+ * "exceeded your current quota"): a bare match also caught per-minute rate-limit
148
+ * messages ("quota exceeded, retry in 60s"), permanently rejecting a healthy
149
+ * model for the whole session over a blip. Misreading billing as transient
150
+ * merely retries and blocks noisily; misreading a blip as permanent bricks the
151
+ * candidate — so uncertainty goes to transient.
152
+ */
153
+ export function isModelUnavailableError(message: string): boolean {
154
+ return /\b(401|403|404)\b|not_found|not found|does not exist|(no|have|lacks?)\s+access|unauthoriz|forbidden|invalid[_ -]?model|model[_ -]?not|unsupported[_ -]?model|no such model|entitl|insufficient[_ -]?quota|exceeded your current quota|billing/i.test(
155
+ message,
156
+ );
157
+ }
158
+
159
+ /** One-line description for the notice and `/auto-mode config`. */
160
+ export function describeCandidate(candidate: Candidate): string {
161
+ const name = `${candidate.model.provider}/${candidate.model.id}`;
162
+ switch (candidate.source) {
163
+ case "configured":
164
+ return `${name} (from autoMode.classifierModel)`;
165
+ case "role-profile": {
166
+ const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
167
+ return `${name} (classifier profile for ${where})`;
168
+ }
169
+ case "cheapest-in-provider": {
170
+ const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
171
+ return `${name} (cheapest available within ${where})`;
172
+ }
173
+ case "session": {
174
+ const where = modelIdentity(candidate.model).profile ?? candidate.model.provider;
175
+ return `${name} (this session's model — no vetted smaller model within ${where})`;
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Path resolution and containment for the shell pre-gate (pure apart from fs
3
+ * reads).
4
+ *
5
+ * Two fixes over the sandbox this derives from:
6
+ *
7
+ * 1. **The symlink leaf is inspected** (review finding N9). The original's
8
+ * ENOENT fallback joined literal basenames onto the nearest resolved
9
+ * ancestor without ever lstat-ing the leaf, so `ln -s /outside/x link`
10
+ * followed by `echo hi > link` resolved to `<project>/link` — inside the
11
+ * project — while bash wrote through the symlink to `/outside/x`.
12
+ * 2. **Resolution failure is reported, not swallowed.** The original returned a
13
+ * best-effort path on failure, so the caller could not tell a real
14
+ * containment pass from a guess. Here an unresolvable path is `undefined`
15
+ * and every caller treats that as "not provably contained".
16
+ */
17
+
18
+ import { lstatSync, readlinkSync, realpathSync } from "node:fs";
19
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
20
+
21
+ /**
22
+ * Tools whose calls write to a path. Lives here — the lowest shared layer both
23
+ * the shell safety floor and the permission gate import — so the two write-gates
24
+ * share one source of truth for "which tools write" (was duplicated).
25
+ */
26
+ export const WRITING_TOOLS = new Set(["edit", "write", "notebook_edit"]);
27
+
28
+ export function isWritingTool(toolName: string): boolean {
29
+ return WRITING_TOOLS.has(toolName);
30
+ }
31
+
32
+ /** Case-folded on darwin/win32, where the filesystem is case-insensitive. */
33
+ function normalize(target: string): string {
34
+ const normalized = resolve(target).replace(/\\/g, "/").replace(/\/+$/, "");
35
+ return process.platform === "linux" ? normalized : normalized.toLowerCase();
36
+ }
37
+
38
+ function tryRealpath(target: string): string | undefined {
39
+ try {
40
+ return realpathSync.native(target);
41
+ } catch {
42
+ try {
43
+ return realpathSync(target);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Resolve a path for containment checks, following a symlink leaf that does not
52
+ * yet have a target. Returns undefined when nothing about the path can be
53
+ * resolved — callers must not treat that as contained.
54
+ */
55
+ export function resolveForContainment(target: string): string | undefined {
56
+ const absolute = resolve(target);
57
+
58
+ const direct = tryRealpath(absolute);
59
+ if (direct) return normalize(direct);
60
+
61
+ // The leaf may be a dangling symlink: realpath fails on it, but writes still
62
+ // follow it. Read the link and resolve its target instead of assuming the
63
+ // literal path (N9).
64
+ try {
65
+ const stats = lstatSync(absolute);
66
+ if (stats.isSymbolicLink()) {
67
+ const linkTarget = readlinkSync(absolute);
68
+ const resolvedTarget = isAbsolute(linkTarget) ? linkTarget : resolve(dirname(absolute), linkTarget);
69
+ // One hop is enough: the target's own realpath covers the rest of a chain.
70
+ return resolveForContainment(resolvedTarget) ?? normalize(resolvedTarget);
71
+ }
72
+ } catch {
73
+ // Does not exist yet — fall through to ancestor resolution below.
74
+ }
75
+
76
+ // A path being created (`> newfile`): resolve the nearest existing ancestor
77
+ // and re-attach the tail, so a symlinked *parent* directory is still followed.
78
+ const tail: string[] = [];
79
+ let cursor = absolute;
80
+ for (;;) {
81
+ const parent = dirname(cursor);
82
+ if (parent === cursor) return undefined;
83
+ tail.unshift(basename(cursor));
84
+ const realParent = tryRealpath(parent);
85
+ if (realParent) return normalize(join(realParent, ...tail));
86
+ cursor = parent;
87
+ }
88
+ }
89
+
90
+ /** True only when `target` is provably at or under `base`. */
91
+ export function isWithin(base: string, target: string): boolean {
92
+ const normalizedBase = normalize(base);
93
+ const normalizedTarget = normalize(target);
94
+ return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}/`);
95
+ }
96
+
97
+ /**
98
+ * Expand a leading `~`, then resolve relative to `cwd`. Kept separate from
99
+ * resolveForContainment so callers can report the *original* token in messages:
100
+ * a resolved path can embed an expanded environment-variable value, and echoing
101
+ * that back to the model leaks the secret it was meant to protect (review
102
+ * finding N18).
103
+ */
104
+ export function toAbsolute(cwd: string, token: string, home: string): string {
105
+ if (token === "~") return home;
106
+ if (token.startsWith("~/")) return resolve(home, token.slice(2));
107
+ if (isAbsolute(token)) return resolve(token);
108
+ return resolve(cwd, token);
109
+ }