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,174 @@
1
+ /**
2
+ * Stateful task list (pure) — Claude Code's TaskCreate/TaskGet/TaskList/TaskUpdate.
3
+ *
4
+ * Unlike todo_write's whole-list replacement, tasks are individually addressable
5
+ * by id and carry owners, arbitrary metadata, and dependency links. `blocks` and
6
+ * `blockedBy` are kept reciprocal: adding one side always records the other.
7
+ */
8
+
9
+ export type TaskStatus = "pending" | "in_progress" | "completed";
10
+
11
+ export interface TaskItem {
12
+ id: string;
13
+ subject: string;
14
+ description: string;
15
+ activeForm?: string;
16
+ status: TaskStatus;
17
+ owner?: string;
18
+ metadata: Record<string, unknown>;
19
+ /** Ids of tasks that cannot start until this one completes. */
20
+ blocks: string[];
21
+ /** Ids of tasks that must complete before this one can start. */
22
+ blockedBy: string[];
23
+ }
24
+
25
+ export interface TaskSnapshot {
26
+ nextId: number;
27
+ tasks: TaskItem[];
28
+ }
29
+
30
+ export interface TaskUpdateInput {
31
+ subject?: string;
32
+ description?: string;
33
+ activeForm?: string;
34
+ status?: TaskStatus | "deleted";
35
+ owner?: string;
36
+ metadata?: Record<string, unknown>;
37
+ addBlocks?: string[];
38
+ addBlockedBy?: string[];
39
+ }
40
+
41
+ export class TaskStore {
42
+ private tasks = new Map<string, TaskItem>();
43
+ private nextId = 1;
44
+
45
+ create(input: { subject: string; description: string; activeForm?: string; metadata?: Record<string, unknown> }): TaskItem {
46
+ const task: TaskItem = {
47
+ id: String(this.nextId++),
48
+ subject: input.subject,
49
+ description: input.description,
50
+ activeForm: input.activeForm,
51
+ status: "pending",
52
+ metadata: { ...(input.metadata ?? {}) },
53
+ blocks: [],
54
+ blockedBy: [],
55
+ };
56
+ this.tasks.set(task.id, task);
57
+ return task;
58
+ }
59
+
60
+ get(id: string): TaskItem | undefined {
61
+ return this.tasks.get(id);
62
+ }
63
+
64
+ list(): TaskItem[] {
65
+ return [...this.tasks.values()];
66
+ }
67
+
68
+ /** Ids in `blockedBy` that refer to existing, not-yet-completed tasks. */
69
+ openBlockers(task: TaskItem): string[] {
70
+ return task.blockedBy.filter((id) => {
71
+ const blocker = this.tasks.get(id);
72
+ return blocker !== undefined && blocker.status !== "completed";
73
+ });
74
+ }
75
+
76
+ update(id: string, input: TaskUpdateInput): { task?: TaskItem; deleted?: boolean; error?: string } {
77
+ const task = this.tasks.get(id);
78
+ if (!task) return { error: `No task with id "${id}". Use task_list to see ids.` };
79
+
80
+ if (input.status === "deleted") {
81
+ this.tasks.delete(id);
82
+ for (const other of this.tasks.values()) {
83
+ other.blocks = other.blocks.filter((b) => b !== id);
84
+ other.blockedBy = other.blockedBy.filter((b) => b !== id);
85
+ }
86
+ return { deleted: true };
87
+ }
88
+
89
+ if (input.subject !== undefined) task.subject = input.subject;
90
+ if (input.description !== undefined) task.description = input.description;
91
+ if (input.activeForm !== undefined) task.activeForm = input.activeForm;
92
+ if (input.status !== undefined) task.status = input.status;
93
+ if (input.owner !== undefined) task.owner = input.owner || undefined;
94
+
95
+ if (input.metadata) {
96
+ for (const [key, value] of Object.entries(input.metadata)) {
97
+ if (value === null) delete task.metadata[key];
98
+ else task.metadata[key] = value;
99
+ }
100
+ }
101
+
102
+ // Separate a self-reference from a genuinely unknown id: link() rejects
103
+ // both, so folding them together reported a valid, existing id as "Unknown
104
+ // task id" — sending the model to hunt a typo that isn't there.
105
+ const unknown: string[] = [];
106
+ let selfRef = false;
107
+ for (const other of input.addBlocks ?? []) {
108
+ if (other === id) selfRef = true;
109
+ else if (!this.link(id, other)) unknown.push(other);
110
+ }
111
+ for (const other of input.addBlockedBy ?? []) {
112
+ if (other === id) selfRef = true;
113
+ else if (!this.link(other, id)) unknown.push(other);
114
+ }
115
+ const errors: string[] = [];
116
+ if (selfRef) errors.push(`Task #${id} cannot block or be blocked by itself`);
117
+ if (unknown.length > 0) errors.push(`Unknown task id(s) in dependency list: ${unknown.join(", ")}`);
118
+ if (errors.length > 0) return { task, error: errors.join(". ") };
119
+ return { task };
120
+ }
121
+
122
+ /** Record "blocker blocks blocked" on both sides. False if either id is unknown. */
123
+ private link(blockerId: string, blockedId: string): boolean {
124
+ const blocker = this.tasks.get(blockerId);
125
+ const blocked = this.tasks.get(blockedId);
126
+ if (!blocker || !blocked || blockerId === blockedId) return false;
127
+ if (!blocker.blocks.includes(blockedId)) blocker.blocks.push(blockedId);
128
+ if (!blocked.blockedBy.includes(blockerId)) blocked.blockedBy.push(blockerId);
129
+ return true;
130
+ }
131
+
132
+ snapshot(): TaskSnapshot {
133
+ return { nextId: this.nextId, tasks: this.list().map((t) => ({ ...t, metadata: { ...t.metadata }, blocks: [...t.blocks], blockedBy: [...t.blockedBy] })) };
134
+ }
135
+
136
+ restore(snapshot: TaskSnapshot | undefined): void {
137
+ this.tasks.clear();
138
+ this.nextId = snapshot?.nextId ?? 1;
139
+ for (const task of snapshot?.tasks ?? []) {
140
+ this.tasks.set(task.id, { ...task, metadata: { ...task.metadata }, blocks: [...task.blocks], blockedBy: [...task.blockedBy] });
141
+ }
142
+ }
143
+ }
144
+
145
+ const STATUS_MARK: Record<TaskStatus, string> = { pending: " ", in_progress: "▸", completed: "x" };
146
+
147
+ export function formatTaskLine(store: TaskStore, task: TaskItem): string {
148
+ const parts = [`#${task.id} [${STATUS_MARK[task.status]}] ${task.subject}`];
149
+ if (task.owner) parts.push(`owner: ${task.owner}`);
150
+ const open = store.openBlockers(task);
151
+ if (open.length > 0) parts.push(`blocked by: ${open.map((b) => `#${b}`).join(", ")}`);
152
+ return parts.join(" · ");
153
+ }
154
+
155
+ export function formatTaskList(store: TaskStore): string {
156
+ const tasks = store.list();
157
+ if (tasks.length === 0) return "No tasks.";
158
+ return tasks.map((t) => formatTaskLine(store, t)).join("\n");
159
+ }
160
+
161
+ export function formatTaskDetails(store: TaskStore, task: TaskItem): string {
162
+ const lines = [
163
+ `Task #${task.id}: ${task.subject}`,
164
+ `Status: ${task.status}${task.owner ? ` · owner: ${task.owner}` : ""}`,
165
+ `Description: ${task.description}`,
166
+ ];
167
+ if (task.blocks.length > 0) lines.push(`Blocks: ${task.blocks.map((b) => `#${b}`).join(", ")}`);
168
+ const open = store.openBlockers(task);
169
+ if (task.blockedBy.length > 0) {
170
+ lines.push(`Blocked by: ${task.blockedBy.map((b) => `#${b}`).join(", ")}${open.length === 0 ? " (all resolved)" : ""}`);
171
+ }
172
+ if (Object.keys(task.metadata).length > 0) lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
173
+ return lines.join("\n");
174
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * todo extension — Claude Code's TodoWrite: stateless whole-list replacement.
3
+ *
4
+ * Schema matches Claude Code (content / status / activeForm per item). State
5
+ * lives in tool-result details, so branching and resuming a session restores
6
+ * the list that was current at that point in history (pattern from pi's
7
+ * official todo example). A widget under the editor shows live progress.
8
+ */
9
+
10
+ import { StringEnum } from "@earendil-works/pi-ai";
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import { restoreLatestDetails } from "../lib/branch-restore.ts";
14
+ import { REMINDER_CHANNEL } from "../lib/reminders.ts";
15
+ import { ccToolRenderers } from "../lib/tui-render.ts";
16
+
17
+ interface TodoItem {
18
+ content: string;
19
+ status: "pending" | "in_progress" | "completed";
20
+ activeForm: string;
21
+ }
22
+
23
+ interface TodoDetails {
24
+ todos: TodoItem[];
25
+ }
26
+
27
+ const TodoWriteParams = Type.Object({
28
+ todos: Type.Array(
29
+ Type.Object({
30
+ content: Type.String({ description: "The task, in imperative form (e.g. 'Fix the login bug')" }),
31
+ status: StringEnum(["pending", "in_progress", "completed"] as const),
32
+ activeForm: Type.String({ description: "Present-continuous form shown while in progress (e.g. 'Fixing the login bug')" }),
33
+ }),
34
+ { description: "The complete todo list; this REPLACES the previous list entirely" },
35
+ ),
36
+ });
37
+
38
+ function formatTodos(todos: TodoItem[]): string {
39
+ if (todos.length === 0) return "Todo list cleared.";
40
+ const mark = (t: TodoItem) => (t.status === "completed" ? "x" : t.status === "in_progress" ? "▸" : " ");
41
+ return todos.map((t) => `[${mark(t)}] ${t.content}`).join("\n");
42
+ }
43
+
44
+ /**
45
+ * Claude Code nudges the model when the task tools have gone unused for a while.
46
+ * A todo tool nobody remembers to call is decoration, so we do the same — quietly
47
+ * and rarely, based on session state.
48
+ */
49
+ const NUDGE_AFTER_TURNS = 8;
50
+ const TODO_TOOLS = new Set(["todo_write"]);
51
+
52
+ export default function todoExtension(pi: ExtensionAPI) {
53
+ let todos: TodoItem[] = [];
54
+ let turnsSinceTodoUse = 0;
55
+
56
+ const updateWidget = (ctx: ExtensionContext) => {
57
+ if (!ctx.hasUI) return;
58
+ if (todos.length === 0) {
59
+ ctx.ui.setWidget("cc-todos", undefined);
60
+ return;
61
+ }
62
+ const done = todos.filter((t) => t.status === "completed").length;
63
+ const active = todos.find((t) => t.status === "in_progress");
64
+ const line = ` todos ${done}/${todos.length}${active ? ` · ${active.activeForm}` : ""}`;
65
+ ctx.ui.setWidget("cc-todos", [line]);
66
+ };
67
+
68
+ const reconstructState = (ctx: ExtensionContext) => {
69
+ const details = restoreLatestDetails<TodoDetails>(ctx.sessionManager.getBranch(), TODO_TOOLS, (d) => Boolean(d?.todos));
70
+ todos = details?.todos ?? [];
71
+ updateWidget(ctx);
72
+ };
73
+
74
+ pi.on("session_start", (_event, ctx) => reconstructState(ctx));
75
+ pi.on("session_tree", (_event, ctx) => reconstructState(ctx));
76
+
77
+ pi.on("turn_end", () => {
78
+ turnsSinceTodoUse++;
79
+ if (turnsSinceTodoUse < NUDGE_AFTER_TURNS) return;
80
+ turnsSinceTodoUse = 0;
81
+
82
+ if (todos.length === 0) {
83
+ pi.events.emit(REMINDER_CHANNEL, {
84
+ text: "The todo tool has not been used recently. If the current work has several steps, track it with todo_write so progress is visible; skip this if the task is simple.",
85
+ });
86
+ return;
87
+ }
88
+
89
+ const active = todos.filter((t) => t.status === "in_progress");
90
+ const done = todos.filter((t) => t.status === "completed").length;
91
+ if (active.length === 0 && done < todos.length) {
92
+ pi.events.emit(REMINDER_CHANNEL, {
93
+ text: `The todo list has ${todos.length - done} unfinished item(s) and none marked in_progress. Update it with todo_write to reflect what you are actually doing, or clear it if it is stale.`,
94
+ });
95
+ } else if (active.length > 1) {
96
+ pi.events.emit(REMINDER_CHANNEL, {
97
+ text: `${active.length} todo items are marked in_progress. Exactly one should be in progress at a time — update the list with todo_write.`,
98
+ });
99
+ }
100
+ });
101
+
102
+ pi.registerTool({
103
+ name: "todo_write",
104
+ label: "Todos",
105
+ ...ccToolRenderers<{ todos?: unknown[] }>("Todos", {
106
+ title: (a) => (a?.todos ? `${a.todos.length} items` : undefined),
107
+ maxCollapsedLines: 12,
108
+ }),
109
+ description:
110
+ "Create and manage a structured task list for the current session. Each call REPLACES the entire list. Use for multi-step tasks (3+ steps): mark exactly one item in_progress before starting it, mark it completed immediately when done, and keep the list current rather than batching updates.",
111
+ promptSnippet: "Track multi-step task progress with a todo list",
112
+ promptGuidelines: [
113
+ "For tasks with 3+ steps, track progress with todo_write; keep exactly one item in_progress",
114
+ ],
115
+ parameters: TodoWriteParams,
116
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
117
+ todos = params.todos as TodoItem[];
118
+ turnsSinceTodoUse = 0;
119
+ updateWidget(ctx);
120
+ return {
121
+ content: [{ type: "text", text: formatTodos(todos) }],
122
+ details: { todos: [...todos] } satisfies TodoDetails,
123
+ };
124
+ },
125
+ });
126
+
127
+ pi.registerCommand("todos", {
128
+ description: "Show the agent's current todo list",
129
+ handler: async (_args, ctx) => {
130
+ ctx.ui.notify(todos.length ? formatTodos(todos) : "No todos.", "info");
131
+ },
132
+ });
133
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * tool-search extension — Claude Code's ToolSearch.
3
+ *
4
+ * Deactivates every tool registered in the deferred registry at session start,
5
+ * tells the model which names exist (a system reminder, as Claude Code does),
6
+ * and activates matches additively when the model calls `tool_search`.
7
+ *
8
+ * Deferral runs on ALL model tiers, matching Claude Code: capture-confirmed that
9
+ * current CC (v2.1.224/226) defers the niche/long-tail tools behind ToolSearch
10
+ * even on Haiku, keeping only the core set eager (findings §14). Which tools are
11
+ * deferrable is decided per tool (extensions opt in via DEFER_CHANNEL) — core
12
+ * tools like read/edit/write/bash never register, so they stay eager.
13
+ *
14
+ * The mechanism is provider-independent: `pi.setActiveTools` edits the agent's
15
+ * active tool set (`agent.state.tools`), which IS what pi sends, so a deactivated
16
+ * tool is omitted from the request on any provider (OpenRouter, deepseek, etc.).
17
+ * Provider-native deferral (Anthropic `defer_loading`, OpenAI `tool_search_call`)
18
+ * is only a cache optimization on top; without it, loading a tool mid-session
19
+ * grows the tools array and invalidates the prompt cache from the tools block
20
+ * down — an accepted tradeoff (findings §7).
21
+ *
22
+ * Load order matters: this extension must come BEFORE any extension that defers
23
+ * a tool, because those emit their defer request while extensions are loading
24
+ * and pi's event bus only delivers to listeners already registered.
25
+ */
26
+
27
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
28
+ import { Type } from "typebox";
29
+ import {
30
+ DEFER_CHANNEL,
31
+ deferredRegistry,
32
+ deferredReminderText,
33
+ type DeferRequest,
34
+ searchTools,
35
+ selectedNames,
36
+ } from "../lib/deferred.ts";
37
+ import { CONTEXT_ORDER, REMINDER_CHANNEL } from "../lib/reminders.ts";
38
+ import { ccToolRenderers } from "../lib/tui-render.ts";
39
+
40
+ export default function toolSearchExtension(pi: ExtensionAPI) {
41
+ let sessionStarted = false;
42
+
43
+ const searchableTools = () =>
44
+ pi
45
+ .getAllTools()
46
+ .filter((tool) => deferredRegistry.has(tool.name))
47
+ .map((tool) => ({
48
+ name: tool.name,
49
+ description: tool.description ?? "",
50
+ keywords: deferredRegistry.keywordsFor(tool.name),
51
+ }));
52
+
53
+ const announceDeferred = () => {
54
+ const available = searchableTools();
55
+ if (available.length === 0) return;
56
+ pi.events.emit(REMINDER_CHANNEL, {
57
+ scope: "every-turn",
58
+ key: "deferred-tools",
59
+ text: deferredReminderText(available),
60
+ placement: "first-prepend",
61
+ order: CONTEXT_ORDER.deferredTools,
62
+ });
63
+ };
64
+
65
+ /** Deactivate every deferred-registry tool and announce the loadable set. */
66
+ const deferAll = () => {
67
+ const deferred = new Set(deferredRegistry.names);
68
+ if (deferred.size === 0) return;
69
+ const active = pi.getActiveTools();
70
+ const next = active.filter((name) => !deferred.has(name));
71
+ if (next.length !== active.length) pi.setActiveTools(next);
72
+ announceDeferred();
73
+ };
74
+
75
+ pi.events.on(DEFER_CHANNEL, (data) => {
76
+ const request = data as DeferRequest;
77
+ deferredRegistry.add(request);
78
+ // A defer arriving after the session_start pass (MCP servers connect
79
+ // asynchronously and register their tools then) would otherwise leave the
80
+ // tool eager AND unlisted in the reminder. Deactivate it and refresh the
81
+ // keyed reminder.
82
+ if (sessionStarted && request?.name) {
83
+ const active = pi.getActiveTools();
84
+ if (active.includes(request.name)) {
85
+ pi.setActiveTools(active.filter((name) => name !== request.name));
86
+ }
87
+ announceDeferred();
88
+ }
89
+ });
90
+
91
+ pi.on("session_start", () => {
92
+ sessionStarted = true;
93
+ deferAll();
94
+ });
95
+
96
+ pi.registerTool({
97
+ name: "tool_search",
98
+ label: "Tool Search",
99
+ ...ccToolRenderers("Tool Search"),
100
+ description:
101
+ "Load the schemas of tools that are available but not yet callable. Query forms: `select:<name>[,<name>]` to load exact tools by name, `+<term> <words>` to require a term in the tool name, or plain keywords to search. Returns the tools that are now callable.",
102
+ promptSnippet: "Load additional tool schemas on demand",
103
+ parameters: Type.Object({
104
+ query: Type.String({ description: "Tool names (`select:a,b`) or keywords describing the capability needed" }),
105
+ max_results: Type.Optional(
106
+ Type.Integer({ minimum: 1, maximum: 20, description: "Maximum tools to load (default 5)" }),
107
+ ),
108
+ }),
109
+ async execute(_toolCallId, params) {
110
+ const available = searchableTools();
111
+ const matches = searchTools(params.query, available, params.max_results ?? 5);
112
+
113
+ // For an exact `select:` query, a requested name that matched nothing was
114
+ // silently dropped before — the model was told the rest "Loaded" and only
115
+ // discovered the miss later as an opaque InputValidationError. Always
116
+ // surface the unmatched names.
117
+ const requested = selectedNames(params.query);
118
+ const notFound = requested
119
+ ? requested.filter((n) => !matches.some((m) => m.name.toLowerCase() === n))
120
+ : [];
121
+ const notFoundNote =
122
+ notFound.length > 0
123
+ ? ` Not found (not deferred tool names — check spelling, or search by keyword instead of \`select:\`): ${notFound.join(", ")}.`
124
+ : "";
125
+
126
+ if (matches.length === 0) {
127
+ const names = available.map((t) => t.name).join(", ") || "(none)";
128
+ return {
129
+ content: [
130
+ { type: "text", text: `No tools matched "${params.query}".${notFoundNote} Tools that can be loaded: ${names}` },
131
+ ],
132
+ details: { matches: [] as string[], added: [] as string[], notFound },
133
+ isError: true,
134
+ };
135
+ }
136
+
137
+ const active = pi.getActiveTools();
138
+ const added = matches.map((m) => m.name).filter((name) => !active.includes(name));
139
+ if (added.length > 0) {
140
+ pi.setActiveTools([...new Set([...active, ...added])]);
141
+ }
142
+
143
+ const loaded = matches.map((m) => m.name);
144
+ return {
145
+ content: [
146
+ {
147
+ type: "text",
148
+ text:
149
+ (added.length > 0
150
+ ? `Loaded ${added.join(", ")}. These tools are now callable.`
151
+ : `Already loaded: ${loaded.join(", ")}.`) + notFoundNote,
152
+ },
153
+ ],
154
+ details: { matches: loaded, added, notFound },
155
+ };
156
+ },
157
+ });
158
+
159
+ pi.registerCommand("tools-deferred", {
160
+ description: "Show which tools are deferred (loadable via tool_search)",
161
+ handler: async (_args, ctx) => {
162
+ const available = searchableTools();
163
+ const activeSet = new Set(pi.getActiveTools());
164
+ const lines = available.map((t) => `${activeSet.has(t.name) ? "loaded " : "deferred"} ${t.name}`);
165
+ ctx.ui.notify(lines.length ? lines.join("\n") : "No deferred tools registered.", "info");
166
+ },
167
+ });
168
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * tool-style extension — Claude Code's `● Tool(args)` / `⎿ result` transcript
3
+ * language for pi's built-in file tools.
4
+ *
5
+ * Registering a tool under a built-in's name overrides it (findings §2, the
6
+ * same mechanism the bash extension uses). Every model-facing field —
7
+ * description, promptSnippet, promptGuidelines, parameters — is passed through
8
+ * from pi's own definition byte-identical, so the system prompt and tool
9
+ * schemas are unchanged; execution delegates to a per-cwd instance of pi's
10
+ * real definition (they close over cwd, and enter_worktree moves the session
11
+ * mid-run). Only the TUI renderers differ: a `●` call line, and the base
12
+ * result component indented under a `⎿` elbow so upstream streaming,
13
+ * truncation, and ctrl+o expansion behavior is preserved. Edit keeps its own
14
+ * call component (the diff preview a permission prompt relies on) with just
15
+ * the header line restyled.
16
+ *
17
+ * grep/find/ls stay tier-gated: registering them does not bypass the
18
+ * search-tools extension, which reconciles the active-tool list on
19
+ * session_start/model_select and removes them for frontier models.
20
+ */
21
+
22
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
23
+ import {
24
+ createEditToolDefinition,
25
+ createFindToolDefinition,
26
+ createGrepToolDefinition,
27
+ createLsToolDefinition,
28
+ createReadToolDefinition,
29
+ createWriteToolDefinition,
30
+ } from "@earendil-works/pi-coding-agent";
31
+ import { perCwd } from "../lib/per-cwd.ts";
32
+ import { ccWrapBuiltinRenderers } from "../lib/tui-render.ts";
33
+
34
+ // The concrete definitions are each strongly typed to their own schema; this
35
+ // extension treats them uniformly, so the spec is deliberately loose. Every
36
+ // field is still passed through verbatim.
37
+ type BuiltinDefinition = ToolDefinition<any, any, any> & { name: string };
38
+
39
+ interface BuiltinSpec {
40
+ label: string;
41
+ create: (cwd: string) => BuiltinDefinition;
42
+ title: (args: Record<string, unknown> | undefined) => string | undefined;
43
+ keepBaseCall?: boolean;
44
+ }
45
+
46
+ const asSpecCreate = (create: (cwd: string) => unknown): BuiltinSpec["create"] =>
47
+ create as BuiltinSpec["create"];
48
+
49
+ const BUILTINS: BuiltinSpec[] = [
50
+ { label: "Read", create: asSpecCreate(createReadToolDefinition), title: (a) => a?.path as string | undefined },
51
+ { label: "Write", create: asSpecCreate(createWriteToolDefinition), title: (a) => a?.path as string | undefined },
52
+ {
53
+ label: "Update",
54
+ create: asSpecCreate(createEditToolDefinition),
55
+ title: (a) => a?.path as string | undefined,
56
+ keepBaseCall: true,
57
+ },
58
+ { label: "Grep", create: asSpecCreate(createGrepToolDefinition), title: (a) => a?.pattern as string | undefined },
59
+ { label: "Find", create: asSpecCreate(createFindToolDefinition), title: (a) => a?.pattern as string | undefined },
60
+ { label: "LS", create: asSpecCreate(createLsToolDefinition), title: (a) => (a?.path as string | undefined) ?? "." },
61
+ ];
62
+
63
+ export default function toolStyleExtension(pi: ExtensionAPI) {
64
+ for (const spec of BUILTINS) {
65
+ const base = spec.create(process.cwd());
66
+ const forCwd = perCwd(spec.create);
67
+
68
+ pi.registerTool({
69
+ name: base.name,
70
+ label: base.label,
71
+ description: base.description,
72
+ promptSnippet: base.promptSnippet,
73
+ promptGuidelines: base.promptGuidelines,
74
+ parameters: base.parameters,
75
+ executionMode: base.executionMode,
76
+ prepareArguments: base.prepareArguments,
77
+ constrainedSampling: base.constrainedSampling,
78
+ ...ccWrapBuiltinRenderers(spec.label, base, { title: spec.title, keepBaseCall: spec.keepBaseCall }),
79
+ async execute(toolCallId, params, signal, onUpdate, ctx: ExtensionContext) {
80
+ return forCwd(ctx.cwd).execute(toolCallId, params as never, signal, onUpdate, ctx);
81
+ },
82
+ } as ToolDefinition);
83
+ }
84
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * web extension — Claude Code's WebSearch role.
3
+ *
4
+ * `web_search` comes from the community `pi-web-search` package, which calls the
5
+ * *current model provider's own* search API (OpenAI/Codex, Anthropic, Gemini) —
6
+ * no third-party key, and as close to Claude Code's server-side search as an
7
+ * extension can get. It also registers Gemini-only `url_context`.
8
+ *
9
+ * WebFetch lives in `extensions/web-fetch` (our own); this file only owns search.
10
+ *
11
+ * Note: pi-web-search drives `setActiveTools` to hide `url_context` on non-Gemini
12
+ * models. That composes with our deferral only because `tool-search` loads first
13
+ * and therefore deactivates deferred tools before this package snapshots the
14
+ * active set. Keep that ordering.
15
+ */
16
+
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import webSearch from "pi-web-search/src/index.ts";
19
+ import { DEFER_CHANNEL } from "../lib/deferred.ts";
20
+
21
+ export default function webExtension(pi: ExtensionAPI) {
22
+ webSearch(pi);
23
+
24
+ // pi-web-search reports failures ("Failed: …" text, details.error set)
25
+ // without isError, so a weak model can read a failure as a successful
26
+ // search with odd content. Stamp isError here rather than patching the
27
+ // vendor package.
28
+ pi.on("tool_result", (event) => {
29
+ if (event.toolName !== "web_search" && event.toolName !== "url_context") return;
30
+ if (event.isError) return;
31
+ const details = event.details as { error?: unknown } | undefined;
32
+ if (details?.error) return { isError: true };
33
+ });
34
+
35
+ pi.events.emit(DEFER_CHANNEL, {
36
+ name: "web_search",
37
+ keywords: ["web", "search", "internet", "google", "news", "lookup", "online", "current"],
38
+ });
39
+ pi.events.emit(DEFER_CHANNEL, {
40
+ name: "url_context",
41
+ keywords: ["url", "analyze page", "gemini"],
42
+ });
43
+ }