@agentex/agent 0.0.23 → 0.0.26

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 (177) hide show
  1. package/CHANGELOG.md +338 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/dist/derived.d.ts +5 -3
  5. package/dist/derived.d.ts.map +1 -1
  6. package/dist/derived.js +11 -7
  7. package/dist/derived.js.map +1 -1
  8. package/dist/index.d.ts +7 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +4 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/providers/acp/index.d.ts +1 -1
  13. package/dist/providers/acp/index.d.ts.map +1 -1
  14. package/dist/providers/acp/index.js +5 -97
  15. package/dist/providers/acp/index.js.map +1 -1
  16. package/dist/providers/acp/session.d.ts +8 -1
  17. package/dist/providers/acp/session.d.ts.map +1 -1
  18. package/dist/providers/acp/session.js +94 -0
  19. package/dist/providers/acp/session.js.map +1 -1
  20. package/dist/providers/claude/attach.d.ts +8 -0
  21. package/dist/providers/claude/attach.d.ts.map +1 -0
  22. package/dist/providers/claude/attach.js +113 -0
  23. package/dist/providers/claude/attach.js.map +1 -0
  24. package/dist/providers/claude/execute.d.ts.map +1 -1
  25. package/dist/providers/claude/execute.js +17 -2
  26. package/dist/providers/claude/execute.js.map +1 -1
  27. package/dist/providers/claude/goal-capability.d.ts +15 -0
  28. package/dist/providers/claude/goal-capability.d.ts.map +1 -0
  29. package/dist/providers/claude/goal-capability.js +20 -0
  30. package/dist/providers/claude/goal-capability.js.map +1 -0
  31. package/dist/providers/claude/index.d.ts.map +1 -1
  32. package/dist/providers/claude/index.js +8 -4
  33. package/dist/providers/claude/index.js.map +1 -1
  34. package/dist/providers/claude/session.d.ts +11 -9
  35. package/dist/providers/claude/session.d.ts.map +1 -1
  36. package/dist/providers/claude/session.js +36 -14
  37. package/dist/providers/claude/session.js.map +1 -1
  38. package/dist/providers/codex/attach.d.ts +9 -0
  39. package/dist/providers/codex/attach.d.ts.map +1 -0
  40. package/dist/providers/codex/attach.js +93 -0
  41. package/dist/providers/codex/attach.js.map +1 -0
  42. package/dist/providers/codex/execute.d.ts.map +1 -1
  43. package/dist/providers/codex/execute.js +17 -3
  44. package/dist/providers/codex/execute.js.map +1 -1
  45. package/dist/providers/codex/goal-capability.d.ts +13 -0
  46. package/dist/providers/codex/goal-capability.d.ts.map +1 -0
  47. package/dist/providers/codex/goal-capability.js +18 -0
  48. package/dist/providers/codex/goal-capability.js.map +1 -0
  49. package/dist/providers/codex/index.d.ts +1 -0
  50. package/dist/providers/codex/index.d.ts.map +1 -1
  51. package/dist/providers/codex/index.js +9 -6
  52. package/dist/providers/codex/index.js.map +1 -1
  53. package/dist/providers/codex/session.d.ts +11 -7
  54. package/dist/providers/codex/session.d.ts.map +1 -1
  55. package/dist/providers/codex/session.js +37 -12
  56. package/dist/providers/codex/session.js.map +1 -1
  57. package/dist/providers/codex/transcript-normalize.d.ts +28 -0
  58. package/dist/providers/codex/transcript-normalize.d.ts.map +1 -0
  59. package/dist/providers/codex/transcript-normalize.js +191 -0
  60. package/dist/providers/codex/transcript-normalize.js.map +1 -0
  61. package/dist/providers/cursor/index.d.ts.map +1 -1
  62. package/dist/providers/cursor/index.js +2 -2
  63. package/dist/providers/cursor/index.js.map +1 -1
  64. package/dist/providers/openclaw/index.d.ts.map +1 -1
  65. package/dist/providers/openclaw/index.js +2 -2
  66. package/dist/providers/openclaw/index.js.map +1 -1
  67. package/dist/providers/opencode/index.d.ts.map +1 -1
  68. package/dist/providers/opencode/index.js +3 -5
  69. package/dist/providers/opencode/index.js.map +1 -1
  70. package/dist/providers/pi/index.d.ts.map +1 -1
  71. package/dist/providers/pi/index.js +3 -5
  72. package/dist/providers/pi/index.js.map +1 -1
  73. package/dist/providers/process/index.d.ts.map +1 -1
  74. package/dist/providers/process/index.js +2 -2
  75. package/dist/providers/process/index.js.map +1 -1
  76. package/dist/registry.d.ts +0 -1
  77. package/dist/registry.d.ts.map +1 -1
  78. package/dist/registry.js +0 -4
  79. package/dist/registry.js.map +1 -1
  80. package/dist/sessions/index.d.ts +3 -0
  81. package/dist/sessions/index.d.ts.map +1 -0
  82. package/dist/sessions/index.js +2 -0
  83. package/dist/sessions/index.js.map +1 -0
  84. package/dist/sessions/record.d.ts +43 -0
  85. package/dist/sessions/record.d.ts.map +1 -0
  86. package/dist/sessions/record.js +85 -0
  87. package/dist/sessions/record.js.map +1 -0
  88. package/dist/types.d.ts +176 -0
  89. package/dist/types.d.ts.map +1 -1
  90. package/dist/types.js.map +1 -1
  91. package/dist/utils/endpoint.d.ts +38 -0
  92. package/dist/utils/endpoint.d.ts.map +1 -0
  93. package/dist/utils/endpoint.js +151 -0
  94. package/dist/utils/endpoint.js.map +1 -0
  95. package/dist/utils/env.d.ts.map +1 -1
  96. package/dist/utils/env.js +5 -1
  97. package/dist/utils/env.js.map +1 -1
  98. package/dist/utils/uuid.d.ts +7 -1
  99. package/dist/utils/uuid.d.ts.map +1 -1
  100. package/dist/utils/uuid.js +21 -1
  101. package/dist/utils/uuid.js.map +1 -1
  102. package/package.json +64 -7
  103. package/src/derived.ts +311 -0
  104. package/src/goals/controller.ts +442 -0
  105. package/src/goals/index.ts +21 -0
  106. package/src/goals/normalize.ts +173 -0
  107. package/src/goals/sentinel.ts +90 -0
  108. package/src/index.ts +270 -0
  109. package/src/providers/_shared/http-agent.ts +304 -0
  110. package/src/providers/acp/index.ts +103 -0
  111. package/src/providers/acp/parse.ts +131 -0
  112. package/src/providers/acp/session.ts +744 -0
  113. package/src/providers/claude/attach.ts +147 -0
  114. package/src/providers/claude/codec.ts +43 -0
  115. package/src/providers/claude/execute.ts +300 -0
  116. package/src/providers/claude/goal-capability.ts +21 -0
  117. package/src/providers/claude/index.ts +72 -0
  118. package/src/providers/claude/mcp.ts +82 -0
  119. package/src/providers/claude/parse.ts +824 -0
  120. package/src/providers/claude/session.ts +1192 -0
  121. package/src/providers/claude/transcript.ts +555 -0
  122. package/src/providers/codex/attach.ts +123 -0
  123. package/src/providers/codex/codec.ts +50 -0
  124. package/src/providers/codex/execute.ts +337 -0
  125. package/src/providers/codex/goal-capability.ts +19 -0
  126. package/src/providers/codex/index.ts +57 -0
  127. package/src/providers/codex/modes.ts +159 -0
  128. package/src/providers/codex/parse.ts +691 -0
  129. package/src/providers/codex/plan-mode.ts +49 -0
  130. package/src/providers/codex/session.ts +1287 -0
  131. package/src/providers/codex/transcript-normalize.ts +197 -0
  132. package/src/providers/codex/transcript.ts +487 -0
  133. package/src/providers/codex/usage-scanner.ts +178 -0
  134. package/src/providers/copilot/index.ts +19 -0
  135. package/src/providers/cursor/codec.ts +44 -0
  136. package/src/providers/cursor/execute.ts +271 -0
  137. package/src/providers/cursor/index.ts +25 -0
  138. package/src/providers/cursor/parse.ts +288 -0
  139. package/src/providers/gemini/index.ts +21 -0
  140. package/src/providers/openclaw/codec.ts +40 -0
  141. package/src/providers/openclaw/execute.ts +19 -0
  142. package/src/providers/openclaw/index.ts +29 -0
  143. package/src/providers/opencode/codec.ts +50 -0
  144. package/src/providers/opencode/event-parse.ts +141 -0
  145. package/src/providers/opencode/execute.ts +251 -0
  146. package/src/providers/opencode/http-session.ts +427 -0
  147. package/src/providers/opencode/index.ts +30 -0
  148. package/src/providers/opencode/parse.ts +203 -0
  149. package/src/providers/opencode/server.ts +0 -0
  150. package/src/providers/pi/codec.ts +44 -0
  151. package/src/providers/pi/execute.ts +297 -0
  152. package/src/providers/pi/index.ts +30 -0
  153. package/src/providers/pi/parse.ts +231 -0
  154. package/src/providers/pi/session.ts +381 -0
  155. package/src/providers/process/execute.ts +148 -0
  156. package/src/providers/process/index.ts +52 -0
  157. package/src/registry.ts +40 -0
  158. package/src/sessions/index.ts +8 -0
  159. package/src/sessions/record.ts +108 -0
  160. package/src/types.ts +1638 -0
  161. package/src/utils/ask-user-question.ts +57 -0
  162. package/src/utils/auth.ts +661 -0
  163. package/src/utils/binary.ts +179 -0
  164. package/src/utils/endpoint.ts +172 -0
  165. package/src/utils/env.ts +63 -0
  166. package/src/utils/execute-all.ts +68 -0
  167. package/src/utils/exit-plan-mode.ts +40 -0
  168. package/src/utils/instructions.ts +427 -0
  169. package/src/utils/process.ts +223 -0
  170. package/src/utils/runtime-config.ts +100 -0
  171. package/src/utils/runtime-homes.ts +49 -0
  172. package/src/utils/skill-commands.ts +493 -0
  173. package/src/utils/skills.ts +500 -0
  174. package/src/utils/template.ts +16 -0
  175. package/src/utils/tool-names.ts +51 -0
  176. package/src/utils/uuid.ts +21 -0
  177. package/src/utils/workspace.ts +156 -0
@@ -0,0 +1,427 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import type { SkillRuntime, SkillLocation } from "./skills.js";
5
+ import { getDefaultRuntimeHome } from "./runtime-homes.js";
6
+
7
+ /**
8
+ * Read an instructions file and return its content.
9
+ * Returns null if no path is provided.
10
+ * Throws a clear error if the file doesn't exist.
11
+ */
12
+ export async function resolveInstructions(filePath?: string): Promise<string | null> {
13
+ if (!filePath) return null;
14
+ try {
15
+ return await fs.readFile(filePath, "utf-8");
16
+ } catch (err) {
17
+ const code = (err as NodeJS.ErrnoException).code;
18
+ if (code === "ENOENT") {
19
+ throw new Error(`Instructions file not found: ${filePath}`);
20
+ }
21
+ throw err;
22
+ }
23
+ }
24
+
25
+ // ===========================================================================
26
+ // installInstructions — the instruction-file twin of installSkills.
27
+ //
28
+ // installSkills condenses every runtime into two discovery channels
29
+ // (.agents/skills + .claude/skills) with a per-runtime "native" escape hatch.
30
+ // Instruction files follow the same shape:
31
+ //
32
+ // - every runtime except Claude reads AGENTS.md; Claude reads CLAUDE.md
33
+ // - Gemini reads GEMINI.md by default (AGENTS.md only when configured), so it
34
+ // gets a native escape hatch
35
+ //
36
+ // Two locations, mirroring installSkills:
37
+ //
38
+ // - "workspace": files at {cwd}/ — the repo-root AGENTS.md convention. Files
39
+ // dedupe by name, so the default writes CLAUDE.md + AGENTS.md once each.
40
+ // - "global": each runtime reads its own file in its own home dir
41
+ // (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...). There
42
+ // is no universal ~/AGENTS.md, so global is inherently per-runtime.
43
+ //
44
+ // Unlike skills (which are symlinked dirs), instruction files carry content, so
45
+ // installInstructions does a managed-region merge: it wraps `content` in marker
46
+ // comments and replaces only that region on re-install, preserving anything the
47
+ // user wrote outside it.
48
+ // ===========================================================================
49
+
50
+ interface RuntimeInstructionSpec {
51
+ /** File this runtime reads at a workspace/repo root. AGENTS.md for all but Claude. */
52
+ projectFile: string;
53
+ /** The runtime's own preferred filename. Differs from projectFile only for Gemini (GEMINI.md). */
54
+ nativeFile: string;
55
+ /** Whether the runtime has a file-based global config. False for Cursor (global = app User Rules). */
56
+ hasGlobalFile: boolean;
57
+ }
58
+
59
+ const RUNTIME_INSTRUCTIONS: Record<SkillRuntime, RuntimeInstructionSpec> = {
60
+ claude: { projectFile: "CLAUDE.md", nativeFile: "CLAUDE.md", hasGlobalFile: true },
61
+ codex: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
62
+ opencode: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
63
+ gemini: { projectFile: "AGENTS.md", nativeFile: "GEMINI.md", hasGlobalFile: true },
64
+ cursor: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: false },
65
+ pi: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", hasGlobalFile: true },
66
+ };
67
+
68
+ const ALL_RUNTIMES: SkillRuntime[] = ["claude", "codex", "gemini", "cursor", "opencode", "pi"];
69
+
70
+ const DEFAULT_MANAGED_TAG = "agentex";
71
+
72
+ export interface InstallInstructionsOptions {
73
+ /** Which runtimes to write instruction files for. Defaults to all known runtimes. */
74
+ runtimes?: SkillRuntime[];
75
+ /** "workspace" ({cwd}/) — default — or "global" (each runtime's home dir). */
76
+ location?: SkillLocation;
77
+ /** Working directory. Required for "workspace". */
78
+ cwd?: string;
79
+ /**
80
+ * Also write each runtime's native file when it differs from the shared
81
+ * standard (currently only Gemini's GEMINI.md). Only affects "workspace";
82
+ * "global" always uses native files. Default: false.
83
+ */
84
+ includeNativeFiles?: boolean;
85
+ /**
86
+ * Wrap `content` in managed markers and merge into any existing file,
87
+ * replacing only the previously-managed region and preserving everything the
88
+ * user wrote outside it. Default: true. When false, the file is overwritten
89
+ * with raw `content` (escape hatch for fully-owned files).
90
+ */
91
+ managed?: boolean;
92
+ /** Marker tag, so the comment reads `<!-- <tag>:managed:start -->`. Default: "agentex". */
93
+ managedTag?: string;
94
+ /**
95
+ * Override the home-directory base for "global" installs (sandboxes / tests).
96
+ * Defaults to os.homedir().
97
+ */
98
+ homeDir?: string;
99
+ }
100
+
101
+ export type InstructionStatus = "created" | "updated" | "skipped" | "error";
102
+
103
+ export interface InstructionInstallEntry {
104
+ /** The filename written, e.g. "AGENTS.md", "CLAUDE.md", "GEMINI.md". */
105
+ filename: string;
106
+ /** Absolute path written. */
107
+ targetPath: string;
108
+ /** Which requested runtimes this file serves. */
109
+ runtimes: SkillRuntime[];
110
+ status: InstructionStatus;
111
+ error?: string;
112
+ }
113
+
114
+ export interface InstructionInstallResult {
115
+ entries: InstructionInstallEntry[];
116
+ installed: number; // newly created files
117
+ updated: number; // existing files whose content changed
118
+ skipped: number; // content already current → no write
119
+ errors: number;
120
+ }
121
+
122
+ export interface InstructionTarget {
123
+ filename: string;
124
+ targetPath: string;
125
+ runtimes: SkillRuntime[];
126
+ }
127
+
128
+ export interface RemoveInstructionsOptions {
129
+ runtimes?: SkillRuntime[];
130
+ location?: SkillLocation;
131
+ cwd?: string;
132
+ /** Marker tag whose managed region should be removed. Default: "agentex". */
133
+ managedTag?: string;
134
+ homeDir?: string;
135
+ }
136
+
137
+ export type InstructionRemoveStatus = "removed" | "not_found" | "skipped" | "error";
138
+
139
+ export interface InstructionRemoveEntry {
140
+ filename: string;
141
+ targetPath: string;
142
+ runtimes: SkillRuntime[];
143
+ status: InstructionRemoveStatus;
144
+ error?: string;
145
+ }
146
+
147
+ export interface InstructionRemoveResult {
148
+ entries: InstructionRemoveEntry[];
149
+ removed: number;
150
+ }
151
+
152
+ export interface ManagedBlockOptions {
153
+ /** Marker tag. Default: "agentex". */
154
+ tag?: string;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Path resolution
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Resolve which instruction files would be written for the given options,
163
+ * without touching disk.
164
+ *
165
+ * - "workspace": files dedupe by name under {cwd}/ (the default writes
166
+ * CLAUDE.md + AGENTS.md once each). `includeNativeFiles` adds GEMINI.md.
167
+ * - "global": one file per runtime in each runtime's home dir. Runtimes without
168
+ * a file-based global config (Cursor) are omitted.
169
+ */
170
+ export function resolveInstructionTargets(options?: {
171
+ runtimes?: SkillRuntime[];
172
+ location?: SkillLocation;
173
+ cwd?: string;
174
+ includeNativeFiles?: boolean;
175
+ homeDir?: string;
176
+ }): InstructionTarget[] {
177
+ const location: SkillLocation = options?.location ?? "workspace";
178
+ const runtimes = dedupeRuntimes(options?.runtimes ?? ALL_RUNTIMES);
179
+
180
+ if (location === "workspace") {
181
+ const cwd = options?.cwd;
182
+ if (!cwd) throw new Error("cwd is required when location is 'workspace'");
183
+
184
+ const byFile = new Map<string, SkillRuntime[]>();
185
+ const add = (filename: string, runtime: SkillRuntime) => {
186
+ const list = byFile.get(filename) ?? [];
187
+ list.push(runtime);
188
+ byFile.set(filename, list);
189
+ };
190
+
191
+ for (const runtime of runtimes) {
192
+ const spec = RUNTIME_INSTRUCTIONS[runtime];
193
+ add(spec.projectFile, runtime);
194
+ if (options?.includeNativeFiles && spec.nativeFile !== spec.projectFile) {
195
+ add(spec.nativeFile, runtime);
196
+ }
197
+ }
198
+
199
+ return [...byFile.entries()].map(([filename, rts]) => ({
200
+ filename,
201
+ targetPath: path.join(cwd, filename),
202
+ runtimes: dedupeRuntimes(rts),
203
+ }));
204
+ }
205
+
206
+ // global: each runtime reads its own native file in its own home dir.
207
+ const targets: InstructionTarget[] = [];
208
+ for (const runtime of runtimes) {
209
+ const spec = RUNTIME_INSTRUCTIONS[runtime];
210
+ if (!spec.hasGlobalFile) continue; // e.g. cursor global = app User Rules, not a file
211
+ const home = getDefaultRuntimeHome(runtime, options?.homeDir);
212
+ targets.push({
213
+ filename: spec.nativeFile,
214
+ targetPath: path.join(home, spec.nativeFile),
215
+ runtimes: [runtime],
216
+ });
217
+ }
218
+ return targets;
219
+ }
220
+
221
+ function dedupeRuntimes(runtimes: SkillRuntime[]): SkillRuntime[] {
222
+ return [...new Set(runtimes)];
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Public API
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Install an instruction brief into the right per-runtime files.
231
+ *
232
+ * By default merges `content` into a managed region of `{cwd}/CLAUDE.md` and
233
+ * `{cwd}/AGENTS.md`, preserving any user-authored content outside the markers.
234
+ * Idempotent: a re-install with unchanged content reports every entry as
235
+ * "skipped".
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * // Workspace (repo-root) install — the common case.
240
+ * await installInstructions(brief, { location: "workspace", cwd: projectDir });
241
+ * // → {cwd}/CLAUDE.md + {cwd}/AGENTS.md
242
+ *
243
+ * // Also drop Gemini's native GEMINI.md (it doesn't read AGENTS.md by default).
244
+ * await installInstructions(brief, { location: "workspace", cwd, includeNativeFiles: true });
245
+ *
246
+ * // Global install — per-runtime home files.
247
+ * await installInstructions(brief, { location: "global" });
248
+ * // → ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...
249
+ * ```
250
+ */
251
+ export async function installInstructions(
252
+ content: string,
253
+ options?: InstallInstructionsOptions,
254
+ ): Promise<InstructionInstallResult> {
255
+ const managed = options?.managed ?? true;
256
+ const tag = options?.managedTag ?? DEFAULT_MANAGED_TAG;
257
+ const targets = resolveInstructionTargets(options);
258
+ const entries: InstructionInstallEntry[] = [];
259
+
260
+ for (const target of targets) {
261
+ try {
262
+ const existing = await readFileOrNull(target.targetPath);
263
+ const next = managed
264
+ ? upsertManagedBlock(existing, content, { tag })
265
+ : ensureTrailingNewline(content);
266
+
267
+ let status: InstructionStatus;
268
+ if (existing === null) {
269
+ status = "created";
270
+ } else if (existing === next) {
271
+ status = "skipped";
272
+ } else {
273
+ status = "updated";
274
+ }
275
+
276
+ if (status !== "skipped") {
277
+ await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
278
+ await fs.writeFile(target.targetPath, next, { mode: 0o644 });
279
+ }
280
+
281
+ entries.push({ ...target, status });
282
+ } catch (err) {
283
+ entries.push({
284
+ ...target,
285
+ status: "error",
286
+ error: err instanceof Error ? err.message : String(err),
287
+ });
288
+ }
289
+ }
290
+
291
+ return {
292
+ entries,
293
+ installed: entries.filter((e) => e.status === "created").length,
294
+ updated: entries.filter((e) => e.status === "updated").length,
295
+ skipped: entries.filter((e) => e.status === "skipped").length,
296
+ errors: entries.filter((e) => e.status === "error").length,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Remove the managed region installed by {@link installInstructions}, preserving
302
+ * any user-authored content outside the markers. If the file contains nothing
303
+ * but the managed block, it is deleted. User-owned files (no managed region) are
304
+ * left untouched and reported as "skipped".
305
+ */
306
+ export async function removeInstructions(
307
+ options?: RemoveInstructionsOptions,
308
+ ): Promise<InstructionRemoveResult> {
309
+ const tag = options?.managedTag ?? DEFAULT_MANAGED_TAG;
310
+ const targets = resolveInstructionTargets({
311
+ runtimes: options?.runtimes,
312
+ location: options?.location,
313
+ cwd: options?.cwd,
314
+ homeDir: options?.homeDir,
315
+ });
316
+ const entries: InstructionRemoveEntry[] = [];
317
+
318
+ for (const target of targets) {
319
+ try {
320
+ const existing = await readFileOrNull(target.targetPath);
321
+ if (existing === null) {
322
+ entries.push({ ...target, status: "not_found" });
323
+ continue;
324
+ }
325
+
326
+ const stripped = stripManagedBlock(existing, { tag });
327
+ if (stripped === existing) {
328
+ // No managed region present — never touch user-owned files.
329
+ entries.push({ ...target, status: "skipped" });
330
+ continue;
331
+ }
332
+
333
+ if (stripped === null) {
334
+ await fs.rm(target.targetPath, { force: true });
335
+ } else {
336
+ await fs.writeFile(target.targetPath, stripped, { mode: 0o644 });
337
+ }
338
+ entries.push({ ...target, status: "removed" });
339
+ } catch (err) {
340
+ entries.push({
341
+ ...target,
342
+ status: "error",
343
+ error: err instanceof Error ? err.message : String(err),
344
+ });
345
+ }
346
+ }
347
+
348
+ return { entries, removed: entries.filter((e) => e.status === "removed").length };
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Managed-region merge (exported for low-level use / hosts with custom layouts)
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Merge `content` into `existing` as a managed region, preserving everything the
357
+ * user wrote outside the markers.
358
+ *
359
+ * - `existing` has a managed region → replace only the bytes between the markers.
360
+ * - `existing` has no markers → prepend the managed block, keep prior content below.
361
+ * - `existing` is null/empty → return just the managed block.
362
+ *
363
+ * The start marker embeds a short content hash, so re-running with identical
364
+ * content produces a byte-identical result (enabling cheap skip detection).
365
+ */
366
+ export function upsertManagedBlock(
367
+ existing: string | null,
368
+ content: string,
369
+ options?: ManagedBlockOptions,
370
+ ): string {
371
+ const tag = options?.tag ?? DEFAULT_MANAGED_TAG;
372
+ const block = buildManagedBlock(content, tag);
373
+
374
+ if (existing === null || existing.length === 0) {
375
+ return `${block}\n`;
376
+ }
377
+
378
+ const re = managedBlockRegex(tag);
379
+ if (re.test(existing)) {
380
+ return existing.replace(re, block);
381
+ }
382
+
383
+ // No managed region yet — prepend it, keep the user's file below.
384
+ return `${block}\n\n${existing.replace(/^\n+/, "")}`;
385
+ }
386
+
387
+ /**
388
+ * Remove the managed region (if any) from `existing`, preserving user content.
389
+ * Returns the cleaned string, the original string if there was no managed
390
+ * region, or null if nothing but the managed block remained.
391
+ */
392
+ export function stripManagedBlock(existing: string, options?: ManagedBlockOptions): string | null {
393
+ const tag = options?.tag ?? DEFAULT_MANAGED_TAG;
394
+ const re = managedBlockRegex(tag);
395
+ if (!re.test(existing)) return existing;
396
+
397
+ const stripped = existing.replace(re, "").replace(/^\n+/, "");
398
+ return stripped.trim().length === 0 ? null : stripped;
399
+ }
400
+
401
+ function buildManagedBlock(content: string, tag: string): string {
402
+ const body = content.replace(/\s+$/, "");
403
+ const hash = createHash("sha256").update(body).digest("hex").slice(0, 12);
404
+ return `<!-- ${tag}:managed:start hash=${hash} -->\n${body}\n<!-- ${tag}:managed:end -->`;
405
+ }
406
+
407
+ function managedBlockRegex(tag: string): RegExp {
408
+ const t = escapeRegExp(tag);
409
+ return new RegExp(`<!--\\s*${t}:managed:start[^>]*-->[\\s\\S]*?<!--\\s*${t}:managed:end\\s*-->`);
410
+ }
411
+
412
+ function escapeRegExp(s: string): string {
413
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
414
+ }
415
+
416
+ function ensureTrailingNewline(s: string): string {
417
+ return s.endsWith("\n") ? s : `${s}\n`;
418
+ }
419
+
420
+ async function readFileOrNull(filePath: string): Promise<string | null> {
421
+ try {
422
+ return await fs.readFile(filePath, "utf-8");
423
+ } catch (err) {
424
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
425
+ throw err;
426
+ }
427
+ }
@@ -0,0 +1,223 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+
4
+ export interface RunProcessOptions {
5
+ runId: string;
6
+ command: string;
7
+ args: string[];
8
+ cwd: string;
9
+ env: Record<string, string>;
10
+ stdin?: string;
11
+ timeoutSec?: number;
12
+ graceSec?: number;
13
+ maxCaptureBytes?: number;
14
+ onOutput?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
15
+ onStart?: (pid: number) => void;
16
+ /** AbortSignal to cancel the process. */
17
+ signal?: AbortSignal;
18
+ }
19
+
20
+ export interface RunProcessResult {
21
+ exitCode: number | null;
22
+ signal: string | null;
23
+ timedOut: boolean;
24
+ aborted: boolean;
25
+ stdout: string;
26
+ stderr: string;
27
+ }
28
+
29
+ const DEFAULT_MAX_CAPTURE = 4 * 1024 * 1024; // 4MB
30
+ const DEFAULT_GRACE_SEC = 5;
31
+
32
+ export function deriveErrorCode(result: RunProcessResult): string | null {
33
+ if (result.aborted) return "aborted";
34
+ if (result.timedOut) return "timeout";
35
+ if (result.signal && !result.timedOut) return "killed";
36
+ return null;
37
+ }
38
+
39
+ export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
40
+ if (process.platform === "win32") {
41
+ try {
42
+ spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
43
+ } catch {
44
+ // Best effort
45
+ }
46
+ return;
47
+ }
48
+
49
+ // Unix: try process group kill
50
+ try {
51
+ process.kill(-pid, signal);
52
+ } catch {
53
+ // Fallback: kill individual process
54
+ try {
55
+ process.kill(pid, signal);
56
+ } catch {
57
+ // Process already dead
58
+ }
59
+ }
60
+ }
61
+
62
+ export function runChildProcess(opts: RunProcessOptions): Promise<RunProcessResult> {
63
+ // If already aborted before spawn, return immediately
64
+ if (opts.signal?.aborted) {
65
+ return Promise.resolve({
66
+ exitCode: null,
67
+ signal: null,
68
+ timedOut: false,
69
+ aborted: true,
70
+ stdout: "",
71
+ stderr: "",
72
+ });
73
+ }
74
+
75
+ return new Promise((resolve) => {
76
+ const maxCapture = opts.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE;
77
+ const graceSec = opts.graceSec ?? DEFAULT_GRACE_SEC;
78
+
79
+ let stdoutBuf = "";
80
+ let stderrBuf = "";
81
+ let stdoutBytes = 0;
82
+ let stderrBytes = 0;
83
+ let timedOut = false;
84
+ let aborted = false;
85
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
86
+ let graceHandle: ReturnType<typeof setTimeout> | null = null;
87
+
88
+ // Chained promise for ordered callback execution
89
+ let callbackChain = Promise.resolve();
90
+
91
+ const child = spawn(opts.command, opts.args, {
92
+ cwd: opts.cwd,
93
+ env: opts.env,
94
+ stdio: ["pipe", "pipe", "pipe"],
95
+ shell: false,
96
+ detached: process.platform !== "win32", // Enable process group on Unix
97
+ });
98
+
99
+ // Notify caller of the child PID
100
+ if (opts.onStart && child.pid != null) {
101
+ opts.onStart(child.pid);
102
+ }
103
+
104
+ // Write stdin and close
105
+ if (opts.stdin != null) {
106
+ child.stdin.write(opts.stdin);
107
+ }
108
+ child.stdin.end();
109
+
110
+ const appendWithCap = (
111
+ stream: "stdout" | "stderr",
112
+ chunk: string,
113
+ ): void => {
114
+ const bytes = Buffer.byteLength(chunk, "utf-8");
115
+ if (stream === "stdout") {
116
+ if (stdoutBytes < maxCapture) {
117
+ const remaining = maxCapture - stdoutBytes;
118
+ stdoutBuf += bytes <= remaining ? chunk : chunk.slice(0, remaining);
119
+ }
120
+ stdoutBytes += bytes;
121
+ } else {
122
+ if (stderrBytes < maxCapture) {
123
+ const remaining = maxCapture - stderrBytes;
124
+ stderrBuf += bytes <= remaining ? chunk : chunk.slice(0, remaining);
125
+ }
126
+ stderrBytes += bytes;
127
+ }
128
+
129
+ if (opts.onOutput) {
130
+ const cb = opts.onOutput;
131
+ callbackChain = callbackChain
132
+ .then(() => cb(stream, chunk))
133
+ .catch(() => {
134
+ // Callback errors must not crash the process
135
+ });
136
+ }
137
+ };
138
+
139
+ child.stdout.setEncoding("utf-8");
140
+ child.stderr.setEncoding("utf-8");
141
+
142
+ child.stdout.on("data", (chunk: string) => appendWithCap("stdout", chunk));
143
+ child.stderr.on("data", (chunk: string) => appendWithCap("stderr", chunk));
144
+
145
+ // Timeout handling
146
+ if (opts.timeoutSec && opts.timeoutSec > 0) {
147
+ timeoutHandle = setTimeout(() => {
148
+ timedOut = true;
149
+ if (child.pid != null) {
150
+ killProcessTree(child.pid, "SIGTERM");
151
+ // Grace period then SIGKILL
152
+ graceHandle = setTimeout(() => {
153
+ if (child.pid != null) {
154
+ killProcessTree(child.pid, "SIGKILL");
155
+ }
156
+ }, graceSec * 1000);
157
+ }
158
+ }, opts.timeoutSec * 1000);
159
+ }
160
+
161
+ // AbortSignal handling
162
+ let abortGraceHandle: ReturnType<typeof setTimeout> | null = null;
163
+ const onAbort = () => {
164
+ aborted = true;
165
+ if (child.pid != null) {
166
+ killProcessTree(child.pid, "SIGTERM");
167
+ abortGraceHandle = setTimeout(() => {
168
+ if (child.pid != null) {
169
+ killProcessTree(child.pid, "SIGKILL");
170
+ }
171
+ }, graceSec * 1000);
172
+ }
173
+ };
174
+ if (opts.signal) {
175
+ opts.signal.addEventListener("abort", onAbort, { once: true });
176
+ }
177
+
178
+ const cleanup = () => {
179
+ if (timeoutHandle) clearTimeout(timeoutHandle);
180
+ if (graceHandle) clearTimeout(graceHandle);
181
+ if (abortGraceHandle) clearTimeout(abortGraceHandle);
182
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbort);
183
+ };
184
+
185
+ child.on("close", (code, signal) => {
186
+ cleanup();
187
+
188
+ // Wait for callbacks to complete before resolving
189
+ callbackChain.then(() => {
190
+ resolve({
191
+ exitCode: code,
192
+ signal: signal ?? null,
193
+ timedOut,
194
+ aborted,
195
+ stdout: stdoutBuf,
196
+ stderr: stderrBuf,
197
+ });
198
+ }).catch(() => {
199
+ resolve({
200
+ exitCode: code,
201
+ signal: signal ?? null,
202
+ timedOut,
203
+ aborted,
204
+ stdout: stdoutBuf,
205
+ stderr: stderrBuf,
206
+ });
207
+ });
208
+ });
209
+
210
+ child.on("error", (err) => {
211
+ cleanup();
212
+
213
+ resolve({
214
+ exitCode: null,
215
+ signal: null,
216
+ timedOut: false,
217
+ aborted: false,
218
+ stdout: stdoutBuf,
219
+ stderr: err.message,
220
+ });
221
+ });
222
+ });
223
+ }