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,98 @@
1
+ /**
2
+ * Claude Code settings.json permissions loading/merging/persistence (pure fs).
3
+ *
4
+ * Sources, lowest to highest precedence:
5
+ * ~/.claude/settings.json (user)
6
+ * <cwd>/.claude/settings.json (project, checked in)
7
+ * <cwd>/.claude/settings.local.json (project, personal)
8
+ *
9
+ * allow/deny/ask arrays concatenate across sources; defaultMode from the most
10
+ * specific source wins. Unknown keys in the files are preserved on write.
11
+ */
12
+
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import type { PermissionMode } from "./matcher.ts";
16
+
17
+ export interface PermissionSettings {
18
+ allow: string[];
19
+ deny: string[];
20
+ ask: string[];
21
+ defaultMode?: PermissionMode;
22
+ }
23
+
24
+ interface ClaudeSettingsFile {
25
+ permissions?: {
26
+ allow?: string[];
27
+ deny?: string[];
28
+ ask?: string[];
29
+ defaultMode?: string;
30
+ };
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ const MODES: PermissionMode[] = ["default", "acceptEdits", "plan", "bypassPermissions", "dontAsk", "auto"];
35
+
36
+ /** Display-name aliases Claude Code accepts wherever a mode is named. */
37
+ const MODE_ALIASES: Record<string, PermissionMode> = { manual: "default" };
38
+
39
+ export function isPermissionMode(value: unknown): value is PermissionMode {
40
+ return typeof value === "string" && (MODES as string[]).includes(value);
41
+ }
42
+
43
+ /** A mode value from user input (flag, settings, env), aliases included. */
44
+ export function normalizePermissionMode(value: unknown): PermissionMode | undefined {
45
+ if (isPermissionMode(value)) return value;
46
+ return typeof value === "string" ? MODE_ALIASES[value] : undefined;
47
+ }
48
+
49
+ function readSettingsFile(path: string): ClaudeSettingsFile | undefined {
50
+ if (!existsSync(path)) return undefined;
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf-8")) as ClaudeSettingsFile;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ export function settingsPaths(cwd: string, home: string): { user: string; project: string; local: string } {
59
+ return {
60
+ user: join(home, ".claude", "settings.json"),
61
+ project: join(cwd, ".claude", "settings.json"),
62
+ local: join(cwd, ".claude", "settings.local.json"),
63
+ };
64
+ }
65
+
66
+ export function loadPermissionSettings(cwd: string, home: string): PermissionSettings {
67
+ const paths = settingsPaths(cwd, home);
68
+ const merged: PermissionSettings = { allow: [], deny: [], ask: [] };
69
+
70
+ for (const path of [paths.user, paths.project, paths.local]) {
71
+ const file = readSettingsFile(path);
72
+ const perms = file?.permissions;
73
+ if (!perms) continue;
74
+ if (Array.isArray(perms.allow)) merged.allow.push(...perms.allow.filter((r) => typeof r === "string"));
75
+ if (Array.isArray(perms.deny)) merged.deny.push(...perms.deny.filter((r) => typeof r === "string"));
76
+ if (Array.isArray(perms.ask)) merged.ask.push(...perms.ask.filter((r) => typeof r === "string"));
77
+ const defaultMode = normalizePermissionMode(perms.defaultMode);
78
+ // `auto` is honoured from user settings only. Both project files live in the
79
+ // repository, so accepting it there would let a checked-in file put the
80
+ // session into auto mode — a repo granting itself the looser mode whose
81
+ // classifier is what contains it. Claude Code makes the same exclusion.
82
+ if (defaultMode && !(defaultMode === "auto" && path !== paths.user)) {
83
+ merged.defaultMode = defaultMode;
84
+ }
85
+ }
86
+
87
+ return merged;
88
+ }
89
+
90
+ /** Append an allow rule to a settings file, creating it if needed. */
91
+ export function persistAllowRule(rule: string, filePath: string): void {
92
+ const file = readSettingsFile(filePath) ?? {};
93
+ const permissions = (file.permissions ??= {});
94
+ const allow = (permissions.allow ??= []);
95
+ if (!allow.includes(rule)) allow.push(rule);
96
+ mkdirSync(dirname(filePath), { recursive: true });
97
+ writeFileSync(filePath, `${JSON.stringify(file, null, 2)}\n`);
98
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * plan-mode extension — Claude Code's EnterPlanMode / ExitPlanMode tools,
3
+ * file-based: the plan lives at ~/.one-code/plans/<slug>.md, the one path plan
4
+ * mode may write. The path is announced to the model in an every-turn reminder
5
+ * and to the permissions extension over the event bus, and exit_plan_mode
6
+ * reads the file rather than taking the plan as a parameter.
7
+ *
8
+ * Mode state is owned by the permissions extension; this extension requests
9
+ * changes over MODE_CHANNEL and reacts to transitions it observes on
10
+ * PERMISSION_STATUS_CHANNEL (which fires on every setMode). Plan-file
11
+ * allocation happens lazily in before_agent_start — the one hook that covers
12
+ * all three ways into plan mode (the tool, ctrl+q, defaultMode: "plan") and
13
+ * runs after every extension's session_start, so restoring a previous path
14
+ * from the session branch can never race a fresh allocation.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+ import { Type } from "typebox";
21
+ import { oneCodeStateDir } from "../lib/paths.ts";
22
+ import { REMINDER_CHANNEL } from "../lib/reminders.ts";
23
+ import { ccToolRenderers, safeThemePaint } from "../lib/tui-render.ts";
24
+ import { PERMISSION_STATUS_CHANNEL, type PermissionStatus } from "../permissions/modes.ts";
25
+ import { buildPlanModeReminder } from "./reminder.ts";
26
+ import { randomSlug } from "./slug.ts";
27
+ import { clampOffset, decodeViewerKey, type PlanChoice, renderPlanViewer, wrapPlanText } from "./viewer.ts";
28
+
29
+ export const MODE_CHANNEL = "one-code:set-permission-mode";
30
+ /** Announces plan mode's one writable file; the permissions matcher consumes it. */
31
+ export const PLAN_FILE_CHANNEL = "one-code:plan-file-path";
32
+ /** Session entry type persisting the allocated path across resume/branch. */
33
+ const PLAN_FILE_ENTRY = "plan-mode-file";
34
+
35
+ const PLANS_DIR = () => join(oneCodeStateDir(), "plans");
36
+
37
+ export default function planModeExtension(pi: ExtensionAPI) {
38
+ let currentMode = "default";
39
+ let planFilePath: string | undefined;
40
+
41
+ /** Restore the branch's plan file, else allocate a fresh slug. */
42
+ const ensurePlanFile = (ctx: ExtensionContext): string => {
43
+ if (planFilePath) return planFilePath;
44
+
45
+ for (const entry of ctx.sessionManager.getBranch()) {
46
+ if (entry.type !== "custom" || entry.customType !== PLAN_FILE_ENTRY) continue;
47
+ const path = (entry.data as { path?: unknown } | undefined)?.path;
48
+ if (typeof path === "string") planFilePath = path;
49
+ }
50
+ if (planFilePath) return planFilePath;
51
+
52
+ let path = join(PLANS_DIR(), `${randomSlug()}.md`);
53
+ for (let attempt = 0; attempt < 5 && existsSync(path); attempt++) {
54
+ path = join(PLANS_DIR(), `${randomSlug()}.md`);
55
+ }
56
+ planFilePath = path;
57
+ pi.appendEntry(PLAN_FILE_ENTRY, { path });
58
+ return path;
59
+ };
60
+
61
+ /** Re-announce path + reminder; every-turn re-emits replace by key. */
62
+ const refresh = (ctx: ExtensionContext) => {
63
+ const path = ensurePlanFile(ctx);
64
+ pi.events.emit(PLAN_FILE_CHANNEL, { path });
65
+ pi.events.emit(REMINDER_CHANNEL, {
66
+ text: buildPlanModeReminder({ filePath: path, fileExists: existsSync(path) }),
67
+ scope: "every-turn",
68
+ key: "permission-mode",
69
+ });
70
+ };
71
+
72
+ pi.events.on(PERMISSION_STATUS_CHANNEL, (data) => {
73
+ const status = data as PermissionStatus;
74
+ if (typeof status?.mode === "string") currentMode = status.mode;
75
+ });
76
+
77
+ pi.on("before_agent_start", (_event, ctx) => {
78
+ if (currentMode === "plan") refresh(ctx);
79
+ });
80
+
81
+ pi.registerTool({
82
+ name: "enter_plan_mode",
83
+ label: "Enter plan mode",
84
+ ...ccToolRenderers("Enter plan mode"),
85
+ description:
86
+ "Enter plan mode for tasks that need investigation and design before changing anything. 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. Use for non-trivial multi-file work; skip it for simple direct changes.",
87
+ promptSnippet: "Switch to read-only planning before non-trivial changes",
88
+ parameters: Type.Object({}),
89
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
90
+ pi.events.emit(MODE_CHANNEL, { mode: "plan" });
91
+ // setMode fires synchronously over the status channel, so currentMode
92
+ // is already "plan"; announce the file in the tool result too so the
93
+ // model can start writing this same turn.
94
+ const path = ensurePlanFile(ctx);
95
+ refresh(ctx);
96
+ return {
97
+ content: [
98
+ {
99
+ type: "text",
100
+ text: `Entered plan mode. Only read-only tools are available, except for your plan file at ${path} — build your plan there incrementally, then call exit_plan_mode to request approval.`,
101
+ },
102
+ ],
103
+ details: { planFilePath: path },
104
+ };
105
+ },
106
+ });
107
+
108
+ pi.registerTool({
109
+ name: "exit_plan_mode",
110
+ label: "Exit plan mode",
111
+ ...ccToolRenderers("Exit plan mode"),
112
+ description:
113
+ "Signal that planning is complete and ask the user to approve the plan. 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.",
114
+ promptSnippet: "Present your plan file for user approval",
115
+ parameters: Type.Object({}),
116
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
117
+ // Guard the out-of-sequence call: without this, a cold exit_plan_mode
118
+ // (never entered plan mode) allocates a fresh empty plan file and blames
119
+ // "the plan file is empty" — a plausible but wrong cause a weak model
120
+ // then chases. The real fix is to enter plan mode first.
121
+ if (currentMode !== "plan") {
122
+ return {
123
+ content: [
124
+ {
125
+ type: "text",
126
+ text: "Not in plan mode, so there is no plan to approve. Call enter_plan_mode first, build the plan in the file it names, then call exit_plan_mode.",
127
+ },
128
+ ],
129
+ details: {},
130
+ isError: true,
131
+ };
132
+ }
133
+ const path = ensurePlanFile(ctx);
134
+ const plan = existsSync(path) ? readFileSync(path, "utf8") : "";
135
+ if (!plan.trim()) {
136
+ return {
137
+ content: [
138
+ {
139
+ type: "text",
140
+ text: `No plan to approve: ${path} is missing or empty. Write your plan there first, then call exit_plan_mode again.`,
141
+ },
142
+ ],
143
+ details: { planFilePath: path },
144
+ isError: true,
145
+ };
146
+ }
147
+
148
+ if (!ctx.hasUI) {
149
+ pi.events.emit(MODE_CHANNEL, { mode: "default" });
150
+ return {
151
+ content: [
152
+ { type: "text", text: "Non-interactive session: plan recorded and plan mode exited. Proceed." },
153
+ ],
154
+ details: { plan, approved: true },
155
+ };
156
+ }
157
+
158
+ const choice = await ctx.ui.custom<PlanChoice | null>((tui, theme, _keybindings, done) => {
159
+ const paint = safeThemePaint(theme);
160
+ const maxVisible = 12;
161
+ let offset = 0;
162
+ let selected: PlanChoice = 0;
163
+ let lineCount = 0;
164
+ return {
165
+ render: (width: number) => {
166
+ const lines = wrapPlanText(plan, Math.max(10, width - 1));
167
+ lineCount = lines.length;
168
+ offset = clampOffset(offset, lineCount, maxVisible);
169
+ return renderPlanViewer({ lines, offset, choice: selected, maxVisible }, paint, width);
170
+ },
171
+ handleInput: (data: string) => {
172
+ const key = decodeViewerKey(data, maxVisible);
173
+ if (!key) return;
174
+ if (key.kind === "cancel") return done(null);
175
+ if (key.kind === "confirm") return done(selected);
176
+ if (key.kind === "pick") return done(key.index);
177
+ if (key.kind === "scroll") offset = clampOffset(offset + key.delta, lineCount, maxVisible);
178
+ else if (key.kind === "choice") selected = (((selected + key.delta) % 3) + 3) % 3 as PlanChoice;
179
+ tui.requestRender();
180
+ },
181
+ invalidate: () => {},
182
+ };
183
+ });
184
+
185
+ if (choice === 0 || choice === 1) {
186
+ pi.events.emit(MODE_CHANNEL, { mode: choice === 1 ? "acceptEdits" : "default" });
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: `Plan approved by the user. You may now implement it. The approved plan stays at ${path} for reference.`,
192
+ },
193
+ ],
194
+ details: { plan, approved: true },
195
+ };
196
+ }
197
+
198
+ return {
199
+ content: [
200
+ {
201
+ type: "text",
202
+ text: "The user did not approve the plan. Stay in plan mode; refine the plan file based on their feedback.",
203
+ },
204
+ ],
205
+ details: { plan, approved: false },
206
+ };
207
+ },
208
+ });
209
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Plan-mode reminder text (pure). Byte-identical for a given state so the
3
+ * every-turn re-emit replaces the queue entry with the same string except when
4
+ * the state genuinely changed (file created, different path).
5
+ *
6
+ * Mirrors Claude Code's plan-mode system message: the plan lives in a file the
7
+ * model builds incrementally — the one writable path in plan mode — and
8
+ * exit_plan_mode reads that file rather than taking the plan as a parameter.
9
+ */
10
+
11
+ export interface PlanReminderState {
12
+ filePath: string;
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
+
21
+ return [
22
+ `Plan mode is active. You may only use read-only tools; edit and write are blocked everywhere except one file. ${fileLine}`,
23
+ "",
24
+ "Workflow:",
25
+ '1. Explore: delegate broad reconnaissance to `subagent` with `agent: "explore"` (up to 3 in parallel) rather than reading everything yourself.',
26
+ '2. Design: delegate the implementation strategy to `subagent` with `agent: "plan"` once exploration has mapped the ground.',
27
+ "3. Review the critical files yourself, and call `ask_user_question` for any decision you cannot make on the user's behalf.",
28
+ `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.",
30
+ "",
31
+ "End every plan-mode turn with either `ask_user_question` or `exit_plan_mode`. Never ask for plan approval in chat text.",
32
+ ].join("\n");
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Three-word plan-file slugs (pure), Claude Code style: `now-snoopy-tome`.
3
+ * Collision handling lives in index.ts — it needs the filesystem, this doesn't.
4
+ */
5
+
6
+ const WORDS = [
7
+ "amber", "arrow", "aspen", "atlas", "badge", "basil", "beacon", "birch",
8
+ "bloom", "breeze", "brisk", "cedar", "chalk", "cliff", "clover", "coral",
9
+ "crane", "crisp", "delta", "drift", "dune", "ember", "fable", "fern",
10
+ "flint", "gale", "glade", "grove", "harbor", "hazel", "heron", "ivory",
11
+ "jade", "juniper", "keel", "kite", "lark", "ledge", "linen", "lunar",
12
+ "maple", "marsh", "meadow", "mesa", "mist", "moss", "north", "oak",
13
+ "olive", "onyx", "opal", "orchard", "otter", "pebble", "pine", "plume",
14
+ "prairie", "quill", "reef", "ridge", "river", "robin", "rowan", "sable",
15
+ "sage", "shale", "shore", "slate", "snug", "spark", "spruce", "starling",
16
+ "stone", "summit", "swift", "thorn", "tide", "timber", "tome", "trail",
17
+ "tundra", "umber", "vale", "vesper", "violet", "wander", "willow", "wren",
18
+ ];
19
+
20
+ /** Three distinct words joined by `-`. Inject `rng` for deterministic tests. */
21
+ export function randomSlug(rng: () => number = Math.random): string {
22
+ const picked: string[] = [];
23
+ while (picked.length < 3) {
24
+ const word = WORDS[Math.floor(rng() * WORDS.length) % WORDS.length];
25
+ if (!picked.includes(word)) picked.push(word);
26
+ }
27
+ return picked.join("-");
28
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Plan-approval viewer (pure): key decoding, wrapping, and rendering for the
3
+ * scrollable dialog exit_plan_mode shows. The thin ctx.ui.custom component in
4
+ * index.ts owns nothing but mutable state and repaint calls — same split as
5
+ * auto-mode's model picker.
6
+ *
7
+ * Every rendered line must stay within the width given: pi-tui crashes the
8
+ * whole app on an overwide line. Content is wrapped (not truncated — a plan
9
+ * must stay readable in full), and chrome lines are cut plain-text *before*
10
+ * painting so ANSI escapes never confuse the width accounting.
11
+ */
12
+
13
+ export const PLAN_CHOICES = ["Approve — manual approvals", "Approve — auto-accept edits", "Keep planning"] as const;
14
+
15
+ /** What the dialog resolves to; index into PLAN_CHOICES, or null for cancel. */
16
+ export type PlanChoice = 0 | 1 | 2;
17
+
18
+ export type ViewerKey =
19
+ | { kind: "scroll"; delta: number }
20
+ | { kind: "choice"; delta: number }
21
+ | { kind: "pick"; index: PlanChoice }
22
+ | { kind: "confirm" }
23
+ | { kind: "cancel" };
24
+
25
+ /** Decode a raw terminal chunk; `page` is the scroll step for PgUp/PgDn. */
26
+ export function decodeViewerKey(data: string, page: number): ViewerKey | undefined {
27
+ switch (data) {
28
+ case "\x1b[A":
29
+ case "\x1bOA":
30
+ return { kind: "scroll", delta: -1 };
31
+ case "\x1b[B":
32
+ case "\x1bOB":
33
+ return { kind: "scroll", delta: 1 };
34
+ case "\x1b[5~":
35
+ return { kind: "scroll", delta: -page };
36
+ case "\x1b[6~":
37
+ return { kind: "scroll", delta: page };
38
+ case "\x1b[D":
39
+ case "\x1bOD":
40
+ return { kind: "choice", delta: -1 };
41
+ case "\x1b[C":
42
+ case "\x1bOC":
43
+ case "\t":
44
+ return { kind: "choice", delta: 1 };
45
+ case "\r":
46
+ case "\n":
47
+ return { kind: "confirm" };
48
+ case "\x1b":
49
+ case "\x03": // ctrl+c — same intent as escape while the dialog is focused
50
+ return { kind: "cancel" };
51
+ case "1":
52
+ case "2":
53
+ case "3":
54
+ return { kind: "pick", index: (Number(data) - 1) as PlanChoice };
55
+ default:
56
+ return undefined;
57
+ }
58
+ }
59
+
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
+ export function clampOffset(offset: number, total: number, visible: number): number {
78
+ return Math.max(0, Math.min(offset, Math.max(0, total - visible)));
79
+ }
80
+
81
+ export type Paint = (color: string, text: string) => string;
82
+
83
+ export interface PlanViewerView {
84
+ /** Pre-wrapped plain-text plan lines (wrapPlanText output for this width). */
85
+ lines: string[];
86
+ offset: number;
87
+ choice: PlanChoice;
88
+ maxVisible?: number;
89
+ }
90
+
91
+ export function renderPlanViewer(view: PlanViewerView, paint: Paint, width: number): string[] {
92
+ const maxVisible = view.maxVisible ?? 12;
93
+ // 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
+ };
98
+ const rule = paint("dim", "─".repeat(Math.max(0, Math.min(width, 72))));
99
+
100
+ const out: string[] = [];
101
+ out.push("");
102
+ out.push(paint("accent", cut("Approve this plan?")));
103
+ out.push(paint("dim", cut("↑/↓ scroll · ←/→ switch choice · 1-3 pick · enter confirm · esc keep planning")));
104
+ out.push(rule);
105
+
106
+ const offset = clampOffset(view.offset, view.lines.length, maxVisible);
107
+ for (const line of view.lines.slice(offset, offset + maxVisible)) {
108
+ out.push(cut(line));
109
+ }
110
+ if (view.lines.length > maxVisible) {
111
+ const last = Math.min(view.lines.length, offset + maxVisible);
112
+ out.push(paint("dim", cut(`lines ${offset + 1}–${last} of ${view.lines.length}`)));
113
+ }
114
+ out.push(rule);
115
+
116
+ for (const [index, label] of PLAN_CHOICES.entries()) {
117
+ const numbered = `${index + 1}. ${label}`;
118
+ out.push(
119
+ index === view.choice ? paint("accent", cut(`❯ ${numbered}`)) : cut(` ${numbered}`),
120
+ );
121
+ }
122
+ out.push("");
123
+ return out;
124
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * plugins extension — Claude Code plugin support.
3
+ *
4
+ * Reads `~/.claude/plugins/installed_plugins.json`, then publishes each
5
+ * plugin's resources so the other extensions can use them:
6
+ *
7
+ * agents/ → the subagent tool, as `<plugin>:<agent>`
8
+ * skills/ → the skill tool, as `<plugin>:<skill>`
9
+ * commands/ → slash commands, as `/<plugin>:<command>`
10
+ * .mcp.json → the MCP client
11
+ *
12
+ * Everything is namespaced the way Claude Code namespaces it, so two plugins can
13
+ * both ship a `commit` command. This extension only owns the slash commands and
14
+ * the `/plugins` listing; the other consumers call `discoverPlugins()` for
15
+ * themselves, because module state is not shared between extension files.
16
+ */
17
+
18
+ import { execFile } from "node:child_process";
19
+ import { readFileSync } from "node:fs";
20
+ import os from "node:os";
21
+ import { join } from "node:path";
22
+ import { promisify } from "node:util";
23
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
24
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
+ import { discoverPlugins, type Plugin } from "../lib/plugins.ts";
26
+ import { findShellPlaceholders, replaceShellPlaceholders, substituteArguments } from "./template.ts";
27
+
28
+ const run = promisify(execFile);
29
+ const SHELL_TIMEOUT_MS = 30_000;
30
+
31
+ async function expandTemplate(body: string, args: string, cwd: string): Promise<string> {
32
+ const withArgs = substituteArguments(body, args);
33
+ const commands = [...new Set(findShellPlaceholders(withArgs))];
34
+ if (commands.length === 0) return withArgs;
35
+
36
+ const outputs = new Map<string, string>();
37
+ await Promise.all(
38
+ commands.map(async (command) => {
39
+ try {
40
+ const { stdout, stderr } = await run(command, {
41
+ cwd,
42
+ shell: true,
43
+ timeout: SHELL_TIMEOUT_MS,
44
+ maxBuffer: 2 * 1024 * 1024,
45
+ });
46
+ outputs.set(command, (stdout || stderr || "").trim());
47
+ } catch (error) {
48
+ const detail = error as { stdout?: string; stderr?: string; message?: string };
49
+ outputs.set(command, (detail.stdout || detail.stderr || detail.message || "command failed").trim());
50
+ }
51
+ }),
52
+ );
53
+ return replaceShellPlaceholders(withArgs, outputs);
54
+ }
55
+
56
+ export default function pluginsExtension(pi: ExtensionAPI) {
57
+ const claudeDir = join(os.homedir(), ".claude");
58
+ const discovered = discoverPlugins(claudeDir);
59
+
60
+ for (const command of discovered.commands) {
61
+ const plugin = discovered.plugins.find((p) => p.name === command.plugin);
62
+ if (plugin) registerPluginCommand(pi, plugin, command.name, command.path);
63
+ }
64
+
65
+ pi.registerCommand("plugins", {
66
+ description: "List installed Claude Code plugins and what they contribute",
67
+ handler: async (_args, ctx) => {
68
+ if (discovered.plugins.length === 0) {
69
+ ctx.ui.notify("No Claude Code plugins installed (~/.claude/plugins/installed_plugins.json).", "info");
70
+ return;
71
+ }
72
+ const lines = discovered.plugins.map((plugin) => {
73
+ const summary = discovered.byPlugin.get(plugin.name);
74
+ const parts: string[] = [];
75
+ if (summary?.agents) parts.push("agents");
76
+ if (summary?.skills) parts.push(`${summary.skills} skill${summary.skills === 1 ? "" : "s"}`);
77
+ if (summary?.commands) parts.push(`${summary.commands} command${summary.commands === 1 ? "" : "s"}`);
78
+ if (summary?.mcp) parts.push("mcp");
79
+ return `${plugin.name}${plugin.version && plugin.version !== "unknown" ? ` ${plugin.version}` : ""} — ${parts.join(", ") || "nothing usable"}`;
80
+ });
81
+ ctx.ui.notify(lines.join("\n"), "info");
82
+ },
83
+ });
84
+ }
85
+
86
+ function registerPluginCommand(pi: ExtensionAPI, plugin: Plugin, name: string, path: string): void {
87
+ let description = `Command from the ${plugin.name} plugin`;
88
+ let argumentHint: string | undefined;
89
+ try {
90
+ const { frontmatter } = parseFrontmatter(readFileSync(path, "utf-8")) as {
91
+ frontmatter?: Record<string, unknown>;
92
+ };
93
+ if (typeof frontmatter?.description === "string") description = frontmatter.description;
94
+ if (typeof frontmatter?.["argument-hint"] === "string") argumentHint = frontmatter["argument-hint"];
95
+ } catch {
96
+ // Fall back to the generic description.
97
+ }
98
+
99
+ pi.registerCommand(name, {
100
+ description: argumentHint ? `${description} (${argumentHint})` : description,
101
+ handler: async (args, ctx) => {
102
+ let body: string;
103
+ try {
104
+ const parsed = parseFrontmatter(readFileSync(path, "utf-8")) as { body: string };
105
+ body = parsed.body;
106
+ } catch (error) {
107
+ ctx.ui.notify(`Could not read ${path}: ${(error as Error).message}`, "error");
108
+ return;
109
+ }
110
+ const expanded = await expandTemplate(body, args, ctx.cwd);
111
+ // Deliver as a user turn, which is how Claude Code runs a command template.
112
+ pi.sendUserMessage(expanded);
113
+ },
114
+ });
115
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Claude Code command-template expansion (pure apart from the shell hook).
3
+ *
4
+ * A command markdown file has frontmatter (`description`, `allowed-tools`,
5
+ * `argument-hint`) and a body that may contain:
6
+ * $ARGUMENTS, $1, $2, $@ — the invocation's arguments
7
+ * !`shell command` — replaced with that command's output
8
+ *
9
+ * The `!` form is why plugin commands like `commit` work: the template gathers
10
+ * git status and diff before the model sees the prompt.
11
+ */
12
+
13
+ export interface CommandTemplate {
14
+ description?: string;
15
+ argumentHint?: string;
16
+ allowedTools?: string;
17
+ body: string;
18
+ }
19
+
20
+ export function substituteArguments(body: string, args: string): string {
21
+ const parts = args.trim().length > 0 ? args.trim().split(/\s+/) : [];
22
+ return body
23
+ .replace(/\$ARGUMENTS\b/g, args.trim())
24
+ .replace(/\$@/g, args.trim())
25
+ .replace(/\$(\d+)/g, (_match, index) => parts[Number(index) - 1] ?? "");
26
+ }
27
+
28
+ /** Finds each !`command` occurrence so the caller can run them. */
29
+ export function findShellPlaceholders(body: string): string[] {
30
+ const found: string[] = [];
31
+ const pattern = /!`([^`]+)`/g;
32
+ let match = pattern.exec(body);
33
+ while (match) {
34
+ found.push(match[1]);
35
+ match = pattern.exec(body);
36
+ }
37
+ return found;
38
+ }
39
+
40
+ export function replaceShellPlaceholders(body: string, outputs: Map<string, string>): string {
41
+ return body.replace(/!`([^`]+)`/g, (_match, command: string) => outputs.get(command) ?? "");
42
+ }
Binary file
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Pure tier → tool-surface policy: which of pi's inactive built-in search
3
+ * tools should be active for a given prompt tier.
4
+ */
5
+
6
+ import type { PromptTier } from "../lib/model-tier.ts";
7
+
8
+ /** pi built-ins that exist in the registry but are not active by default. */
9
+ export const SEARCH_TOOLS = ["grep", "find", "ls"] as const;
10
+
11
+ /**
12
+ * Returns the active-tool list adjusted for the tier: mid/low gain the search
13
+ * tools (deduped, appended in a stable order), frontier loses them (Claude
14
+ * Code's frontier surface has no dedicated search tools). Order of the
15
+ * existing names is preserved so the rebuilt system prompt stays byte-stable.
16
+ */
17
+ export function withSearchTools(active: string[], tier: PromptTier): string[] {
18
+ if (tier === "frontier") {
19
+ const drop = new Set<string>(SEARCH_TOOLS);
20
+ return active.filter((name) => !drop.has(name));
21
+ }
22
+ const present = new Set(active);
23
+ return [...active, ...SEARCH_TOOLS.filter((name) => !present.has(name))];
24
+ }