dsh-context-mode 0.1.3 → 0.2.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/LICENSING.md +37 -0
- package/README.md +39 -13
- package/lib/types/cjk.d.ts +54 -0
- package/lib/types/cjk.d.ts.map +1 -0
- package/lib/types/cjk.js +64 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +65 -21
- package/lib/types/output-containment.d.ts +35 -0
- package/lib/types/output-containment.d.ts.map +1 -0
- package/lib/types/output-containment.js +103 -0
- package/lib/types/routing.d.ts +3 -1
- package/lib/types/routing.d.ts.map +1 -1
- package/lib/types/routing.js +81 -6
- package/package.json +9 -5
- package/skills/context-mode/SKILL.md +104 -11
- package/vendor/context-mode/LICENSE +94 -0
- package/vendor/context-mode/server.bundle.mjs +1126 -0
- package/vendor/context-mode/src/cli.ts +2040 -0
- package/vendor/context-mode/src/db-base.ts +617 -0
- package/vendor/context-mode/src/executor.ts +785 -0
- package/vendor/context-mode/src/exit-classify.ts +33 -0
- package/vendor/context-mode/src/fetch-cache.ts +15 -0
- package/vendor/context-mode/src/lifecycle.ts +305 -0
- package/vendor/context-mode/src/platform/client-map.ts +45 -0
- package/vendor/context-mode/src/platform/detect.ts +645 -0
- package/vendor/context-mode/src/platform/dsh.ts +206 -0
- package/vendor/context-mode/src/platform/types.ts +503 -0
- package/vendor/context-mode/src/runPool.ts +81 -0
- package/vendor/context-mode/src/runtime.ts +765 -0
- package/vendor/context-mode/src/search/auto-memory.ts +200 -0
- package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
- package/vendor/context-mode/src/search/flood-guard.ts +111 -0
- package/vendor/context-mode/src/search/unified.ts +176 -0
- package/vendor/context-mode/src/security.ts +889 -0
- package/vendor/context-mode/src/server.ts +4991 -0
- package/vendor/context-mode/src/session/analytics.ts +3085 -0
- package/vendor/context-mode/src/session/db.ts +1726 -0
- package/vendor/context-mode/src/session/error-classifier.ts +392 -0
- package/vendor/context-mode/src/session/event-emit.ts +132 -0
- package/vendor/context-mode/src/session/extract.ts +2958 -0
- package/vendor/context-mode/src/session/index.ts +130 -0
- package/vendor/context-mode/src/session/model-prices.json +429 -0
- package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
- package/vendor/context-mode/src/session/pricing.ts +191 -0
- package/vendor/context-mode/src/session/project-attribution.ts +309 -0
- package/vendor/context-mode/src/session/purge.ts +338 -0
- package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
- package/vendor/context-mode/src/session/snapshot.ts +577 -0
- package/vendor/context-mode/src/store-directory.ts +290 -0
- package/vendor/context-mode/src/store.ts +2071 -0
- package/vendor/context-mode/src/truncate.ts +154 -0
- package/vendor/context-mode/src/types.ts +147 -0
- package/vendor/context-mode/src/util/claude-config.ts +95 -0
- package/vendor/context-mode/src/util/hook-config.ts +78 -0
- package/vendor/context-mode/src/util/jsonc.ts +70 -0
- package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
- package/vendor/context-mode/src/util/project-dir.ts +347 -0
- package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/types — Platform adapter interface for multi-platform hook support.
|
|
3
|
+
*
|
|
4
|
+
* Defines the contract that each platform adapter must implement.
|
|
5
|
+
* Three paradigms exist across supported platforms:
|
|
6
|
+
* A) JSON stdin/stdout — Claude Code, Gemini/Qwen family CLIs, Copilot/Codex/Kimi,
|
|
7
|
+
* Cursor, Kiro, Antigravity CLI (`agy`)
|
|
8
|
+
* B) TS Plugin Functions — OpenCode, KiloCode, OpenClaw
|
|
9
|
+
* C) MCP-only (no hooks) — Antigravity IDE, Zed, Pi/OMP MCP-only paths
|
|
10
|
+
*
|
|
11
|
+
* The MCP server layer is 100% portable and needs no adapter.
|
|
12
|
+
* Only the hook layer requires platform-specific adapters.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ─────────────────────────────────────────────────────────
|
|
16
|
+
// Hook paradigm
|
|
17
|
+
// ─────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
import { resolveHookRuntime } from "../runtime.js";
|
|
20
|
+
|
|
21
|
+
export type HookParadigm = "json-stdio" | "ts-plugin" | "mcp-only";
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────
|
|
24
|
+
// Platform capabilities
|
|
25
|
+
// ─────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface PlatformCapabilities {
|
|
28
|
+
/** Platform supports PreToolUse / BeforeTool / tool.execute.before hooks. */
|
|
29
|
+
preToolUse: boolean;
|
|
30
|
+
/** Platform supports PostToolUse / AfterTool / tool.execute.after hooks. */
|
|
31
|
+
postToolUse: boolean;
|
|
32
|
+
/** Platform supports PreCompact / PreCompress / session.compacting hooks. */
|
|
33
|
+
preCompact: boolean;
|
|
34
|
+
/** Platform supports SessionStart / session.created hooks. */
|
|
35
|
+
sessionStart: boolean;
|
|
36
|
+
/** Platform allows modifying tool input arguments via hooks. */
|
|
37
|
+
canModifyArgs: boolean;
|
|
38
|
+
/** Platform allows modifying tool output via PostToolUse hooks. */
|
|
39
|
+
canModifyOutput: boolean;
|
|
40
|
+
/** Platform allows injecting context during session start or compaction. */
|
|
41
|
+
canInjectSessionContext: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─────────────────────────────────────────────────────────
|
|
45
|
+
// Normalized hook event types
|
|
46
|
+
// ─────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/** Normalized PreToolUse event — platform-agnostic representation. */
|
|
49
|
+
export interface PreToolUseEvent {
|
|
50
|
+
/** Tool name being invoked (e.g., "Bash", "Read", "WebFetch"). */
|
|
51
|
+
toolName: string;
|
|
52
|
+
/** Tool input arguments as key-value pairs. */
|
|
53
|
+
toolInput: Record<string, unknown>;
|
|
54
|
+
/** Session ID extracted by the adapter. */
|
|
55
|
+
sessionId: string;
|
|
56
|
+
/** Project directory (if available). */
|
|
57
|
+
projectDir?: string;
|
|
58
|
+
/** Raw platform-specific input (for passthrough if needed). */
|
|
59
|
+
raw: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalized PostToolUse event — platform-agnostic representation. */
|
|
63
|
+
export interface PostToolUseEvent {
|
|
64
|
+
/** Tool name that was invoked. */
|
|
65
|
+
toolName: string;
|
|
66
|
+
/** Tool input arguments. */
|
|
67
|
+
toolInput: Record<string, unknown>;
|
|
68
|
+
/** Tool output/response (if available). */
|
|
69
|
+
toolOutput?: string;
|
|
70
|
+
/** Whether the tool call resulted in an error. */
|
|
71
|
+
isError?: boolean;
|
|
72
|
+
/** Session ID extracted by the adapter. */
|
|
73
|
+
sessionId: string;
|
|
74
|
+
/** Project directory (if available). */
|
|
75
|
+
projectDir?: string;
|
|
76
|
+
/** Raw platform-specific input. */
|
|
77
|
+
raw: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Normalized PreCompact event. */
|
|
81
|
+
export interface PreCompactEvent {
|
|
82
|
+
/** Session ID. */
|
|
83
|
+
sessionId: string;
|
|
84
|
+
/** Project directory (if available). */
|
|
85
|
+
projectDir?: string;
|
|
86
|
+
/** Raw platform-specific input. */
|
|
87
|
+
raw: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Normalized SessionStart event. */
|
|
91
|
+
export interface SessionStartEvent {
|
|
92
|
+
/** Session ID. */
|
|
93
|
+
sessionId: string;
|
|
94
|
+
/** Lifecycle source: fresh start, compaction, resume, or clear. */
|
|
95
|
+
source: "startup" | "compact" | "resume" | "clear";
|
|
96
|
+
/** Project directory (if available). */
|
|
97
|
+
projectDir?: string;
|
|
98
|
+
/** Raw platform-specific input. */
|
|
99
|
+
raw: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─────────────────────────────────────────────────────────
|
|
103
|
+
// Hook response types
|
|
104
|
+
// ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Response from PreToolUse hook — can block, modify, inject context, or pass through. */
|
|
107
|
+
export interface PreToolUseResponse {
|
|
108
|
+
/**
|
|
109
|
+
* "allow" = pass through (no action)
|
|
110
|
+
* "deny" = block tool execution
|
|
111
|
+
* "modify" = change input args
|
|
112
|
+
* "context" = inject additional context (soft guidance)
|
|
113
|
+
* "ask" = prompt user for confirmation (security policy match)
|
|
114
|
+
*/
|
|
115
|
+
decision: "allow" | "deny" | "modify" | "context" | "ask";
|
|
116
|
+
/** Reason for denial (shown to the model). */
|
|
117
|
+
reason?: string;
|
|
118
|
+
/** Modified tool input (only when decision = "modify"). */
|
|
119
|
+
updatedInput?: Record<string, unknown>;
|
|
120
|
+
/** Additional context to inject (only when decision = "context"). */
|
|
121
|
+
additionalContext?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Response from PostToolUse hook — can inject context or modify output. */
|
|
125
|
+
export interface PostToolUseResponse {
|
|
126
|
+
/** Additional context to inject after tool output. */
|
|
127
|
+
additionalContext?: string;
|
|
128
|
+
/** Modified tool output (if platform supports it). */
|
|
129
|
+
updatedOutput?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Response from PreCompact hook — injects context before compaction. */
|
|
133
|
+
export interface PreCompactResponse {
|
|
134
|
+
/** Context to preserve across compaction. */
|
|
135
|
+
context?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Response from SessionStart hook — injects context at session start. */
|
|
139
|
+
export interface SessionStartResponse {
|
|
140
|
+
/** Context to inject at session start. */
|
|
141
|
+
context?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─────────────────────────────────────────────────────────
|
|
145
|
+
// Hook config types
|
|
146
|
+
// ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** A single hook entry in platform configuration. */
|
|
149
|
+
export interface HookEntry {
|
|
150
|
+
/** Tool matcher pattern (empty = match all). */
|
|
151
|
+
matcher: string;
|
|
152
|
+
/** Hook commands/handlers to execute. */
|
|
153
|
+
hooks: Array<{
|
|
154
|
+
type: string;
|
|
155
|
+
command: string;
|
|
156
|
+
}>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Hook registration map — maps hook types to their entries. */
|
|
160
|
+
export type HookRegistration = Record<string, HookEntry[]>;
|
|
161
|
+
|
|
162
|
+
// ─────────────────────────────────────────────────────────
|
|
163
|
+
// Adapter interface
|
|
164
|
+
// ─────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* HookAdapter — contract for platform-specific hook implementations.
|
|
168
|
+
*
|
|
169
|
+
* Each supported platform (Claude Code, Gemini CLI, OpenCode, etc.)
|
|
170
|
+
* provides an adapter that normalizes its hook I/O into a common format.
|
|
171
|
+
*/
|
|
172
|
+
export interface HookAdapter {
|
|
173
|
+
/** Human-readable platform name (e.g., "Claude Code", "Gemini CLI"). */
|
|
174
|
+
readonly name: string;
|
|
175
|
+
|
|
176
|
+
/** Hook I/O paradigm used by this platform. */
|
|
177
|
+
readonly paradigm: HookParadigm;
|
|
178
|
+
|
|
179
|
+
/** What this platform supports. */
|
|
180
|
+
readonly capabilities: PlatformCapabilities;
|
|
181
|
+
|
|
182
|
+
// ── Input parsing ──────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/** Parse raw PreToolUse input into normalized form. */
|
|
185
|
+
parsePreToolUseInput(raw: unknown): PreToolUseEvent;
|
|
186
|
+
|
|
187
|
+
/** Parse raw PostToolUse input into normalized form. */
|
|
188
|
+
parsePostToolUseInput(raw: unknown): PostToolUseEvent;
|
|
189
|
+
|
|
190
|
+
/** Parse raw PreCompact input (optional — not all platforms support it). */
|
|
191
|
+
parsePreCompactInput?(raw: unknown): PreCompactEvent;
|
|
192
|
+
|
|
193
|
+
/** Parse raw SessionStart input (optional — not all platforms support it). */
|
|
194
|
+
parseSessionStartInput?(raw: unknown): SessionStartEvent;
|
|
195
|
+
|
|
196
|
+
// ── Response formatting ────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** Format a PreToolUse response into platform-specific output. */
|
|
199
|
+
formatPreToolUseResponse(response: PreToolUseResponse): unknown;
|
|
200
|
+
|
|
201
|
+
/** Format a PostToolUse response into platform-specific output. */
|
|
202
|
+
formatPostToolUseResponse(response: PostToolUseResponse): unknown;
|
|
203
|
+
|
|
204
|
+
/** Format a PreCompact response into platform-specific output. */
|
|
205
|
+
formatPreCompactResponse?(response: PreCompactResponse): unknown;
|
|
206
|
+
|
|
207
|
+
/** Format a SessionStart response into platform-specific output. */
|
|
208
|
+
formatSessionStartResponse?(response: SessionStartResponse): unknown;
|
|
209
|
+
|
|
210
|
+
// ── Configuration ──────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/** Path to the platform's settings file (e.g., ~/.claude/settings.json). */
|
|
213
|
+
getSettingsPath(): string;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Directory where session data is stored.
|
|
217
|
+
*
|
|
218
|
+
* NOTE — C2 narrowing (2026-05): this is the ONLY storage-path concern an
|
|
219
|
+
* adapter exposes. Per-project DB paths are derived by callers via
|
|
220
|
+
* `resolveSessionDbPath({ projectDir, sessionsDir: adapter.getSessionDir() })`
|
|
221
|
+
* (see `src/session/db.ts`). Per-project events.md paths follow the same
|
|
222
|
+
* `<sessionDir>/<hash><suffix>-events.md` shape and are computed inline at
|
|
223
|
+
* the small number of call sites that need them (server.ts, hooks).
|
|
224
|
+
*/
|
|
225
|
+
getSessionDir(): string;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Platform config directory.
|
|
229
|
+
*
|
|
230
|
+
* Contract: ALWAYS returns an absolute path. Never returns a relative
|
|
231
|
+
* segment, never returns an empty string. This eliminates the leaky-seam
|
|
232
|
+
* where callers could not tell whether the return needed further resolution.
|
|
233
|
+
*
|
|
234
|
+
* Resolution rules:
|
|
235
|
+
* - Home-rooted platforms (claude-code, codex, qwen, gemini, antigravity,
|
|
236
|
+
* zed, opencode, …) return paths under `homedir()` / XDG / APPDATA.
|
|
237
|
+
* - Project-scoped platforms (cursor → `.cursor`, vscode-copilot &
|
|
238
|
+
* jetbrains-copilot → `.github`, kiro → `.kiro`, openclaw → project root)
|
|
239
|
+
* resolve their segment against the supplied `projectDir`. When
|
|
240
|
+
* `projectDir` is omitted, `process.cwd()` is used as the fallback.
|
|
241
|
+
*
|
|
242
|
+
* @param projectDir Optional project root used to resolve project-scoped
|
|
243
|
+
* adapters. Ignored by home-rooted adapters.
|
|
244
|
+
*/
|
|
245
|
+
getConfigDir(projectDir?: string): string;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Names of platform-native instruction/rule files that act as the
|
|
249
|
+
* project's "user CLAUDE.md equivalent" (e.g., ["CLAUDE.md"],
|
|
250
|
+
* ["AGENTS.md"], ["GEMINI.md"]). Auto-memory scans for these in the
|
|
251
|
+
* project root and config dir, and rule-detection emits "rule" events
|
|
252
|
+
* when they are read.
|
|
253
|
+
*/
|
|
254
|
+
getInstructionFiles(): string[];
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Directory where persistent per-user memory is stored
|
|
258
|
+
* (e.g., ~/.claude/memory, ~/.codex/memories). Auto-memory scans
|
|
259
|
+
* *.md files in this directory.
|
|
260
|
+
*
|
|
261
|
+
* When `projectDir` is supplied, the path MUST be project-scoped (issue
|
|
262
|
+
* #663) so two projects running in parallel cannot read each other's
|
|
263
|
+
* memory. Adapters scope via `hashProjectDirCanonical(projectDir)`.
|
|
264
|
+
* Callers that pre-date this contract may omit `projectDir`; in that
|
|
265
|
+
* case the unscoped legacy path is returned.
|
|
266
|
+
*/
|
|
267
|
+
getMemoryDir(projectDir?: string): string;
|
|
268
|
+
|
|
269
|
+
/** Generate hook registration config for this platform. */
|
|
270
|
+
generateHookConfig(pluginRoot: string): HookRegistration;
|
|
271
|
+
|
|
272
|
+
/** Read current platform settings. */
|
|
273
|
+
readSettings(): Record<string, unknown> | null;
|
|
274
|
+
|
|
275
|
+
/** Write platform settings. */
|
|
276
|
+
writeSettings(settings: Record<string, unknown>): void;
|
|
277
|
+
|
|
278
|
+
// ── Diagnostics (doctor) ───────────────────────────────
|
|
279
|
+
|
|
280
|
+
/** Validate that hooks are properly configured for this platform. */
|
|
281
|
+
validateHooks(pluginRoot: string): DiagnosticResult[];
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Adapter-defined per-platform health checks (Algo-D1).
|
|
285
|
+
*
|
|
286
|
+
* OPTIONAL. Adapters that don't override return nothing — they don't
|
|
287
|
+
* have this class of check today. claude-code overrides with hook-script
|
|
288
|
+
* existence checks that join `pluginRoot + scriptName` directly via
|
|
289
|
+
* `existsSync`, so doctor never round-trips through a regex on a hook
|
|
290
|
+
* command (the #548 root cause).
|
|
291
|
+
*
|
|
292
|
+
* Adapter #16 with hook scripts inherits the contract by overriding;
|
|
293
|
+
* adapter #17 without hook scripts simply doesn't override. The doctor
|
|
294
|
+
* iterates `adapter.getHealthChecks?.(pluginRoot) ?? []` and renders
|
|
295
|
+
* each — no per-adapter wiring in the doctor body.
|
|
296
|
+
*/
|
|
297
|
+
getHealthChecks?(pluginRoot: string): readonly HealthCheck[];
|
|
298
|
+
|
|
299
|
+
/** Check if the plugin is registered/enabled on this platform. */
|
|
300
|
+
checkPluginRegistration(): DiagnosticResult;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get the installed version from this platform's registry/marketplace, or
|
|
304
|
+
* "standalone" when no platform-owned plugin version exists.
|
|
305
|
+
*/
|
|
306
|
+
getInstalledVersion(): string;
|
|
307
|
+
|
|
308
|
+
// ── Upgrade ────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** Configure all hooks for this platform. Returns change descriptions. */
|
|
311
|
+
configureAllHooks(pluginRoot: string): string[];
|
|
312
|
+
|
|
313
|
+
/** Backup platform settings before modification. Returns backup path or null. */
|
|
314
|
+
backupSettings(): string | null;
|
|
315
|
+
|
|
316
|
+
/** Set executable permissions on hook scripts. Returns paths that were set. */
|
|
317
|
+
setHookPermissions(pluginRoot: string): string[];
|
|
318
|
+
|
|
319
|
+
/** Update platform's plugin registry to point to given path and version. */
|
|
320
|
+
updatePluginRegistry(pluginRoot: string, version: string): void;
|
|
321
|
+
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─────────────────────────────────────────────────────────
|
|
325
|
+
// Diagnostic result
|
|
326
|
+
// ─────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/** Result from a platform-specific diagnostic check. */
|
|
329
|
+
export interface DiagnosticResult {
|
|
330
|
+
/** What was checked. */
|
|
331
|
+
check: string;
|
|
332
|
+
/** Pass, fail, or warning. */
|
|
333
|
+
status: "pass" | "fail" | "warn";
|
|
334
|
+
/** Human-readable message. */
|
|
335
|
+
message: string;
|
|
336
|
+
/** Suggested fix command (if applicable). */
|
|
337
|
+
fix?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Adapter-defined health check (Algo-D1).
|
|
342
|
+
*
|
|
343
|
+
* Lighter-weight than `DiagnosticResult`: adapters declare a name and a
|
|
344
|
+
* synchronous `check()` thunk. The doctor renders the result. The
|
|
345
|
+
* thunk-style intentionally avoids forcing adapters into async — the
|
|
346
|
+
* existsSync probe used by claude-code is sync and the doctor invokes it
|
|
347
|
+
* directly without an `await`. Adapters needing async work return a
|
|
348
|
+
* pre-resolved status (the check ran at thunk-creation time) or extend
|
|
349
|
+
* `validateHooks()` instead.
|
|
350
|
+
*/
|
|
351
|
+
export interface HealthCheck {
|
|
352
|
+
/** Human-readable check title (e.g. "Hook script exists: pretooluse.mjs"). */
|
|
353
|
+
readonly name: string;
|
|
354
|
+
/** Synchronous check thunk. Returns OK or FAIL with optional detail. */
|
|
355
|
+
check(): { status: "OK" | "FAIL"; detail?: string };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ─────────────────────────────────────────────────────────
|
|
359
|
+
// Platform detection
|
|
360
|
+
// ─────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
// ─────────────────────────────────────────────────────────
|
|
363
|
+
// Cross-platform command helpers (#369, #372)
|
|
364
|
+
// ─────────────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Build a cross-platform `node <script>` command string.
|
|
368
|
+
*
|
|
369
|
+
* Fixes two Windows bugs:
|
|
370
|
+
* #369 — Bare `node` fails on Windows Git Bash (MSYS) because PATH
|
|
371
|
+
* resolution is unreliable. Uses `process.execPath` instead.
|
|
372
|
+
* #372 — MSYS rewrites absolute paths on non-C: drives (e.g.
|
|
373
|
+
* `C:\Users\...` → `D:\c\Users\...`). Forward slashes +
|
|
374
|
+
* double-quoting prevents the translation.
|
|
375
|
+
*
|
|
376
|
+
* Safe on macOS/Linux — quoting and forward slashes are no-ops there.
|
|
377
|
+
*/
|
|
378
|
+
export function buildNodeCommand(
|
|
379
|
+
scriptPath: string,
|
|
380
|
+
opts?: { platform?: string; jsRuntime?: string },
|
|
381
|
+
): string {
|
|
382
|
+
let nodePath = process.execPath.replace(/\\/g, "/");
|
|
383
|
+
if (isInProcessPluginPlatform(opts?.platform)) {
|
|
384
|
+
const base = nodePath.split("/").pop()!.replace(/\.exe$/i, "");
|
|
385
|
+
if (!JS_RUNTIMES.has(base)) {
|
|
386
|
+
nodePath = opts?.jsRuntime?.replace(/\\/g, "/") ?? "node";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const safePath = scriptPath.replace(/\\/g, "/");
|
|
390
|
+
return `"${nodePath}" "${safePath}"`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Build a cross-platform hook spawn command using the resolved JS runtime
|
|
395
|
+
* (issue #738). Identical wire-format to {@link buildNodeCommand} — two
|
|
396
|
+
* double-quoted, forward-slashed tokens separated by whitespace — so it
|
|
397
|
+
* round-trips through {@link parseNodeCommand} unchanged.
|
|
398
|
+
*
|
|
399
|
+
* The only difference is the runtime path: when a Bun ≥1.0 install is
|
|
400
|
+
* detected at process start, that path is used in place of `process.execPath`.
|
|
401
|
+
* Hooks run end-to-end in pure JS (no native modules) so swapping the
|
|
402
|
+
* runtime is a no-op for output but cuts ~40-60ms of Node cold-start per
|
|
403
|
+
* tool call.
|
|
404
|
+
*
|
|
405
|
+
* Why a SEPARATE helper instead of repurposing {@link buildNodeCommand}:
|
|
406
|
+
* `buildNodeCommand` is also called by openclaw plugin (doctor / upgrade
|
|
407
|
+
* command suggestions in `src/adapters/openclaw/plugin.ts`). Those CLI
|
|
408
|
+
* targets MUST stay on Node because they load better-sqlite3, which has
|
|
409
|
+
* no Bun-compatible prebuild yet (#543). Keeping the two helpers separate
|
|
410
|
+
* makes the audit trivial: anything emitting a hook spawn command uses
|
|
411
|
+
* `buildHookRuntimeCommand`; anything emitting a user-visible CLI command
|
|
412
|
+
* stays on `buildNodeCommand`.
|
|
413
|
+
*
|
|
414
|
+
* `opts.platform` is forwarded to {@link isInProcessPluginPlatform} so the
|
|
415
|
+
* existing opencode/kilo in-process JS-runtime substitution still works
|
|
416
|
+
* (those platforms inject their own runtime via `opts.jsRuntime`).
|
|
417
|
+
*/
|
|
418
|
+
export function buildHookRuntimeCommand(
|
|
419
|
+
scriptPath: string,
|
|
420
|
+
opts?: { platform?: string; jsRuntime?: string },
|
|
421
|
+
): string {
|
|
422
|
+
// In-process plugin platforms (opencode/kilo) inject their own runtime —
|
|
423
|
+
// delegate to buildNodeCommand which already handles that special case.
|
|
424
|
+
if (isInProcessPluginPlatform(opts?.platform)) {
|
|
425
|
+
return buildNodeCommand(scriptPath, opts);
|
|
426
|
+
}
|
|
427
|
+
const runtime = resolveHookRuntime();
|
|
428
|
+
const runtimePath = runtime.path.replace(/\\/g, "/");
|
|
429
|
+
const safePath = scriptPath.replace(/\\/g, "/");
|
|
430
|
+
return `"${runtimePath}" "${safePath}"`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Strict inverse of `buildNodeCommand`.
|
|
435
|
+
*
|
|
436
|
+
* Returns `{ nodePath, scriptPath }` ONLY when `cmd` could have been
|
|
437
|
+
* produced by `buildNodeCommand` — i.e. exactly two double-quoted args
|
|
438
|
+
* separated by whitespace. Anything else (bare `node …`, single quotes,
|
|
439
|
+
* unquoted ambiguous input, CLI dispatcher entries) returns `null`.
|
|
440
|
+
*
|
|
441
|
+
* Why strict: the legacy `\S+\.mjs` fallback in
|
|
442
|
+
* `src/util/hook-config.ts:24` and the two-step regex in
|
|
443
|
+
* `src/adapters/claude-code/hooks.ts:178` silently grabbed the path tail
|
|
444
|
+
* after the last whitespace whenever the host wire-format dropped quotes,
|
|
445
|
+
* producing the #548 doubled-path FAIL when `pluginRoot` contained
|
|
446
|
+
* spaces (e.g. `C:\Users\High Ground Services\…`). A canonical inverse
|
|
447
|
+
* lets every emit (`buildNodeCommand`) round-trip through every parse
|
|
448
|
+
* (`parseNodeCommand`) without inventing fallbacks. Adapter #16 inherits
|
|
449
|
+
* the contract by importing one module.
|
|
450
|
+
*/
|
|
451
|
+
export function parseNodeCommand(
|
|
452
|
+
cmd: string,
|
|
453
|
+
): { nodePath: string; scriptPath: string } | null {
|
|
454
|
+
if (typeof cmd !== "string" || cmd.length === 0) return null;
|
|
455
|
+
// Match `"<nodePath>" "<scriptPath>"` with arbitrary whitespace
|
|
456
|
+
// separator. Both segments must be non-empty and contain no embedded
|
|
457
|
+
// double quotes — buildNodeCommand never emits embedded quotes.
|
|
458
|
+
const m = cmd.match(/^"([^"]+)"\s+"([^"]+)"\s*$/);
|
|
459
|
+
if (!m) return null;
|
|
460
|
+
return { nodePath: m[1], scriptPath: m[2] };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Known JS runtime binary names (base filename without extension). */
|
|
464
|
+
export const JS_RUNTIMES: ReadonlySet<string> = new Set(["node", "bun", "deno"]);
|
|
465
|
+
|
|
466
|
+
/** Platforms where context-mode runs as an in-process TS plugin (not MCP stdio). */
|
|
467
|
+
export const IN_PROCESS_PLUGIN_PLATFORMS: ReadonlySet<string> = new Set(["opencode", "kilo"]);
|
|
468
|
+
|
|
469
|
+
export function isInProcessPluginPlatform(p: string | undefined): boolean {
|
|
470
|
+
return !!p && IN_PROCESS_PLUGIN_PLATFORMS.has(p);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Supported platform identifiers. */
|
|
474
|
+
export type PlatformId =
|
|
475
|
+
| "claude-code"
|
|
476
|
+
| "gemini-cli"
|
|
477
|
+
| "opencode"
|
|
478
|
+
| "kilo"
|
|
479
|
+
| "openclaw"
|
|
480
|
+
| "codex"
|
|
481
|
+
| "vscode-copilot"
|
|
482
|
+
| "jetbrains-copilot"
|
|
483
|
+
| "copilot-cli"
|
|
484
|
+
| "cursor"
|
|
485
|
+
| "antigravity"
|
|
486
|
+
| "antigravity-cli"
|
|
487
|
+
| "kiro"
|
|
488
|
+
| "pi"
|
|
489
|
+
| "omp"
|
|
490
|
+
| "kimi"
|
|
491
|
+
| "zed"
|
|
492
|
+
| "qwen-code"
|
|
493
|
+
| "unknown";
|
|
494
|
+
|
|
495
|
+
/** Detection signal used to identify which platform is running. */
|
|
496
|
+
export interface DetectionSignal {
|
|
497
|
+
/** Platform identifier. */
|
|
498
|
+
platform: PlatformId;
|
|
499
|
+
/** Confidence: env var match > config dir match > fallback. */
|
|
500
|
+
confidence: "high" | "medium" | "low";
|
|
501
|
+
/** How it was detected. */
|
|
502
|
+
reason: string;
|
|
503
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic in-flight-capped worker pool.
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - runBatchCommands (ctx_batch_execute parallel branch)
|
|
6
|
+
* - runBatchFetch (ctx_fetch_and_index batch path)
|
|
7
|
+
*
|
|
8
|
+
* Returns Promise.allSettled-style results so one job's throw cannot
|
|
9
|
+
* strand siblings. Caller maps fulfilled/rejected per index. Output
|
|
10
|
+
* order is preserved by input index (not completion order).
|
|
11
|
+
*
|
|
12
|
+
* Designed to be the SINGLE concurrency primitive for the project —
|
|
13
|
+
* all "run N independent operations with at most M in flight" needs
|
|
14
|
+
* route here. Avoids the worker-pool copy-paste flagged in the
|
|
15
|
+
* concurrency PRD architectural review (finding G).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { cpus } from "node:os";
|
|
19
|
+
|
|
20
|
+
export interface PoolJob<T> {
|
|
21
|
+
run(): Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunPoolOptions {
|
|
25
|
+
/** Hard concurrency cap (1-N). Auto-clamped to job count. */
|
|
26
|
+
concurrency: number;
|
|
27
|
+
/** Optional: also clamp by `os.cpus().length` (memory-pressure safety). Default false. */
|
|
28
|
+
capByCpuCount?: boolean;
|
|
29
|
+
/** Optional: per-settled callback (e.g. for progress reporting / metrics). */
|
|
30
|
+
onSettled?: (idx: number, result: PromiseSettledResult<unknown>) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RunPoolResult<T> {
|
|
34
|
+
/** Per-index settled result, ordered by input index. */
|
|
35
|
+
settled: PromiseSettledResult<T>[];
|
|
36
|
+
/** Concurrency actually used after all caps applied. */
|
|
37
|
+
effectiveConcurrency: number;
|
|
38
|
+
/** True when effectiveConcurrency < requested concurrency. */
|
|
39
|
+
capped: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runPool<T>(
|
|
43
|
+
jobs: PoolJob<T>[],
|
|
44
|
+
opts: RunPoolOptions,
|
|
45
|
+
): Promise<RunPoolResult<T>> {
|
|
46
|
+
const { concurrency, capByCpuCount = false, onSettled } = opts;
|
|
47
|
+
|
|
48
|
+
if (jobs.length === 0) {
|
|
49
|
+
return { settled: [], effectiveConcurrency: 0, capped: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const requested = Math.max(1, concurrency);
|
|
53
|
+
const cpuCap = capByCpuCount ? Math.max(1, cpus().length) : requested;
|
|
54
|
+
const effectiveConcurrency = Math.min(requested, cpuCap, jobs.length);
|
|
55
|
+
const capped = effectiveConcurrency < requested;
|
|
56
|
+
|
|
57
|
+
const settled: PromiseSettledResult<T>[] = new Array(jobs.length);
|
|
58
|
+
let nextIdx = 0;
|
|
59
|
+
|
|
60
|
+
async function worker(): Promise<void> {
|
|
61
|
+
while (true) {
|
|
62
|
+
const idx = nextIdx++;
|
|
63
|
+
if (idx >= jobs.length) return;
|
|
64
|
+
try {
|
|
65
|
+
const value = await jobs[idx].run();
|
|
66
|
+
settled[idx] = { status: "fulfilled", value };
|
|
67
|
+
} catch (err) {
|
|
68
|
+
settled[idx] = { status: "rejected", reason: err };
|
|
69
|
+
}
|
|
70
|
+
onSettled?.(idx, settled[idx]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const workers: Promise<void>[] = [];
|
|
75
|
+
for (let w = 0; w < effectiveConcurrency; w++) workers.push(worker());
|
|
76
|
+
// allSettled defends against any promise rejection escaping a worker
|
|
77
|
+
// (the worker already swallows its own errors, but this is belt-and-braces).
|
|
78
|
+
await Promise.allSettled(workers);
|
|
79
|
+
|
|
80
|
+
return { settled, effectiveConcurrency, capped };
|
|
81
|
+
}
|