one-code-extension 0.3.1 → 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.
- package/README.md +88 -10
- package/docs/guide/README.md +1 -0
- package/docs/guide/configuration.md +2 -2
- package/docs/guide/differences-from-claude-code.md +13 -4
- package/docs/guide/hooks.md +14 -2
- package/docs/guide/installation.md +54 -6
- package/docs/guide/permissions-modes-and-auto-mode.md +23 -7
- package/docs/guide/reference.md +5 -2
- package/docs/guide/sessions-and-context.md +18 -0
- package/docs/guide/terminal-interface.md +1 -1
- package/docs/guide/tools.md +24 -2
- package/docs/guide/troubleshooting.md +14 -4
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
package/extensions/lib/paths.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* other's. See "Own state, borrowed config" in docs/decisions.md.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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.
|
|
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
|
|
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
|
-
/** `
|
|
27
|
-
export
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
this.nextTurn =
|
|
218
|
-
return
|
|
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
|
|
223
|
-
* on this request — so every later request re-attaches them there
|
|
224
|
-
* Called by the owner at `context` time, after it has decided the
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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. `
|
|
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
|
|
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.
|
|
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
|
|
511
|
+
if (index === -1) continue;
|
|
512
|
+
push(entry.placement === "user-prepend" ? before : after, index, [reminderBlock(entry)]);
|
|
460
513
|
}
|
|
461
514
|
}
|
|
462
515
|
|