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,54 @@
1
+ /**
2
+ * Tested-pi-version guard for the package install path.
3
+ *
4
+ * The package rides on whatever pi the user has installed, and pi renames
5
+ * settings/flags between minor versions (0.83→0.84 renamed `uiMode` to
6
+ * `tuiMode`, and 0.84 rejects the old flag). A user who floats onto an
7
+ * untested pi hits silent breakage, so startup compares the running pi
8
+ * version to the range this release was verified against and warns — softly,
9
+ * never blocking — when outside it. The bundled `one-code` app pins pi inside
10
+ * the range, so it never warns.
11
+ */
12
+
13
+ /** Inclusive minimum and exclusive maximum pi version this release is tested against. */
14
+ export const TESTED_PI_MIN = "0.83.0";
15
+ export const TESTED_PI_MAX_EXCLUSIVE = "0.85.0";
16
+
17
+ /** Dotted-numeric parse; undefined for anything that is not plain x.y.z numbers. */
18
+ export function parseVersion(version: string): number[] | undefined {
19
+ const parts = version.trim().split(".");
20
+ if (parts.length === 0 || parts.length > 3) return undefined;
21
+ const nums = parts.map((p) => (/^\d+$/.test(p) ? Number(p) : Number.NaN));
22
+ return nums.some(Number.isNaN) ? undefined : nums;
23
+ }
24
+
25
+ /** Standard triple compare; undefined when either side is unparseable. */
26
+ export function compareVersions(a: string, b: string): number | undefined {
27
+ const left = parseVersion(a);
28
+ const right = parseVersion(b);
29
+ if (!left || !right) return undefined;
30
+ for (let i = 0; i < 3; i++) {
31
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
32
+ if (diff !== 0) return diff < 0 ? -1 : 1;
33
+ }
34
+ return 0;
35
+ }
36
+
37
+ /**
38
+ * A warning line when the running pi is outside the tested range, undefined
39
+ * when in range or the version cannot be parsed (fail silent — a guard must
40
+ * never take the session down).
41
+ */
42
+ export function piVersionWarning(runningPiVersion: string | undefined): string | undefined {
43
+ if (!runningPiVersion) return undefined;
44
+ const belowMin = compareVersions(runningPiVersion, TESTED_PI_MIN);
45
+ const atOrAboveMax = compareVersions(runningPiVersion, TESTED_PI_MAX_EXCLUSIVE);
46
+ if (belowMin === undefined || atOrAboveMax === undefined) return undefined;
47
+ if (belowMin >= 0 && atOrAboveMax < 0) return undefined;
48
+ const tested = `${TESTED_PI_MIN} – <${TESTED_PI_MAX_EXCLUSIVE}`;
49
+ return (
50
+ `one-code-extension is tested against pi ${tested}; you are running pi ${runningPiVersion}. ` +
51
+ `Things may still work, but if tools or settings misbehave, update one-code-extension ` +
52
+ `(or pin pi inside the tested range).`
53
+ );
54
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Claude Code plugin discovery (pure).
3
+ *
4
+ * On-disk reality, read from a real installation:
5
+ *
6
+ * ~/.claude/plugins/installed_plugins.json
7
+ * { "version": 2, "plugins": { "<name>@<marketplace>": [{ scope, installPath, version }] } }
8
+ *
9
+ * <installPath>/.claude-plugin/plugin.json { name, description, author }
10
+ * <installPath>/agents/*.md agent definitions
11
+ * <installPath>/commands/*.md slash commands
12
+ * <installPath>/skills/<name>/SKILL.md skills
13
+ * <installPath>/.mcp.json MCP servers
14
+ *
15
+ * Claude Code namespaces everything a plugin provides as `<plugin>:<name>`, so
16
+ * two plugins can both ship a `commit` command.
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
20
+ import { basename, join } from "node:path";
21
+
22
+ export interface PluginManifest {
23
+ name?: string;
24
+ description?: string;
25
+ }
26
+
27
+ export interface Plugin {
28
+ /** Plugin name, without the @marketplace suffix. */
29
+ name: string;
30
+ marketplace?: string;
31
+ path: string;
32
+ version?: string;
33
+ description?: string;
34
+ }
35
+
36
+ export interface PluginResources {
37
+ agentsDir?: string;
38
+ commandsDir?: string;
39
+ skillsDir?: string;
40
+ mcpConfig?: string;
41
+ /** <plugin>/hooks/hooks.json when present — consumed by extensions/hooks. */
42
+ hooksConfig?: string;
43
+ }
44
+
45
+ interface InstalledEntry {
46
+ scope?: string;
47
+ installPath?: string;
48
+ version?: string;
49
+ enabled?: boolean;
50
+ }
51
+
52
+ export function splitPluginKey(key: string): { name: string; marketplace?: string } {
53
+ const at = key.lastIndexOf("@");
54
+ if (at <= 0) return { name: key };
55
+ return { name: key.slice(0, at), marketplace: key.slice(at + 1) };
56
+ }
57
+
58
+ /** Read a file's raw contents, or `undefined` if it doesn't exist or can't be read. */
59
+ export function tryReadFile(path: string): string | undefined {
60
+ try {
61
+ return readFileSync(path, "utf-8");
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ function readJsonFile<T>(path: string): T | undefined {
68
+ if (!existsSync(path)) return undefined;
69
+ const raw = tryReadFile(path);
70
+ if (raw === undefined) return undefined;
71
+ try {
72
+ return JSON.parse(raw) as T;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ /** Reads the installed-plugin registry; the newest entry per plugin wins. */
79
+ export function loadInstalledPlugins(claudeDir: string): Plugin[] {
80
+ const registry = readJsonFile<{ plugins?: Record<string, InstalledEntry[]> }>(
81
+ join(claudeDir, "plugins", "installed_plugins.json"),
82
+ );
83
+ if (!registry?.plugins) return [];
84
+
85
+ const plugins: Plugin[] = [];
86
+ for (const [key, entries] of Object.entries(registry.plugins)) {
87
+ if (!Array.isArray(entries) || entries.length === 0) continue;
88
+ const entry = entries.find((e) => e.enabled !== false && e.installPath);
89
+ if (!entry?.installPath || !existsSync(entry.installPath)) continue;
90
+
91
+ const { name, marketplace } = splitPluginKey(key);
92
+ const manifest = readJsonFile<PluginManifest>(join(entry.installPath, ".claude-plugin", "plugin.json"));
93
+ plugins.push({
94
+ name: manifest?.name || name,
95
+ marketplace,
96
+ path: entry.installPath,
97
+ version: entry.version,
98
+ description: manifest?.description,
99
+ });
100
+ }
101
+ return plugins.sort((a, b) => a.name.localeCompare(b.name));
102
+ }
103
+
104
+ export function pluginResources(plugin: Plugin): PluginResources {
105
+ const dirIfExists = (name: string) => {
106
+ const path = join(plugin.path, name);
107
+ return existsSync(path) && statSync(path).isDirectory() ? path : undefined;
108
+ };
109
+ const mcpConfig = join(plugin.path, ".mcp.json");
110
+ const hooksConfig = join(plugin.path, "hooks", "hooks.json");
111
+ return {
112
+ agentsDir: dirIfExists("agents"),
113
+ commandsDir: dirIfExists("commands"),
114
+ skillsDir: dirIfExists("skills"),
115
+ mcpConfig: existsSync(mcpConfig) ? mcpConfig : undefined,
116
+ hooksConfig: existsSync(hooksConfig) ? hooksConfig : undefined,
117
+ };
118
+ }
119
+
120
+ export interface PluginSkill {
121
+ /** Namespaced `<plugin>:<skill>`, as Claude Code presents it. */
122
+ name: string;
123
+ plugin: string;
124
+ path: string;
125
+ }
126
+
127
+ /** Each immediate subdirectory holding a SKILL.md is one skill. */
128
+ export function findPluginSkills(plugin: Plugin, skillsDir: string): PluginSkill[] {
129
+ const skills: PluginSkill[] = [];
130
+ let entries: string[];
131
+ try {
132
+ entries = readdirSync(skillsDir);
133
+ } catch {
134
+ return skills;
135
+ }
136
+ for (const entry of entries) {
137
+ const skillFile = join(skillsDir, entry, "SKILL.md");
138
+ if (existsSync(skillFile)) {
139
+ skills.push({ name: `${plugin.name}:${entry}`, plugin: plugin.name, path: skillFile });
140
+ }
141
+ }
142
+ return skills;
143
+ }
144
+
145
+ export interface PluginCommand {
146
+ /** Namespaced `<plugin>:<command>`. */
147
+ name: string;
148
+ plugin: string;
149
+ path: string;
150
+ }
151
+
152
+ export function findPluginCommands(plugin: Plugin, commandsDir: string): PluginCommand[] {
153
+ let entries: string[];
154
+ try {
155
+ entries = readdirSync(commandsDir);
156
+ } catch {
157
+ return [];
158
+ }
159
+ return entries
160
+ .filter((entry) => entry.endsWith(".md"))
161
+ .map((entry) => ({
162
+ name: `${plugin.name}:${basename(entry, ".md")}`,
163
+ plugin: plugin.name,
164
+ path: join(commandsDir, entry),
165
+ }));
166
+ }
167
+
168
+ /**
169
+ * Everything a plugin contributes, resolved in one pass.
170
+ *
171
+ * IMPORTANT: this is a function each consuming extension calls for itself, not a
172
+ * shared singleton. Under jiti every extension file gets its own module
173
+ * instance, so module-level state written by one extension is invisible to
174
+ * another — a shared registry silently read as empty. Re-deriving is cheap (one
175
+ * small JSON plus a few directory checks) and has no load-order constraints.
176
+ */
177
+ export interface NamespacedDir {
178
+ dir: string;
179
+ namespace: string;
180
+ }
181
+
182
+ export interface DiscoveredPlugins {
183
+ plugins: Plugin[];
184
+ agentDirs: NamespacedDir[];
185
+ skills: PluginSkill[];
186
+ commands: PluginCommand[];
187
+ mcpConfigs: string[];
188
+ /** Per-plugin summary, for status output. */
189
+ byPlugin: Map<string, { agents: boolean; skills: number; commands: number; mcp: boolean }>;
190
+ }
191
+
192
+ let cache: { claudeDir: string; result: DiscoveredPlugins } | undefined;
193
+
194
+ export function discoverPlugins(claudeDir: string): DiscoveredPlugins {
195
+ if (cache?.claudeDir === claudeDir) return cache.result;
196
+
197
+ const plugins = loadInstalledPlugins(claudeDir);
198
+ const result: DiscoveredPlugins = {
199
+ plugins,
200
+ agentDirs: [],
201
+ skills: [],
202
+ commands: [],
203
+ mcpConfigs: [],
204
+ byPlugin: new Map(),
205
+ };
206
+
207
+ for (const plugin of plugins) {
208
+ const resources = pluginResources(plugin);
209
+ const skills = resources.skillsDir ? findPluginSkills(plugin, resources.skillsDir) : [];
210
+ const commands = resources.commandsDir ? findPluginCommands(plugin, resources.commandsDir) : [];
211
+
212
+ if (resources.agentsDir) result.agentDirs.push({ dir: resources.agentsDir, namespace: plugin.name });
213
+ result.skills.push(...skills);
214
+ result.commands.push(...commands);
215
+ if (resources.mcpConfig) result.mcpConfigs.push(resources.mcpConfig);
216
+
217
+ result.byPlugin.set(plugin.name, {
218
+ agents: !!resources.agentsDir,
219
+ skills: skills.length,
220
+ commands: commands.length,
221
+ mcp: !!resources.mcpConfig,
222
+ });
223
+ }
224
+
225
+ cache = { claudeDir, result };
226
+ return result;
227
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * System-reminder queue — the steering mechanism shared by all One Code
3
+ * extensions. Reminders are injected transiently into the outgoing request via
4
+ * pi's `context` event; they are never written to the session file.
5
+ *
6
+ * Cross-extension contract: emit on the event bus channel
7
+ * `one-code:system-reminder` with `{ text, scope? }` to enqueue from any
8
+ * extension (including third-party ones). Extensions in this package can also
9
+ * import `reminderQueue` directly.
10
+ *
11
+ * Placement mirrors Claude Code's first-user-message reminder stack. Session
12
+ * context reminders (deferred tools, agent catalog, MCP instructions, skills,
13
+ * the `# claudeMd` block) are `first-prepend`: they ride at the front of the
14
+ * FIRST user message, before the user's text, ordered by `order`. Turn-steering
15
+ * reminders (file changed, mode cycled, memory near-limit) are `last-append`:
16
+ * appended to the LAST user message, the Claude Code default. See
17
+ * docs/decisions and the captured payloads (opus-4-8.json / latest-haiku.json).
18
+ */
19
+
20
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
21
+ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
22
+
23
+ export const REMINDER_CHANNEL = "one-code:system-reminder";
24
+
25
+ /** "next-turn" fires once on the next LLM call; "every-turn" fires on every call until removed. */
26
+ export type ReminderScope = "next-turn" | "every-turn";
27
+
28
+ /**
29
+ * `first-prepend` → front of the first user message, before the user text,
30
+ * sorted by `order` (Claude Code's context stack). `last-append` → end of the
31
+ * last user message (Claude Code's steering default).
32
+ */
33
+ export type ReminderPlacement = "first-prepend" | "last-append";
34
+
35
+ /**
36
+ * `order` values for the `first-prepend` context stack, matching Claude Code's
37
+ * fixed sequence on the first user message: deferred tools → agent catalog → MCP
38
+ * instructions → skills → the `# claudeMd` block (last, just before the user's
39
+ * text). Gaps leave room for One Code-specific reminders (e.g. subagent models).
40
+ */
41
+ export const CONTEXT_ORDER = {
42
+ deferredTools: 10,
43
+ subagentModels: 20,
44
+ agents: 21,
45
+ mcp: 30,
46
+ skills: 40,
47
+ claudeMd: 50,
48
+ } as const;
49
+
50
+ /** A drained reminder with everything the injector needs to place it. */
51
+ export interface ReminderEntry {
52
+ text: string;
53
+ placement: ReminderPlacement;
54
+ order: number;
55
+ /**
56
+ * Literal text appended AFTER the closing `</system-reminder>` tag. Claude
57
+ * Code's `# claudeMd` block ends `</system-reminder>\n\n` on the wire; this
58
+ * reproduces that byte-for-byte. Defaults to "".
59
+ */
60
+ suffix?: string;
61
+ }
62
+
63
+ export interface ReminderPayload {
64
+ text?: string;
65
+ scope?: ReminderScope;
66
+ /** Key so a reminder can be replaced (next-turn) or replaced/removed (every-turn). */
67
+ key?: string;
68
+ /** Remove the every-turn reminder registered under `key` instead of enqueueing. */
69
+ remove?: boolean;
70
+ /** Where in the message stack this reminder lands. Defaults to `last-append`. */
71
+ placement?: ReminderPlacement;
72
+ /** Sort key among `first-prepend` reminders (Claude Code order). Defaults to 0. */
73
+ order?: number;
74
+ /** Literal text appended after the closing `</system-reminder>` tag. Defaults to "". */
75
+ suffix?: string;
76
+ }
77
+
78
+ interface StoredReminder extends ReminderEntry {
79
+ key?: string;
80
+ }
81
+
82
+ type EnqueueOptions = {
83
+ scope?: ReminderScope;
84
+ key?: string;
85
+ placement?: ReminderPlacement;
86
+ order?: number;
87
+ suffix?: string;
88
+ };
89
+
90
+ export class ReminderQueue {
91
+ private nextTurn: StoredReminder[] = [];
92
+ private everyTurn = new Map<string, StoredReminder>();
93
+
94
+ enqueue(text: string, opts?: EnqueueOptions): void {
95
+ if (!text.trim()) return;
96
+ const entry: StoredReminder = {
97
+ text,
98
+ placement: opts?.placement ?? "last-append",
99
+ order: opts?.order ?? 0,
100
+ suffix: opts?.suffix,
101
+ key: opts?.key,
102
+ };
103
+ if (opts?.scope === "every-turn") {
104
+ this.everyTurn.set(opts.key ?? text, entry);
105
+ } else {
106
+ // A keyed next-turn reminder replaces its predecessor, so a rapidly
107
+ // re-emitted state change (cycling permission modes) announces only
108
+ // where it settled.
109
+ if (opts?.key) this.nextTurn = this.nextTurn.filter((r) => r.key !== opts.key);
110
+ this.nextTurn.push(entry);
111
+ }
112
+ }
113
+
114
+ remove(key: string): void {
115
+ this.everyTurn.delete(key);
116
+ }
117
+
118
+ /** Returns pending reminders. Clears next-turn reminders; every-turn ones persist. */
119
+ drain(): ReminderEntry[] {
120
+ const drained: ReminderEntry[] = [
121
+ ...[...this.everyTurn.values()].map(strip),
122
+ ...this.nextTurn.map(strip),
123
+ ];
124
+ this.nextTurn = [];
125
+ return drained;
126
+ }
127
+
128
+ get size(): number {
129
+ return this.everyTurn.size + this.nextTurn.length;
130
+ }
131
+ }
132
+
133
+ function strip(r: StoredReminder): ReminderEntry {
134
+ return { text: r.text, placement: r.placement, order: r.order, suffix: r.suffix };
135
+ }
136
+
137
+ /** Shared queue instance for extensions in this package. */
138
+ export const reminderQueue = new ReminderQueue();
139
+
140
+ export function wrapReminder(text: string): string {
141
+ return `<system-reminder>\n${text}\n</system-reminder>`;
142
+ }
143
+
144
+ type ContentBlock = TextContent | ImageContent;
145
+ type UserMessage = AgentMessage & { content: string | ContentBlock[] };
146
+
147
+ function toBlocks(content: string | ContentBlock[]): ContentBlock[] {
148
+ return typeof content === "string" ? [{ type: "text", text: content }] : [...content];
149
+ }
150
+
151
+ function reminderBlock(entry: ReminderEntry): TextContent {
152
+ return { type: "text", text: wrapReminder(entry.text) + (entry.suffix ?? "") };
153
+ }
154
+
155
+ /**
156
+ * Returns a copy of `messages` with reminder blocks injected (Claude Code
157
+ * convention: reminders ride inside user-message content blocks). `first-prepend`
158
+ * entries go to the front of the first user message, sorted by `order`, before
159
+ * the user's text; `last-append` entries go to the end of the last user message.
160
+ * A bare string is treated as `last-append` (back-compat). The input array and
161
+ * its messages are not mutated. With no user message, messages are returned
162
+ * unchanged and the caller re-enqueues as needed.
163
+ */
164
+ export function injectReminders(
165
+ messages: AgentMessage[],
166
+ reminders: Array<string | ReminderEntry>,
167
+ ): AgentMessage[] {
168
+ if (reminders.length === 0) return messages;
169
+
170
+ const entries: ReminderEntry[] = reminders.map((r) =>
171
+ typeof r === "string" ? { text: r, placement: "last-append", order: 0 } : r,
172
+ );
173
+
174
+ const firstPrepend = entries
175
+ .filter((e) => e.placement === "first-prepend")
176
+ .map((e, i) => ({ e, i }))
177
+ .sort((a, b) => a.e.order - b.e.order || a.i - b.i)
178
+ .map((x) => x.e);
179
+ const lastAppend = entries.filter((e) => e.placement === "last-append");
180
+
181
+ const firstUserIndex = messages.findIndex((m) => m.role === "user");
182
+ if (firstUserIndex === -1) return messages;
183
+ const lastUserIndex = messages.findLastIndex((m) => m.role === "user");
184
+
185
+ const result = [...messages];
186
+
187
+ if (firstUserIndex === lastUserIndex) {
188
+ const target = result[firstUserIndex] as UserMessage;
189
+ const blocks = toBlocks(target.content);
190
+ result[firstUserIndex] = {
191
+ ...target,
192
+ content: [...firstPrepend.map(reminderBlock), ...blocks, ...lastAppend.map(reminderBlock)],
193
+ } as AgentMessage;
194
+ return result;
195
+ }
196
+
197
+ if (firstPrepend.length > 0) {
198
+ const target = result[firstUserIndex] as UserMessage;
199
+ result[firstUserIndex] = {
200
+ ...target,
201
+ content: [...firstPrepend.map(reminderBlock), ...toBlocks(target.content)],
202
+ } as AgentMessage;
203
+ }
204
+ if (lastAppend.length > 0) {
205
+ const target = result[lastUserIndex] as UserMessage;
206
+ result[lastUserIndex] = {
207
+ ...target,
208
+ content: [...toBlocks(target.content), ...lastAppend.map(reminderBlock)],
209
+ } as AgentMessage;
210
+ }
211
+ return result;
212
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Session scratchpad — Claude Code's designated temp-files directory.
3
+ *
4
+ * Claude Code puts one under `<tmp>/claude-<uid>/<project-slug>/<session-id>/scratchpad`
5
+ * and tells the model (via a system-prompt section) to use it for everything
6
+ * that would otherwise land in `/tmp` or leak into the project. The path is
7
+ * per-session, so parallel sessions on one project never collide.
8
+ *
9
+ * Three extensions need the same path (system-prompt renders the section,
10
+ * permissions and the workflow gate allow writes into it), and jiti isolates
11
+ * module state — so each re-derives it through `sessionScratchpadDir`.
12
+ */
13
+
14
+ import { realpathSync } from "node:fs";
15
+ import os from "node:os";
16
+ import { join } from "node:path";
17
+ import { findGitRoot } from "./git.ts";
18
+ import { projectSlug } from "./memory.ts";
19
+
20
+ /** Pure core, testable: the Claude Code path shape. */
21
+ export function scratchpadDir(
22
+ tmpRoot: string,
23
+ uid: number | undefined,
24
+ projectRoot: string,
25
+ sessionId: string,
26
+ ): string {
27
+ const owner = uid === undefined ? "claude" : `claude-${uid}`;
28
+ return join(tmpRoot, owner, projectSlug(projectRoot), sessionId, "scratchpad");
29
+ }
30
+
31
+ /**
32
+ * `/tmp` resolved through its symlink (macOS: `/private/tmp`), so the path in
33
+ * the prompt, the path the permission check compares, and the case-folded
34
+ * resolved subject all name the same real location. Falls back to os.tmpdir()
35
+ * where /tmp does not exist.
36
+ */
37
+ export function resolveTmpRoot(): string {
38
+ try {
39
+ return realpathSync("/tmp");
40
+ } catch {
41
+ return os.tmpdir();
42
+ }
43
+ }
44
+
45
+ /** The session's scratchpad, derived the same way by every consumer. */
46
+ export function sessionScratchpadDir(cwd: string, sessionId: string): string {
47
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
48
+ return scratchpadDir(resolveTmpRoot(), uid, findGitRoot(cwd) ?? cwd, sessionId);
49
+ }
50
+
51
+ /** Claude Code's Scratchpad Directory prompt section, verbatim (see payload.json). */
52
+ export function scratchpadPromptSection(dir: string): string {
53
+ return `# Scratchpad Directory
54
+
55
+ IMPORTANT: Always use this scratchpad directory for temporary files instead of \`/tmp\` or other system temp directories:
56
+ \`${dir}\`
57
+
58
+ Use this directory for ALL temporary file needs:
59
+ - Storing intermediate results or data during multi-step tasks
60
+ - Writing temporary scripts or configuration files
61
+ - Saving outputs that don't belong in the user's project
62
+ - Creating working files during analysis or processing
63
+ - Any file that would otherwise go to \`/tmp\`
64
+
65
+ Only use \`/tmp\` if the user explicitly requests it.
66
+
67
+ The scratchpad directory is session-specific, isolated from the user's project, and can generally be used without permission prompts.`;
68
+ }