one-code-extension 0.3.0 → 0.4.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 (119) hide show
  1. package/README.md +189 -94
  2. package/docs/guide/README.md +3 -2
  3. package/docs/guide/bring-your-claude-code-setup.md +7 -5
  4. package/docs/guide/configuration.md +7 -5
  5. package/docs/guide/differences-from-claude-code.md +25 -14
  6. package/docs/guide/doctor.md +5 -3
  7. package/docs/guide/hooks.md +20 -6
  8. package/docs/guide/installation.md +62 -12
  9. package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
  10. package/docs/guide/providers-and-models.md +12 -10
  11. package/docs/guide/reference.md +9 -4
  12. package/docs/guide/sessions-and-context.md +23 -3
  13. package/docs/guide/skills-plugins-and-mcp.md +7 -5
  14. package/docs/guide/subagents-and-workflows.md +13 -11
  15. package/docs/guide/tasks-and-background-work.md +10 -8
  16. package/docs/guide/terminal-interface.md +7 -5
  17. package/docs/guide/tools.md +29 -5
  18. package/docs/guide/troubleshooting.md +26 -14
  19. package/docs/guide/windows.md +184 -0
  20. package/extensions/auto-mode/actions.ts +3 -1
  21. package/extensions/auto-mode/paths.ts +21 -23
  22. package/extensions/auto-mode/safety-floor.ts +44 -6
  23. package/extensions/auto-mode/shell-analysis.ts +25 -10
  24. package/extensions/auto-mode/transcript.ts +15 -5
  25. package/extensions/background/index.ts +83 -34
  26. package/extensions/background/monitor-batch.ts +7 -5
  27. package/extensions/background/wakeup.ts +18 -24
  28. package/extensions/bash/background.ts +19 -10
  29. package/extensions/bash/guards.ts +36 -16
  30. package/extensions/bash/index.ts +36 -166
  31. package/extensions/branding/index.ts +35 -4
  32. package/extensions/btw/index.ts +207 -0
  33. package/extensions/btw/panel.ts +179 -0
  34. package/extensions/btw/prompt.ts +45 -0
  35. package/extensions/clear/index.ts +15 -0
  36. package/extensions/compaction/fit.ts +9 -6
  37. package/extensions/doctor/build.ts +22 -2
  38. package/extensions/doctor/dependencies.ts +63 -31
  39. package/extensions/doctor/fix-prompt.ts +12 -4
  40. package/extensions/doctor/index.ts +1 -0
  41. package/extensions/doctor/models.ts +2 -1
  42. package/extensions/doctor/report.ts +2 -3
  43. package/extensions/doctor/update-lookup.ts +19 -11
  44. package/extensions/effort/index.ts +2 -1
  45. package/extensions/footer/footer-line.ts +19 -0
  46. package/extensions/footer/index.ts +25 -5
  47. package/extensions/hooks/executor.ts +84 -18
  48. package/extensions/hooks/index.ts +1 -0
  49. package/extensions/hooks/matcher.ts +23 -9
  50. package/extensions/hooks/settings.ts +15 -0
  51. package/extensions/lib/branch-restore.ts +12 -0
  52. package/extensions/lib/claude-context.ts +2 -3
  53. package/extensions/lib/claude-settings.ts +19 -0
  54. package/extensions/lib/command-launch.ts +71 -0
  55. package/extensions/lib/deferred.ts +173 -33
  56. package/extensions/lib/git.ts +16 -0
  57. package/extensions/lib/keys.ts +38 -0
  58. package/extensions/lib/local-command.ts +112 -0
  59. package/extensions/lib/model-facts.generated.json +263 -68
  60. package/extensions/lib/model-policy.ts +13 -0
  61. package/extensions/lib/model-tier.ts +16 -5
  62. package/extensions/lib/notifications.ts +547 -53
  63. package/extensions/lib/paths.ts +130 -2
  64. package/extensions/lib/pi-ai-estimate.ts +57 -26
  65. package/extensions/lib/pi-version.ts +1 -1
  66. package/extensions/lib/process-tree.ts +131 -5
  67. package/extensions/lib/reminders.ts +80 -27
  68. package/extensions/lib/scratchpad.ts +11 -10
  69. package/extensions/lib/shell-spawn.ts +298 -0
  70. package/extensions/lib/shell-tool.ts +252 -0
  71. package/extensions/lib/shell-tools.ts +13 -0
  72. package/extensions/lib/side-call.ts +76 -0
  73. package/extensions/lib/tui-render.ts +62 -11
  74. package/extensions/lib/update-check.mjs +155 -0
  75. package/extensions/lib/usage-bus.ts +1 -1
  76. package/extensions/lib/which.ts +62 -0
  77. package/extensions/lib/worktree-channel.ts +18 -0
  78. package/extensions/lib/worktree-isolation.ts +2 -1
  79. package/extensions/lsp/client.ts +14 -3
  80. package/extensions/lsp/index.ts +12 -5
  81. package/extensions/lsp/install-hints.ts +33 -3
  82. package/extensions/lsp/plugin-servers.ts +2 -2
  83. package/extensions/lsp/servers.ts +29 -7
  84. package/extensions/mcp/index.ts +3 -2
  85. package/extensions/memory/entries.ts +9 -6
  86. package/extensions/memory/index.ts +3 -2
  87. package/extensions/memory/open-external.ts +4 -1
  88. package/extensions/model-default/index.ts +21 -2
  89. package/extensions/permissions/index.ts +57 -28
  90. package/extensions/permissions/matcher.ts +120 -33
  91. package/extensions/permissions/modes.ts +14 -6
  92. package/extensions/permissions/powershell-rules.ts +549 -0
  93. package/extensions/permissions/session-grant.ts +9 -9
  94. package/extensions/plugins/index.ts +3 -2
  95. package/extensions/plugins/install/install.ts +1 -1
  96. package/extensions/powershell/description.ts +139 -0
  97. package/extensions/powershell/guards.ts +91 -0
  98. package/extensions/powershell/index.ts +89 -0
  99. package/extensions/powershell/policy.ts +91 -0
  100. package/extensions/recap/index.ts +10 -24
  101. package/extensions/recap/prompt.ts +3 -3
  102. package/extensions/session-title/index.ts +120 -0
  103. package/extensions/session-title/title.ts +111 -0
  104. package/extensions/skill/index.ts +40 -18
  105. package/extensions/skill/invoke.ts +1 -0
  106. package/extensions/subagents/hand-back-review.ts +10 -12
  107. package/extensions/subagents/index.ts +221 -78
  108. package/extensions/subagents/model-select.ts +75 -7
  109. package/extensions/system-prompt/environment.ts +14 -1
  110. package/extensions/system-reminder/index.ts +9 -0
  111. package/extensions/tasks/index.ts +2 -1
  112. package/extensions/tool-search/index.ts +41 -20
  113. package/extensions/workflow/agent-session.ts +6 -4
  114. package/extensions/workflow/index.ts +28 -5
  115. package/extensions/worktree/guards.ts +5 -4
  116. package/extensions/worktree/index.ts +15 -2
  117. package/extensions/worktree/rewrite.ts +17 -0
  118. package/package.json +7 -4
  119. package/types/app.d.ts +18 -2
@@ -9,8 +9,9 @@
9
9
  * other's. See "Own state, borrowed config" in docs/decisions.md.
10
10
  */
11
11
 
12
- import { homedir } from "node:os";
13
- import { join } from "node:path";
12
+ import { realpathSync } from "node:fs";
13
+ import { homedir, tmpdir } from "node:os";
14
+ import { isAbsolute, join, relative, resolve } from "node:path";
14
15
 
15
16
  /**
16
17
  * Claude Code's config dir — the compat surface One Code reads, never writes.
@@ -59,3 +60,130 @@ export function expandTilde(path: string, home: string): string {
59
60
  export function oneCodeStateDir(env: Record<string, string | undefined> = process.env, home: string = homedir()): string {
60
61
  return env.ONECODE_STATE_DIR || join(home, ".onecode");
61
62
  }
63
+
64
+ /**
65
+ * A path with forward slashes, in Claude Code's POSIX shape on Windows
66
+ * (`utils/windowsPaths.ts windowsPathToPosixPath`): `C:\Users\x` becomes
67
+ * `/c/Users/x`, a UNC `\\server\share` becomes `//server/share`, anything
68
+ * else only flips its separators. This is the form Claude Code matches
69
+ * permission path patterns against on Windows, so a rule written for it —
70
+ * `Read(//c/Users/x/**)` — matches here too. Elsewhere the path is unchanged.
71
+ */
72
+ export function toPosixPath(path: string): string {
73
+ if (process.platform !== "win32") return path;
74
+ if (path.startsWith("\\\\")) return path.replace(/\\/g, "/");
75
+ const drive = /^([A-Za-z]):[/\\]/.exec(path);
76
+ if (drive) return `/${drive[1].toLowerCase()}${path.slice(2).replace(/\\/g, "/")}`;
77
+ return path.replace(/\\/g, "/");
78
+ }
79
+
80
+ /**
81
+ * The inverse conversion, for a path spelled the way Git Bash (MSYS2) spells
82
+ * it, on every platform (pure — `gitBashPathToNative` is the win32-gated
83
+ * form). Claude Code's `posixPathToWindowsPath` covers the first two shapes:
84
+ * `/c/Users/x` is `C:\Users\x`, `/cygdrive/c/x` is `C:\x`. The third is Git
85
+ * for Windows' own mount table: `/tmp/x` is `<tmp>\x`, because its default
86
+ * fstab mounts `/tmp` on the user's temp dir (`usertemp`; findings §22 saw
87
+ * `pwd` print `/tmp/…` for a cwd under `%TEMP%`). Every other POSIX absolute
88
+ * path (`/usr/bin/…`, `/etc/hosts` — files under the Git install) and every
89
+ * non-POSIX path is returned as given.
90
+ */
91
+ export function msysPathToWindows(path: string, tmp: string): string {
92
+ const drive = /^\/(?:cygdrive\/)?([A-Za-z])(?:\/(.*))?$/.exec(path);
93
+ if (drive) return `${drive[1].toUpperCase()}:\\${(drive[2] ?? "").replace(/\//g, "\\")}`;
94
+ const temp = /^\/tmp(?:\/(.*))?$/.exec(path);
95
+ if (temp) return temp[1] ? join(tmp, temp[1]) : tmp;
96
+ return path;
97
+ }
98
+
99
+ /**
100
+ * A token from a bash command line as the path it reaches on this machine:
101
+ * on Windows the bash tool runs Git Bash, so `/c/…`, `/cygdrive/c/…` and
102
+ * `/tmp/…` are converted ({@link msysPathToWindows}); elsewhere, and for any
103
+ * other spelling, the token is unchanged. For bash tokens only — pi's file
104
+ * tools resolve their paths natively, so a gate judging a file-tool path must
105
+ * see what `path.resolve` sees.
106
+ */
107
+ export function gitBashPathToNative(token: string): string {
108
+ return process.platform === "win32" ? msysPathToWindows(token, tmpdir()) : token;
109
+ }
110
+
111
+ /**
112
+ * A Windows system executable by its canonical location, `%SystemRoot%\System32\<name>`
113
+ * (defaulting to `C:\Windows`), so a PATH entry can never substitute it —
114
+ * `taskkill.exe`, `cmd.exe`, Windows PowerShell's `powershell.exe`.
115
+ */
116
+ export function system32Path(name: string, env: Record<string, string | undefined> = process.env): string {
117
+ return join(env.SystemRoot ?? "C:\\Windows", "System32", name);
118
+ }
119
+
120
+ /**
121
+ * The real path of `target`, or undefined when nothing about it resolves:
122
+ * the native realpath first (on Windows it also expands 8.3 short names and
123
+ * fixes the case), the JS one as a fallback. Shared by every containment
124
+ * resolver so "resolved" means one thing.
125
+ */
126
+ export function tryRealpath(target: string): string | undefined {
127
+ try {
128
+ return realpathSync.native(target);
129
+ } catch {
130
+ try {
131
+ return realpathSync(target);
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+ }
137
+
138
+ /** `path` as given when absolute, else joined onto `base` (no `..` normalisation — the spelling stays recognisable). */
139
+ export function absoluteFrom(base: string, path: string): string {
140
+ return isAbsolute(path) ? path : join(base, path);
141
+ }
142
+
143
+ /** Separators as `/`, whatever the platform — for display and for `/`-spelled comparisons. */
144
+ export function forwardSlashes(path: string): string {
145
+ return path.replace(/\\/g, "/");
146
+ }
147
+
148
+ /** Filesystems that fold case (darwin, win32): two spellings of one path compare equal. */
149
+ export function foldsCase(): boolean {
150
+ return process.platform !== "linux";
151
+ }
152
+
153
+ /**
154
+ * The one comparison form of a path: resolved, `/` separators, no trailing
155
+ * slash, case-folded where the filesystem folds (darwin, win32). Every
156
+ * containment check in the codebase compares these — the shell pre-gate's
157
+ * `resolveForContainment`/`isWithin`, the permission matcher's session dirs,
158
+ * the safety floor — so "under this directory" means one thing everywhere.
159
+ */
160
+ export function comparablePath(path: string): string {
161
+ const normalized = forwardSlashes(resolve(path)).replace(/\/+$/, "");
162
+ return foldsCase() ? normalized.toLowerCase() : normalized;
163
+ }
164
+
165
+ /** Whether `path` is at or under `dir`, on {@link comparablePath} forms. No symlink resolution — resolve both sides first when that matters. */
166
+ export function isPathAtOrUnder(path: string, dir: string): boolean {
167
+ const target = comparablePath(path);
168
+ const base = comparablePath(dir);
169
+ return target === base || target.startsWith(`${base}/`);
170
+ }
171
+
172
+ /** A `path.relative` result that stays inside its base: non-empty, no `..` hop, not another root/drive. */
173
+ export function isRelativeInside(rel: string): boolean {
174
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
175
+ }
176
+
177
+ /**
178
+ * `~`-abbreviated display form of a path: the home dir itself is `~`, a path
179
+ * under it is `~/rest` with forward slashes, anything else is returned as
180
+ * given (`C:\Users\x\.claude` under `C:\Users\x` shows as `~/.claude`;
181
+ * `path.relative` compares case-insensitively on Windows, like its filesystem).
182
+ */
183
+ export function tildify(path: string, home: string): string {
184
+ if (!home) return path;
185
+ const rest = relative(resolve(home), resolve(path));
186
+ if (rest === "") return "~";
187
+ return isRelativeInside(rest) ? `~/${forwardSlashes(rest)}` : path;
188
+ }
189
+
@@ -16,7 +16,10 @@
16
16
  *
17
17
  * So the two consumed helpers (`clampMaxTokensToContext`, `estimateMessageTokens`)
18
18
  * and the private estimator chain they depend on are copied here from pi-ai
19
- * 0.85.0, importing only types from the bare specifier. The two exported helpers
19
+ * 0.86.1, importing from the bare specifier only types plus `getSystemMessageText`
20
+ * (which pi-ai re-exports from its entry point, so it needs no vendoring — unlike
21
+ * the estimate/clamp helpers under `/utils/estimate` and `/api/simple-options`,
22
+ * which have no bare re-export). The two exported helpers
20
23
  * are byte-identical to upstream; the one adaptation is the internal
21
24
  * `estimateContextTokens`, narrowed to accept only `Context` (the sole shape we
22
25
  * call it with) rather than upstream's `Context | Message[]` overload — the
@@ -24,9 +27,27 @@
24
27
  * imports the real deep paths from node_modules and asserts the two exported
25
28
  * helpers produce identical numbers, so an upstream change is caught rather than
26
29
  * silently drifting.
30
+ *
31
+ * pi 0.86.1 changes re-vendored here (from 0.85.0): the `Message` union gained a
32
+ * `SystemMessage` (its prompt text, `toolsAdded`, and `toolsRemoved` now ride the
33
+ * transcript, so `estimateMessageTokens` has a `system` branch that calls pi-ai's
34
+ * `getSystemMessageText`); `addedToolNames` was removed
35
+ * from `ToolResultMessage`; and `estimateContextTokens` no longer adds
36
+ * `context.systemPrompt`/`context.tools` separately — it sums the messages alone,
37
+ * counting the prompt and tools through any `SystemMessage` present.
38
+ *
39
+ * pi's `completeSimple`/`streamSimple` run `normalizeContext` (folding
40
+ * `systemPrompt`/`tools` into a leading `SystemMessage`) BEFORE the clamp, and
41
+ * `estimateContextTokens` counts the prompt and tools only through that message.
42
+ * So `normalizeContext` and `createInitialSystemMessage` are vendored here too
43
+ * (byte-identical to pi-ai's `utils/transcript`), and the compaction extension
44
+ * normalizes a `Context` before clamping it, exactly as `completeSimple` does —
45
+ * otherwise a raw `Context`'s `systemPrompt`/`tools` go uncounted and the clamp
46
+ * over-estimates the output room. `test/unit/pi-ai-estimate.test.ts` locks all
47
+ * four helpers against the real ones.
27
48
  */
28
49
 
29
- import type { Api, Context, ImageContent, Message, Model, TextContent, Tool, Usage } from "@earendil-works/pi-ai";
50
+ import { getSystemMessageText, type Api, type Context, type ImageContent, type Message, type Model, type TextContent, type Usage } from "@earendil-works/pi-ai";
30
51
 
31
52
  const CHARS_PER_TOKEN = 4;
32
53
  const ESTIMATED_IMAGE_CHARS = 4800;
@@ -62,6 +83,9 @@ function estimateTextAndImageContentTokens(content: string | Array<TextContent |
62
83
 
63
84
  export function estimateMessageTokens(message: Message): number {
64
85
  let chars = 0;
86
+ if (message.role === "system") {
87
+ return estimateTextTokens(getSystemMessageText(message)) + estimateToolsTokens(message.toolsAdded) + estimateToolsTokens(message.toolsRemoved);
88
+ }
65
89
  if (message.role === "user") return estimateTextAndImageContentTokens(message.content);
66
90
  if (message.role === "toolResult") return estimateTextAndImageContentTokens(message.content);
67
91
  for (const block of message.content) {
@@ -122,35 +146,42 @@ function estimateMessages(messages: readonly Message[]): ContextUsageEstimate {
122
146
  return { tokens, usageTokens: 0, trailingTokens: tokens, lastUsageIndex: null };
123
147
  }
124
148
 
125
- function estimateToolsTokens(tools: Tool[] | undefined): number {
149
+ // Accepts `Tool[]` (`toolsAdded`) or `ToolReference[]` (`toolsRemoved`); pi's own
150
+ // helper only JSON-stringifies, so the element shape is immaterial.
151
+ function estimateToolsTokens(tools: readonly unknown[] | undefined): number {
126
152
  if (!tools || tools.length === 0) return 0;
127
153
  return estimateTextTokens(safeJsonStringify(tools));
128
154
  }
129
155
 
156
+ // pi 0.86.1: the prompt and tool declarations ride the transcript as system
157
+ // messages, so this sums the messages alone — no separate systemPrompt/tools
158
+ // term, and no `addedToolNames` accounting (both gone upstream).
130
159
  function estimateContextTokens(context: Context): ContextUsageEstimate {
131
- const estimate = estimateMessages(context.messages);
132
- if (estimate.lastUsageIndex !== null) {
133
- const addedNames = new Set(
134
- context.messages
135
- .slice(estimate.lastUsageIndex + 1)
136
- .filter((message) => message.role === "toolResult")
137
- .flatMap((message) => (message.role === "toolResult" ? message.addedToolNames ?? [] : [])),
138
- );
139
- const addedToolTokens = estimateToolsTokens(context.tools?.filter((tool) => addedNames.has(tool.name)));
140
- return {
141
- tokens: estimate.tokens + addedToolTokens,
142
- usageTokens: estimate.usageTokens,
143
- trailingTokens: estimate.trailingTokens + addedToolTokens,
144
- lastUsageIndex: estimate.lastUsageIndex,
145
- };
146
- }
147
- const prefixTokens = (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools);
148
- return {
149
- tokens: estimate.tokens + prefixTokens,
150
- usageTokens: estimate.usageTokens,
151
- trailingTokens: estimate.trailingTokens + prefixTokens,
152
- lastUsageIndex: estimate.lastUsageIndex,
153
- };
160
+ return estimateMessages(context.messages);
161
+ }
162
+
163
+ /**
164
+ * pi-ai's `createInitialSystemMessage` (`utils/transcript`): the leading system
165
+ * message carrying the prompt and tool declarations, or undefined when both are
166
+ * empty. Byte-identical to upstream.
167
+ */
168
+ export function createInitialSystemMessage(systemPrompt: Context["systemPrompt"], tools: Context["tools"]): Message | undefined {
169
+ const hasSystemPrompt = systemPrompt !== undefined && systemPrompt.length > 0;
170
+ const hasTools = tools !== undefined && tools.length > 0;
171
+ if (!hasSystemPrompt && !hasTools) return undefined;
172
+ return { role: "system", content: systemPrompt ?? "", ...(hasTools ? { toolsAdded: tools } : {}), timestamp: 0 };
173
+ }
174
+
175
+ /**
176
+ * pi-ai's `normalizeContext` (`utils/transcript`): fold `systemPrompt`/`tools`
177
+ * into a leading system message so the estimate counts them. `completeSimple`
178
+ * runs this before the clamp; callers of the vendored clamp must do the same.
179
+ * Returns a plain `Context` (the vendored clamp reads only `.messages`; pi's real
180
+ * clamp is typed for the branded `TranscriptContext`).
181
+ */
182
+ export function normalizeContext(context: Context): Context {
183
+ const initialMessage = createInitialSystemMessage(context.systemPrompt, context.tools);
184
+ return { messages: initialMessage ? [initialMessage, ...context.messages] : context.messages };
154
185
  }
155
186
 
156
187
  export function clampMaxTokensToContext(model: Model<Api>, context: Context, maxTokens: number): number {
@@ -12,7 +12,7 @@
12
12
 
13
13
  /** Inclusive minimum and exclusive maximum pi version this release is tested against. */
14
14
  export const TESTED_PI_MIN = "0.83.0";
15
- export const TESTED_PI_MAX_EXCLUSIVE = "0.86.0";
15
+ export const TESTED_PI_MAX_EXCLUSIVE = "0.87.0";
16
16
 
17
17
  /** Dotted-numeric parse; undefined for anything that is not plain x.y.z numbers. */
18
18
  export function parseVersion(version: string): number[] | undefined {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Process-tree termination for the shells One Code spawns in the background
3
- * (`bash run_in_background`, `monitor`).
3
+ * (`bash run_in_background`, `monitor`), and the one way to wait for such a
4
+ * child to finish.
4
5
  *
5
6
  * A `$SHELL -c "cmd"` child is only the leader: with zsh (macOS default) a
6
7
  * `cmd; echo` or a pipeline runs the command in further processes, and
@@ -16,20 +17,73 @@
16
17
  * `stopProcessTree` adds the grace period: SIGTERM now, SIGKILL if the leader
17
18
  * has not exited by then (a command that ignores SIGTERM still closes late,
18
19
  * which is exactly the late callback the shutdown paths guard against).
20
+ *
21
+ * `waitForChildExit` is the counterpart on the waiting side: it settles on
22
+ * `exit` plus a short stdio grace, never on `close` alone — a descendant the
23
+ * kill missed (Windows: a process the `taskkill /T` snapshot did not see) can
24
+ * hold the inherited pipes open for as long as it lives, and the task must not
25
+ * wait for it (pi's `waitForChildProcess` has the same shape and reason).
19
26
  */
20
27
 
21
- import type { ChildProcess } from "node:child_process";
28
+ import { type ChildProcess, execFile } from "node:child_process";
29
+ import { system32Path } from "./paths.ts";
22
30
 
23
31
  /** SIGTERM → SIGKILL grace for a stopped background tree (bash tasks, monitors). */
24
32
  export const KILL_GRACE_MS = 2_000;
25
33
 
26
- /** `detached` everywhere but Windows, where process groups do not exist. */
27
- export function detachedSpawnOptions(): { detached: boolean } {
28
- return { detached: process.platform !== "win32" };
34
+ /** After `exit`, how long buffered stdio may keep arriving before the wait settles. */
35
+ export const EXIT_STDIO_GRACE_MS = 200;
36
+
37
+ /** ...and the most it may keep arriving in total: a straggler writing continuously must not keep re-arming the grace. */
38
+ export const EXIT_STDIO_MAX_MS = 2_000;
39
+
40
+ /**
41
+ * `detached` everywhere but Windows, where process groups do not exist — except
42
+ * for a child that must outlive an exiting parent (a fire-and-forget hook),
43
+ * which is what `detached: true` means on Windows (Node's documented use); the
44
+ * leader is still killed by pid there.
45
+ */
46
+ export function detachedSpawnOptions(opts: { outlivesParent?: boolean } = {}): { detached: boolean } {
47
+ return { detached: process.platform !== "win32" || Boolean(opts.outlivesParent) };
48
+ }
49
+
50
+ /**
51
+ * Windows has no process groups and no SIGTERM: `taskkill /T /F` ends the
52
+ * tree by pid (the same call pi's own `killProcessTree` makes), from System32
53
+ * so a PATH entry cannot substitute the binary. Whatever taskkill reports, the
54
+ * leader itself is then terminated directly too — taskkill can fail to kill
55
+ * (or to find) a member, and the leader's exit is what the waiting side keys
56
+ * on. `ONECODE_DEBUG_KILL=1` logs taskkill's output to stderr.
57
+ */
58
+ function taskkillTree(child: ChildProcess): void {
59
+ const pid = child.pid;
60
+ if (pid == null) return;
61
+ const direct = () => {
62
+ try {
63
+ child.kill();
64
+ } catch {
65
+ // Already gone.
66
+ }
67
+ };
68
+ try {
69
+ const proc = execFile(system32Path("taskkill.exe"), ["/F", "/T", "/PID", String(pid)], { windowsHide: true }, (error, stdout, stderr) => {
70
+ if (process.env.ONECODE_DEBUG_KILL) {
71
+ process.stderr.write(`[process-tree] taskkill /T /F /PID ${pid}: ${error ? `error ${error.message}` : "ok"}\n${stdout}${stderr}`);
72
+ }
73
+ direct();
74
+ });
75
+ proc.unref();
76
+ } catch {
77
+ direct();
78
+ }
29
79
  }
30
80
 
31
81
  export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void {
32
82
  if (child.pid == null) return;
83
+ if (process.platform === "win32") {
84
+ taskkillTree(child);
85
+ return;
86
+ }
33
87
  try {
34
88
  process.kill(-child.pid, signal);
35
89
  } catch {
@@ -47,6 +101,8 @@ export function killProcessTree(child: ChildProcess, signal: NodeJS.Signals = "S
47
101
  */
48
102
  export function stopProcessTree(child: ChildProcess, graceMs: number): void {
49
103
  killProcessTree(child, "SIGTERM");
104
+ // taskkill /F is already forceful; there is no gentler first signal to grace.
105
+ if (process.platform === "win32") return;
50
106
  if (child.exitCode !== null || child.signalCode !== null) return;
51
107
  const timer = setTimeout(() => {
52
108
  if (child.exitCode === null && child.signalCode === null) killProcessTree(child, "SIGKILL");
@@ -54,3 +110,73 @@ export function stopProcessTree(child: ChildProcess, graceMs: number): void {
54
110
  timer.unref?.();
55
111
  child.once("exit", () => clearTimeout(timer));
56
112
  }
113
+
114
+ export interface ChildExit {
115
+ code: number | null;
116
+ signal: NodeJS.Signals | null;
117
+ }
118
+
119
+ /**
120
+ * Resolve when the child has exited and its stdio has drained: on `close`
121
+ * when that arrives promptly, else `EXIT_STDIO_GRACE_MS` after `exit` (the
122
+ * grace re-arms while output is still arriving, so a burst written just before
123
+ * exit is not cut) and `EXIT_STDIO_MAX_MS` after `exit` at the latest (a
124
+ * descendant the kill missed that keeps writing must not hold the wait).
125
+ * Rejects on a spawn `error`. Listeners the caller attached for `data` keep
126
+ * working; the streams are destroyed once this settles so a straggler holding
127
+ * the far end cannot keep them — or the process — alive.
128
+ */
129
+ export function waitForChildExit(child: ChildProcess): Promise<ChildExit> {
130
+ return new Promise((resolve, reject) => {
131
+ let settled = false;
132
+ let exited: ChildExit | undefined;
133
+ let grace: NodeJS.Timeout | undefined;
134
+ let deadline: NodeJS.Timeout | undefined;
135
+ const cleanup = () => {
136
+ if (grace) clearTimeout(grace);
137
+ if (deadline) clearTimeout(deadline);
138
+ child.removeListener("error", onError);
139
+ child.removeListener("exit", onExit);
140
+ child.removeListener("close", onClose);
141
+ child.stdout?.removeListener("data", onData);
142
+ child.stderr?.removeListener("data", onData);
143
+ };
144
+ const finish = () => {
145
+ if (settled || !exited) return;
146
+ settled = true;
147
+ cleanup();
148
+ child.stdout?.destroy();
149
+ child.stderr?.destroy();
150
+ resolve(exited);
151
+ };
152
+ const arm = () => {
153
+ if (grace) clearTimeout(grace);
154
+ // Ref'd on purpose: in a one-shot run this timer may be the only handle
155
+ // left once the pipes have closed, and the promise must still settle.
156
+ grace = setTimeout(finish, EXIT_STDIO_GRACE_MS);
157
+ };
158
+ const onError = (error: Error) => {
159
+ if (settled) return;
160
+ settled = true;
161
+ cleanup();
162
+ reject(error);
163
+ };
164
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
165
+ exited = { code, signal };
166
+ arm();
167
+ deadline = setTimeout(finish, EXIT_STDIO_MAX_MS);
168
+ };
169
+ const onClose = (code: number | null, signal: NodeJS.Signals | null) => {
170
+ exited ??= { code, signal };
171
+ finish();
172
+ };
173
+ const onData = () => {
174
+ if (exited && !settled) arm();
175
+ };
176
+ child.once("error", onError);
177
+ child.once("exit", onExit);
178
+ child.once("close", onClose);
179
+ child.stdout?.on("data", onData);
180
+ child.stderr?.on("data", onData);
181
+ });
182
+ }
@@ -35,6 +35,15 @@
35
35
  * id, else the last user message by timestamp) and re-attached to that same
36
36
  * message on every later request, byte-identical. Pins are process memory
37
37
  * (a `--resume` drops them: one miss, once).
38
+ * - `user-prepend` — LOCAL-COMMAND BREADCRUMBS (`/clear`, `/model`, a panel
39
+ * command): what Claude Code shows the model when the user ran a slash
40
+ * command. Bare text blocks placed BEFORE the user's text on the next user
41
+ * message that opens a request (after the `first-prepend` stack on message
42
+ * one), then pinned there like a delivered one-shot. They never ride a tool
43
+ * result — a command run mid-turn waits for the next prompt. The caveat
44
+ * block is queued `once` per pending run (a second command joins the first
45
+ * caveat); the command and stdout blocks are never collapsed.
46
+ * `lib/local-command.ts` builds the blocks.
38
47
  *
39
48
  * Persisted or pinned is decided by WHEN the one-shot is emitted, which is a
40
49
  * load-order rule: system-reminder's `tool_result` hook takes the one-shots
@@ -66,7 +75,7 @@ export const REMINDER_CHANNEL = "one-code:system-reminder";
66
75
  export type ReminderScope = "next-turn" | "every-turn";
67
76
 
68
77
  /** See the module header for which placement fits which kind of reminder. */
69
- export type ReminderPlacement = "first-prepend" | "sticky-append" | "last-append";
78
+ export type ReminderPlacement = "first-prepend" | "sticky-append" | "last-append" | "user-prepend";
70
79
 
71
80
  /** Where a delivered one-shot stays: a tool result (by call id) or a user turn (by timestamp). */
72
81
  export type PinAnchor = { kind: "toolResult"; toolCallId: string } | { kind: "user"; timestamp: number };
@@ -136,6 +145,8 @@ export interface ReminderPayload {
136
145
  suffix?: string;
137
146
  /** Emit the text bare, with no `<system-reminder>` frame. */
138
147
  raw?: boolean;
148
+ /** `user-prepend` only: skip when the same text is already pending (the shared caveat). */
149
+ once?: boolean;
139
150
  /**
140
151
  * `sticky-append` only: anchor the block from this timestamp instead of now —
141
152
  * `0` puts it on every user message in the session, including ones a resume
@@ -157,6 +168,8 @@ type EnqueueOptions = {
157
168
  raw?: boolean;
158
169
  /** Test seam / explicit anchor for `sticky-append`; defaults to now. */
159
170
  since?: number;
171
+ /** `user-prepend` only: skip when the same text is already pending. */
172
+ once?: boolean;
160
173
  };
161
174
 
162
175
  export class ReminderQueue {
@@ -194,13 +207,18 @@ export class ReminderQueue {
194
207
  }
195
208
  if (opts?.scope === "every-turn") {
196
209
  this.everyTurn.set(opts.key ?? text, entry);
197
- } else {
198
- // A keyed next-turn reminder replaces its predecessor, so a rapidly
199
- // re-emitted state change (cycling permission modes) announces only
200
- // where it settled.
201
- if (opts?.key) this.nextTurn = this.nextTurn.filter((r) => r.key !== opts.key);
202
- this.nextTurn.push(entry);
210
+ return;
203
211
  }
212
+ // A breadcrumb run shares one caveat block (`once`): the same text pending
213
+ // twice would show the model two identical blocks in a row. Only the
214
+ // announcer's caveat asks for this — two commands with the same (empty)
215
+ // stdout are two commands, and both stay.
216
+ if (placement === "user-prepend" && opts?.once && this.nextTurn.some((r) => r.placement === placement && r.text === text)) return;
217
+ // A keyed next-turn reminder replaces its predecessor, so a rapidly
218
+ // re-emitted state change (cycling permission modes) announces only
219
+ // where it settled.
220
+ if (opts?.key) this.nextTurn = this.nextTurn.filter((r) => r.key !== opts.key);
221
+ this.nextTurn.push(entry);
204
222
  }
205
223
 
206
224
  remove(key: string): void {
@@ -213,27 +231,30 @@ export class ReminderQueue {
213
231
  * placements stay queued for the next request.
214
232
  */
215
233
  takeOneShots(): ReminderEntry[] {
216
- const taken = this.nextTurn.filter((r) => r.placement === "last-append");
217
- this.nextTurn = this.nextTurn.filter((r) => r.placement !== "last-append");
218
- return taken.map(strip);
234
+ const { matching, rest } = partition(this.nextTurn, (r) => r.placement === "last-append");
235
+ this.nextTurn = rest;
236
+ return matching.map(strip);
219
237
  }
220
238
 
221
239
  /**
222
- * Fix the pending `last-append` one-shots to `anchor` — the message they ride
223
- * on this request — so every later request re-attaches them there unchanged.
224
- * Called by the owner at `context` time, after it has decided the tail.
240
+ * Fix the pending one-shots of `placement` to `anchor` — the message they
241
+ * ride on this request — so every later request re-attaches them there
242
+ * unchanged. Called by the owner at `context` time, after it has decided the
243
+ * tail: `last-append` goes on the tail (`tailAnchor`), `user-prepend` only
244
+ * on a request that ends in a user-like message (`openingUserAnchor`) —
245
+ * mid-turn, breadcrumbs stay queued.
225
246
  */
226
- pin(anchor: PinAnchor): void {
227
- for (const entry of this.nextTurn) {
228
- if (entry.placement !== "last-append") continue;
229
- this.pinned.push({ ...entry, pin: anchor });
230
- }
231
- this.nextTurn = this.nextTurn.filter((r) => r.placement !== "last-append");
247
+ pin(anchor: PinAnchor, placement: "last-append" | "user-prepend" = "last-append"): void {
248
+ const { matching, rest } = partition(this.nextTurn, (r) => r.placement === placement);
249
+ for (const entry of matching) this.pinned.push({ ...entry, pin: anchor });
250
+ this.nextTurn = rest;
232
251
  }
233
252
 
234
253
  /**
235
254
  * Everything to inject on this request: every-turn state, pinned one-shots,
236
- * then whatever is still pending. `messages` is the request being built:
255
+ * then whatever is still pending. A `user-prepend` breadcrumb leaves the
256
+ * queue only through `pin(anchor, "user-prepend")` (it must land on a user
257
+ * message, not a tool result), so an unpinned one stays for the next request. `messages` is the request being built:
237
258
  * pins whose anchor is no longer in it (compacted away, or left behind on
238
259
  * another branch) are dropped here, as part of draining, so no caller can
239
260
  * forget the step. Nothing else is ever evicted: a pin is one small object,
@@ -246,21 +267,34 @@ export class ReminderQueue {
246
267
  const locate = pinLocator(messages);
247
268
  this.pinned = this.pinned.filter((entry) => locate(entry.pin as PinAnchor) !== -1);
248
269
  }
249
- const pending = this.nextTurn.map(strip);
250
- this.nextTurn = [];
251
- return [...[...this.everyTurn.values()].map(strip), ...this.pinned.map(strip), ...pending];
270
+ const { matching: held, rest: pending } = partition(this.nextTurn, (r) => r.placement === "user-prepend");
271
+ this.nextTurn = held;
272
+ return [...[...this.everyTurn.values()].map(strip), ...this.pinned.map(strip), ...pending.map(strip)];
252
273
  }
253
274
 
254
275
  get size(): number {
255
276
  return this.everyTurn.size + this.nextTurn.length + this.pinned.length;
256
277
  }
257
278
 
258
- /** True when a one-shot is waiting to be delivered (the owner decides where). */
279
+ /** True when a one-shot of `placement` is waiting to be delivered (the owner decides where). */
280
+ hasPending(placement: "last-append" | "user-prepend"): boolean {
281
+ return this.nextTurn.some((r) => r.placement === placement);
282
+ }
283
+
284
+ /** `hasPending("last-append")` — kept for the existing callers and tests. */
259
285
  get hasPendingOneShots(): boolean {
260
- return this.nextTurn.some((r) => r.placement === "last-append");
286
+ return this.hasPending("last-append");
261
287
  }
262
288
  }
263
289
 
290
+ /** One pass: the entries `test` accepts and the rest, in order. */
291
+ function partition<T>(items: T[], test: (item: T) => boolean): { matching: T[]; rest: T[] } {
292
+ const matching: T[] = [];
293
+ const rest: T[] = [];
294
+ for (const item of items) (test(item) ? matching : rest).push(item);
295
+ return { matching, rest };
296
+ }
297
+
264
298
  function strip(r: StoredReminder): ReminderEntry {
265
299
  const entry: ReminderEntry = { text: r.text, placement: r.placement, order: r.order, suffix: r.suffix };
266
300
  if (r.raw) entry.raw = true;
@@ -303,6 +337,17 @@ export function tailAnchor(messages: AgentMessage[]): PinAnchor | undefined {
303
337
  return undefined;
304
338
  }
305
339
 
340
+ /**
341
+ * The anchor for a `user-prepend` breadcrumb on this request: the trailing
342
+ * message when it is a user-like turn (the prompt that opens the request), else
343
+ * undefined — mid-turn (a trailing tool result) the breadcrumbs keep waiting.
344
+ */
345
+ export function openingUserAnchor(messages: AgentMessage[]): PinAnchor | undefined {
346
+ const last = messages[messages.length - 1] as (AgentMessage & { timestamp?: number }) | undefined;
347
+ if (last && isUserLike(last) && typeof last.timestamp === "number") return { kind: "user", timestamp: last.timestamp };
348
+ return undefined;
349
+ }
350
+
306
351
  /** A tool result's content with reminder blocks appended (for the `tool_result` hook). */
307
352
  export function appendReminderBlocks(content: ContentBlock[], entries: ReminderEntry[]): ContentBlock[] {
308
353
  return [...content, ...entries.map(reminderBlock)];
@@ -406,6 +451,10 @@ export function injectReminders(messages: AgentMessage[], reminders: Array<strin
406
451
  const sticky = entries.filter((e) => e.placement === "sticky-append");
407
452
  const pinnedEntries = entries.filter((e) => e.pin !== undefined);
408
453
  const lastAppend = entries.filter((e) => e.placement === "last-append" && e.pin === undefined);
454
+ // An unpinned `user-prepend` never reaches here through the queue (drain
455
+ // holds it back); a caller passing one directly gets it on the opening user
456
+ // message, before the text, like a pinned one.
457
+ const looseUserPrepend = entries.filter((e) => e.placement === "user-prepend" && e.pin === undefined);
409
458
 
410
459
  const firstUserIndex = messages.findIndex(isUserLike);
411
460
  const lastUserIndex = messages.findLastIndex(isUserLike);
@@ -449,14 +498,18 @@ export function injectReminders(messages: AgentMessage[], reminders: Array<strin
449
498
  }
450
499
 
451
500
  push(after, tailIndex === -1 ? firstUserIndex : tailIndex, lastAppend.map(reminderBlock));
501
+ if (lastUserIndex !== -1) push(before, lastUserIndex, looseUserPrepend.map(reminderBlock));
452
502
 
453
503
  // Pinned one-shots ride the exact message they first landed on; a message
454
- // compacted away simply no longer carries its pin.
504
+ // compacted away simply no longer carries its pin. A pinned breadcrumb sits
505
+ // BEFORE that message's text (after the first-prepend stack, which was
506
+ // pushed first); every other pin sits after it.
455
507
  if (pinnedEntries.length > 0) {
456
508
  const locate = pinLocator(messages);
457
509
  for (const entry of pinnedEntries) {
458
510
  const index = locate(entry.pin as PinAnchor);
459
- if (index !== -1) push(after, index, [reminderBlock(entry)]);
511
+ if (index === -1) continue;
512
+ push(entry.placement === "user-prepend" ? before : after, index, [reminderBlock(entry)]);
460
513
  }
461
514
  }
462
515