@agentex/agent 0.0.36 → 0.0.38

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 (38) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +32 -7
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/providers/claude/parse.d.ts +3 -0
  7. package/dist/providers/claude/parse.d.ts.map +1 -1
  8. package/dist/providers/claude/parse.js +23 -1
  9. package/dist/providers/claude/parse.js.map +1 -1
  10. package/dist/providers/claude/session.d.ts +174 -4
  11. package/dist/providers/claude/session.d.ts.map +1 -1
  12. package/dist/providers/claude/session.js +541 -29
  13. package/dist/providers/claude/session.js.map +1 -1
  14. package/dist/providers/codex/execute.d.ts.map +1 -1
  15. package/dist/providers/codex/execute.js +3 -0
  16. package/dist/providers/codex/execute.js.map +1 -1
  17. package/dist/providers/codex/parse.d.ts +12 -1
  18. package/dist/providers/codex/parse.d.ts.map +1 -1
  19. package/dist/providers/codex/parse.js +21 -0
  20. package/dist/providers/codex/parse.js.map +1 -1
  21. package/dist/providers/codex/session.d.ts.map +1 -1
  22. package/dist/providers/codex/session.js +21 -1
  23. package/dist/providers/codex/session.js.map +1 -1
  24. package/dist/types.d.ts +95 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/utils/instructions.d.ts +36 -12
  27. package/dist/utils/instructions.d.ts.map +1 -1
  28. package/dist/utils/instructions.js +104 -53
  29. package/dist/utils/instructions.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +2 -0
  32. package/src/providers/claude/parse.ts +23 -1
  33. package/src/providers/claude/session.ts +556 -30
  34. package/src/providers/codex/execute.ts +3 -0
  35. package/src/providers/codex/parse.ts +25 -0
  36. package/src/providers/codex/session.ts +24 -1
  37. package/src/types.ts +97 -0
  38. package/src/utils/instructions.ts +152 -64
@@ -250,6 +250,9 @@ export async function executeCodexProvider(ctx: ExecutionContext): Promise<Execu
250
250
  status: "stopped",
251
251
  description: task.description,
252
252
  summary: null,
253
+ // Cut short, so there is no result to hand back.
254
+ toolUseId: null,
255
+ report: null,
253
256
  parentTaskId: task.parentTaskId,
254
257
  timestamp: new Date().toISOString(),
255
258
  providerType: "codex",
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ BackgroundTaskReport,
2
3
  BaseStreamEventFields,
3
4
  GoalSource,
4
5
  StreamEvent,
@@ -70,6 +71,26 @@ function parseStringArray(value: unknown): string[] {
70
71
  : [];
71
72
  }
72
73
 
74
+ /**
75
+ * The delivered-result payload for a background task, or null.
76
+ *
77
+ * One rule for every Codex emitter: a child that reached a terminal outcome
78
+ * handed something back; one that was cut short did not. Defined here rather
79
+ * than at each construction site because there are five of them and four
80
+ * previously forgot, so a host keying on `report !== null` — which the README
81
+ * tells it to do — rendered no row for completions that reached it by the
82
+ * reconcile path. Mirrors the Claude gate in `claude/parse.ts`.
83
+ */
84
+ export function codexBackgroundTaskReport(
85
+ phase: "started" | "progress" | "completed",
86
+ status: "pending" | "running" | "paused" | "completed" | "failed" | "stopped" | null,
87
+ summary: string | null,
88
+ ): BackgroundTaskReport | null {
89
+ if (phase !== "completed") return null;
90
+ if (status !== "completed" && status !== "failed") return null;
91
+ return { summary, outputFile: null, usage: null };
92
+ }
93
+
73
94
  function codexBackgroundTaskState(
74
95
  value: unknown,
75
96
  ): { phase: "started" | "completed"; status: "running" | "completed" | "failed" | "stopped"; summary: string | null } {
@@ -454,6 +475,8 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
454
475
  description: asNullableString(item["agentPath"]),
455
476
  summary: null,
456
477
  parentTaskId: null,
478
+ toolUseId: null,
479
+ report: null,
457
480
  ...base,
458
481
  };
459
482
  }
@@ -491,6 +514,8 @@ function parseV2Notification(event: Record<string, unknown>): StreamEvent | Stre
491
514
  description,
492
515
  summary: state.summary,
493
516
  parentTaskId: null,
517
+ toolUseId: null,
518
+ report: codexBackgroundTaskReport(state.phase, state.status, state.summary),
494
519
  ...base,
495
520
  };
496
521
  });
@@ -2,6 +2,7 @@ import type { ChildProcess } from "node:child_process";
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type {
5
+ BackgroundTaskReport,
5
6
  AgentSession,
6
7
  CancelResult,
7
8
  ClearGoalResult,
@@ -27,7 +28,7 @@ import { translateEndpoint } from "../../utils/endpoint.js";
27
28
  import { injectWorkspaceSkills } from "../../utils/skills.js";
28
29
  import { resolveInstructions } from "../../utils/instructions.js";
29
30
  import { createToolNameTracker } from "../../utils/tool-names.js";
30
- import { parseCodexStreamLines } from "./parse.js";
31
+ import { parseCodexStreamLines, codexBackgroundTaskReport } from "./parse.js";
31
32
  import { withPlanModePreamble } from "./plan-mode.js";
32
33
  import { scanCodexSessionUsage } from "./usage-scanner.js";
33
34
  import { codexSessionCodec } from "./codec.js";
@@ -1448,6 +1449,8 @@ export class CodexSessionImpl implements AgentSession {
1448
1449
  parentTaskId?: string | null;
1449
1450
  turnId?: string | null;
1450
1451
  eventId?: string | null;
1452
+ /** Set on the edge that actually hands the child's result back. */
1453
+ report?: BackgroundTaskReport | null;
1451
1454
  raw: Record<string, unknown>;
1452
1455
  },
1453
1456
  ): Extract<StreamEvent, { type: "background_task" }> {
@@ -1461,6 +1464,16 @@ export class CodexSessionImpl implements AgentSession {
1461
1464
  description: options.description ?? null,
1462
1465
  summary: options.summary ?? null,
1463
1466
  parentTaskId: options.parentTaskId ?? null,
1467
+ // Codex identifies children by thread id, not by the id of the
1468
+ // `spawn_agent` call that created them, so there is no tool-call link to
1469
+ // report here.
1470
+ toolUseId: null,
1471
+ // Derived, not passed. Four of the five emitters that reach this helper
1472
+ // never set it, and the omission was invisible until a host asked why
1473
+ // reconciled completions produced no row.
1474
+ report: options.report !== undefined
1475
+ ? options.report
1476
+ : codexBackgroundTaskReport(phase, status, options.summary ?? null),
1464
1477
  timestamp: new Date().toISOString(),
1465
1478
  providerType: "codex",
1466
1479
  sessionId: rootThreadId,
@@ -1833,6 +1846,8 @@ export class CodexSessionImpl implements AgentSession {
1833
1846
  status: "running",
1834
1847
  description,
1835
1848
  summary: null,
1849
+ toolUseId: null,
1850
+ report: null,
1836
1851
  parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1837
1852
  timestamp: new Date().toISOString(),
1838
1853
  providerType: "codex",
@@ -1867,6 +1882,8 @@ export class CodexSessionImpl implements AgentSession {
1867
1882
  status: "running",
1868
1883
  description: task.description,
1869
1884
  summary: null,
1885
+ toolUseId: null,
1886
+ report: null,
1870
1887
  parentTaskId: task.parentTaskId,
1871
1888
  timestamp: new Date().toISOString(),
1872
1889
  providerType: "codex",
@@ -1920,6 +1937,12 @@ export class CodexSessionImpl implements AgentSession {
1920
1937
  description: task.description,
1921
1938
  summary: task.summary ?? (errorMessage || null),
1922
1939
  parentTaskId: task.parentTaskId,
1940
+ toolUseId: null,
1941
+ // The child's turn ended and its result is being handed back — the same
1942
+ // edge Claude expresses as `task_notification`. Same rule as every other
1943
+ // emitter, so a host can render "one row per delivered result" across
1944
+ // providers instead of special-casing each one.
1945
+ report: codexBackgroundTaskReport("completed", status, task.summary ?? (errorMessage || null)),
1923
1946
  timestamp: new Date().toISOString(),
1924
1947
  providerType: "codex",
1925
1948
  sessionId: rootThreadId,
package/src/types.ts CHANGED
@@ -998,6 +998,48 @@ export type BackgroundTaskType = "subagent" | "process" | "unknown";
998
998
  /** Lifecycle edge represented by one `background_task` StreamEvent. */
999
999
  export type BackgroundTaskPhase = "started" | "progress" | "completed";
1000
1000
 
1001
+ /**
1002
+ * A background task's delivered output.
1003
+ *
1004
+ * Present only on the event where the provider actually *hands the result
1005
+ * back* — distinct from a state patch that merely says the task reached a
1006
+ * terminal status. Claude emits both for one completion (`task_updated` then
1007
+ * `task_notification`), and they are different records, not duplicates: only
1008
+ * the delivery carries the summary, the output file, and the `toolUseId`
1009
+ * linking the task to the call that launched it.
1010
+ *
1011
+ * Collapsing the two into one indistinguishable "completed" event is what
1012
+ * made hosts render every finished task twice: once with its report and once
1013
+ * with nothing. Only the delivery carries the summary and the output file
1014
+ * (`toolUseId` is also on the task's `started` record).
1015
+ *
1016
+ * Absent on a task that was cut short — a stop or a kill delivers no result.
1017
+ */
1018
+ export interface BackgroundTaskReport {
1019
+ /** The task's final output as the provider summarized it. */
1020
+ summary: string | null;
1021
+ /** Path to the task's full transcript/output on disk, when the provider writes one. */
1022
+ outputFile: string | null;
1023
+ /** What the task consumed, when reported. */
1024
+ usage: {
1025
+ totalTokens: number | null;
1026
+ toolUses: number | null;
1027
+ durationMs: number | null;
1028
+ } | null;
1029
+ }
1030
+
1031
+ /**
1032
+ * Why a turn began.
1033
+ *
1034
+ * - `send` — dispatched through `send()`. Usually the host; agentex's own
1035
+ * emulated goal loop also continues a session this way, so this means
1036
+ * "someone called send()", not strictly "the user".
1037
+ * - `resume` — the provider started it on its own. Claude does this when a
1038
+ * background task finishes: it enqueues a task-notification as user input,
1039
+ * which opens a fresh turn with no host involvement.
1040
+ */
1041
+ export type TurnTrigger = "send" | "resume";
1042
+
1001
1043
  /** Current normalized state carried by a `background_task` event. */
1002
1044
  export type BackgroundTaskStatus =
1003
1045
  | "pending"
@@ -1229,6 +1271,61 @@ export type StreamEvent =
1229
1271
  description: string | null;
1230
1272
  summary: string | null;
1231
1273
  parentTaskId: string | null;
1274
+ /**
1275
+ * The `tool_call` id that launched this task, when the provider reports
1276
+ * it. This is the structured link between a task and the tool call it
1277
+ * came from — the same id Claude writes into a subagent's `meta.json`.
1278
+ */
1279
+ toolUseId: string | null;
1280
+ /**
1281
+ * The task's delivered output, on the event that delivers it, else null.
1282
+ * A terminal `status` says the task finished; a non-null `report` says
1283
+ * its result is being handed back. See `BackgroundTaskReport`.
1284
+ */
1285
+ report: BackgroundTaskReport | null;
1286
+ } & BaseStreamEventFields)
1287
+ /**
1288
+ * A turn opened. Pairs with `result`, which closes one.
1289
+ *
1290
+ * Hosts that track "is the agent working" cannot derive it from their own
1291
+ * dispatch alone, because not every turn is theirs: when a background task
1292
+ * finishes, Claude enqueues a notification as user input and starts a turn
1293
+ * by itself. A host keying off its own `send()` sees that turn as idle and
1294
+ * reports the session as finished while it is visibly working.
1295
+ *
1296
+ * Emitted for host-initiated turns too, so `turn_start` → `result` describes
1297
+ * turn liveness straight off the stream.
1298
+ *
1299
+ * Deliberately does not name the background task behind a `resume`. Claude
1300
+ * delivers a task's result and opens the turn as two unlinked records, and
1301
+ * with several tasks in flight the pairing is not recoverable from the wire
1302
+ * — every attempt to infer it produced a plausible id that was sometimes
1303
+ * simply wrong. Correlate through `background_task.report` and `toolUseId`,
1304
+ * which the provider does state.
1305
+ */
1306
+ | ({
1307
+ type: "turn_start";
1308
+ turnId: string;
1309
+ trigger: TurnTrigger;
1310
+ } & BaseStreamEventFields)
1311
+ /**
1312
+ * A turn closed. Every `turn_start` is followed by exactly one of these.
1313
+ *
1314
+ * `result` cannot serve as the close signal on its own: a message the CLI
1315
+ * cancels, discards, or refuses opens a turn and produces no result at all,
1316
+ * so a host tracking `turn_start` → `result` would stay busy forever. This
1317
+ * is the guaranteed counterpart; `result` remains the outcome payload.
1318
+ */
1319
+ | ({
1320
+ type: "turn_end";
1321
+ turnId: string;
1322
+ trigger: TurnTrigger;
1323
+ /**
1324
+ * Why the turn ended. `result` means a normal completion whose payload
1325
+ * arrives as the accompanying `result` event; the rest are CLI verdicts
1326
+ * on the message that produced no result.
1327
+ */
1328
+ reason: "result" | "cancelled" | "discarded" | "refused" | "session_closed";
1232
1329
  } & BaseStreamEventFields)
1233
1330
  | ({
1234
1331
  type: "result";
@@ -29,18 +29,27 @@ export async function resolveInstructions(filePath?: string): Promise<string | n
29
29
  // (.agents/skills + .claude/skills) with a per-runtime "native" escape hatch.
30
30
  // Instruction files follow the same shape:
31
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
32
+ // - every runtime reads AGENTS.md at a workspace root (Claude Code since
33
+ // 2.1.277, where the folder has no CLAUDE.md)
34
+ // - Claude's CLAUDE.md and Gemini's GEMINI.md are native files, written only
35
+ // on opt-in (`includeNativeFiles`)
35
36
  //
36
37
  // Two locations, mirroring installSkills:
37
38
  //
38
39
  // - "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
+ // dedupe by name, so the default writes AGENTS.md once.
40
41
  // - "global": each runtime reads its own file in its own home dir
41
42
  // (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md, ...). There
42
43
  // is no universal ~/AGENTS.md, so global is inherently per-runtime.
43
44
  //
45
+ // Claude Code reads AGENTS.md only where no CLAUDE.md exists: a CLAUDE.md (or
46
+ // a CLAUDE.local.md, or one in a parent directory) hides AGENTS.md completely.
47
+ // So a workspace CLAUDE.md is only ever a one-line pointer (`@AGENTS.md`),
48
+ // never a second copy of the brief, and a CLAUDE.md already on disk is
49
+ // reconciled on every install: one holding nothing but our managed region is
50
+ // removed when the opt-in is off, and one the user wrote gets the pointer so
51
+ // the brief still reaches Claude.
52
+ //
44
53
  // Unlike skills (which are symlinked dirs), instruction files carry content, so
45
54
  // installInstructions does a managed-region merge: it wraps `content` in marker
46
55
  // comments and replaces only that region on re-install, preserving anything the
@@ -48,21 +57,28 @@ export async function resolveInstructions(filePath?: string): Promise<string | n
48
57
  // ===========================================================================
49
58
 
50
59
  interface RuntimeInstructionSpec {
51
- /** File this runtime reads at a workspace/repo root. AGENTS.md for all but Claude. */
60
+ /** File this runtime reads at a workspace/repo root. AGENTS.md for every runtime. */
52
61
  projectFile: string;
53
- /** The runtime's own preferred filename. Differs from projectFile only for Gemini (GEMINI.md). */
62
+ /** The runtime's own preferred filename. Written in a workspace only on opt-in (`includeNativeFiles`). */
54
63
  nativeFile: string;
64
+ /**
65
+ * How an opt-in native file carries the brief. "pointer": only an import of
66
+ * the project file (`@AGENTS.md`), because the native file's mere presence
67
+ * hides the project file from the runtime (Claude). "copy": the brief itself
68
+ * (Gemini, which doesn't read AGENTS.md by default).
69
+ */
70
+ nativeFileMode: "pointer" | "copy";
55
71
  /** Whether the runtime has a file-based global config. False for Cursor (global = app User Rules). */
56
72
  hasGlobalFile: boolean;
57
73
  }
58
74
 
59
75
  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 },
76
+ claude: { projectFile: "AGENTS.md", nativeFile: "CLAUDE.md", nativeFileMode: "pointer", hasGlobalFile: true },
77
+ codex: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", nativeFileMode: "copy", hasGlobalFile: true },
78
+ opencode: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", nativeFileMode: "copy", hasGlobalFile: true },
79
+ gemini: { projectFile: "AGENTS.md", nativeFile: "GEMINI.md", nativeFileMode: "copy", hasGlobalFile: true },
80
+ cursor: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", nativeFileMode: "copy", hasGlobalFile: false },
81
+ pi: { projectFile: "AGENTS.md", nativeFile: "AGENTS.md", nativeFileMode: "copy", hasGlobalFile: true },
66
82
  };
67
83
 
68
84
  const ALL_RUNTIMES: SkillRuntime[] = ["claude", "codex", "gemini", "cursor", "opencode", "pi"];
@@ -78,15 +94,20 @@ export interface InstallInstructionsOptions {
78
94
  cwd?: string;
79
95
  /**
80
96
  * 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.
97
+ * AGENTS.md: Claude's CLAUDE.md (as a one-line `@AGENTS.md` pointer) and
98
+ * Gemini's GEMINI.md (as a copy). Only affects "workspace"; "global" always
99
+ * uses native files. Default: false.
100
+ *
101
+ * Opt in for Claude Code before 2.1.277, or on Bedrock, Vertex or Foundry,
102
+ * where Claude doesn't read AGENTS.md on its own.
83
103
  */
84
104
  includeNativeFiles?: boolean;
85
105
  /**
86
106
  * Wrap `content` in managed markers and merge into any existing file,
87
107
  * replacing only the previously-managed region and preserving everything the
88
108
  * user wrote outside it. Default: true. When false, the file is overwritten
89
- * with raw `content` (escape hatch for fully-owned files).
109
+ * with raw `content` (escape hatch for fully-owned files), and an existing
110
+ * CLAUDE.md is left alone even though it hides AGENTS.md from Claude Code.
90
111
  */
91
112
  managed?: boolean;
92
113
  /** Marker tag, so the comment reads `<!-- <tag>:managed:start -->`. Default: "agentex". */
@@ -98,7 +119,11 @@ export interface InstallInstructionsOptions {
98
119
  homeDir?: string;
99
120
  }
100
121
 
101
- export type InstructionStatus = "created" | "updated" | "skipped" | "error";
122
+ /**
123
+ * "removed": a workspace CLAUDE.md that held nothing but this installer's
124
+ * managed region, deleted because the opt-in (`includeNativeFiles`) is off.
125
+ */
126
+ export type InstructionStatus = "created" | "updated" | "skipped" | "removed" | "error";
102
127
 
103
128
  export interface InstructionInstallEntry {
104
129
  /** The filename written, e.g. "AGENTS.md", "CLAUDE.md", "GEMINI.md". */
@@ -107,6 +132,8 @@ export interface InstructionInstallEntry {
107
132
  targetPath: string;
108
133
  /** Which requested runtimes this file serves. */
109
134
  runtimes: SkillRuntime[];
135
+ /** Set when the file is a pointer: its managed region is `@<importsFile>`, not the brief. */
136
+ importsFile?: string;
110
137
  status: InstructionStatus;
111
138
  error?: string;
112
139
  }
@@ -116,6 +143,7 @@ export interface InstructionInstallResult {
116
143
  installed: number; // newly created files
117
144
  updated: number; // existing files whose content changed
118
145
  skipped: number; // content already current → no write
146
+ removed: number; // managed-only CLAUDE.md deleted because the opt-in is off
119
147
  errors: number;
120
148
  }
121
149
 
@@ -123,6 +151,8 @@ export interface InstructionTarget {
123
151
  filename: string;
124
152
  targetPath: string;
125
153
  runtimes: SkillRuntime[];
154
+ /** Set when the file is a pointer: its managed region is `@<importsFile>`, not the brief. */
155
+ importsFile?: string;
126
156
  }
127
157
 
128
158
  export interface RemoveInstructionsOptions {
@@ -163,7 +193,8 @@ export interface ManagedBlockOptions {
163
193
  * without touching disk.
164
194
  *
165
195
  * - "workspace": files dedupe by name under {cwd}/ (the default writes
166
- * CLAUDE.md + AGENTS.md once each). `includeNativeFiles` adds GEMINI.md.
196
+ * AGENTS.md once). `includeNativeFiles` adds CLAUDE.md (a pointer, marked by
197
+ * `importsFile`) and GEMINI.md.
167
198
  * - "global": one file per runtime in each runtime's home dir. Runtimes without
168
199
  * a file-based global config (Cursor) are omitted.
169
200
  */
@@ -181,26 +212,27 @@ export function resolveInstructionTargets(options?: {
181
212
  const cwd = options?.cwd;
182
213
  if (!cwd) throw new Error("cwd is required when location is 'workspace'");
183
214
 
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);
215
+ const byFile = new Map<string, InstructionTarget>();
216
+ const add = (filename: string, runtime: SkillRuntime, importsFile?: string) => {
217
+ const target: InstructionTarget = byFile.get(filename) ?? {
218
+ filename,
219
+ targetPath: path.join(cwd, filename),
220
+ runtimes: [],
221
+ ...(importsFile !== undefined && { importsFile }),
222
+ };
223
+ target.runtimes.push(runtime);
224
+ byFile.set(filename, target);
189
225
  };
190
226
 
191
227
  for (const runtime of runtimes) {
192
228
  const spec = RUNTIME_INSTRUCTIONS[runtime];
193
229
  add(spec.projectFile, runtime);
194
230
  if (options?.includeNativeFiles && spec.nativeFile !== spec.projectFile) {
195
- add(spec.nativeFile, runtime);
231
+ add(spec.nativeFile, runtime, spec.nativeFileMode === "pointer" ? spec.projectFile : undefined);
196
232
  }
197
233
  }
198
234
 
199
- return [...byFile.entries()].map(([filename, rts]) => ({
200
- filename,
201
- targetPath: path.join(cwd, filename),
202
- runtimes: dedupeRuntimes(rts),
203
- }));
235
+ return [...byFile.values()].map((target) => ({ ...target, runtimes: dedupeRuntimes(target.runtimes) }));
204
236
  }
205
237
 
206
238
  // global: each runtime reads its own native file in its own home dir.
@@ -229,18 +261,25 @@ function dedupeRuntimes(runtimes: SkillRuntime[]): SkillRuntime[] {
229
261
  /**
230
262
  * Install an instruction brief into the right per-runtime files.
231
263
  *
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".
264
+ * By default merges `content` into a managed region of `{cwd}/AGENTS.md`,
265
+ * preserving any user-authored content outside the markers. Idempotent: a
266
+ * re-install with unchanged content reports every entry as "skipped".
267
+ *
268
+ * When Claude is among the runtimes, a `{cwd}/CLAUDE.md` already on disk is
269
+ * reconciled, because it would hide AGENTS.md from Claude Code: one holding
270
+ * nothing but this installer's managed region is removed, and one the user
271
+ * wrote gets `@AGENTS.md` in its managed region. With `includeNativeFiles`,
272
+ * CLAUDE.md is written as that pointer either way. `managed: false` leaves an
273
+ * existing CLAUDE.md alone.
236
274
  *
237
275
  * @example
238
276
  * ```ts
239
277
  * // Workspace (repo-root) install — the common case.
240
278
  * await installInstructions(brief, { location: "workspace", cwd: projectDir });
241
- * // → {cwd}/CLAUDE.md + {cwd}/AGENTS.md
279
+ * // → {cwd}/AGENTS.md
242
280
  *
243
- * // Also drop Gemini's native GEMINI.md (it doesn't read AGENTS.md by default).
281
+ * // Also write the native files: CLAUDE.md as an `@AGENTS.md` pointer (for
282
+ * // Claude Code before 2.1.277, or on Bedrock/Vertex/Foundry) and GEMINI.md.
244
283
  * await installInstructions(brief, { location: "workspace", cwd, includeNativeFiles: true });
245
284
  *
246
285
  * // Global install — per-runtime home files.
@@ -254,37 +293,16 @@ export async function installInstructions(
254
293
  ): Promise<InstructionInstallResult> {
255
294
  const managed = options?.managed ?? true;
256
295
  const tag = options?.managedTag ?? DEFAULT_MANAGED_TAG;
257
- const targets = resolveInstructionTargets(options);
258
296
  const entries: InstructionInstallEntry[] = [];
259
297
 
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
- });
298
+ for (const target of resolveInstructionTargets(options)) {
299
+ const body = target.importsFile !== undefined ? `@${target.importsFile}` : content;
300
+ entries.push(await writeTarget(target, body, managed, tag));
301
+ }
302
+ if (managed) {
303
+ for (const target of unrequestedPointerTargets(options)) {
304
+ const entry = await reconcilePointer(target, tag);
305
+ if (entry) entries.push(entry);
288
306
  }
289
307
  }
290
308
 
@@ -293,15 +311,84 @@ export async function installInstructions(
293
311
  installed: entries.filter((e) => e.status === "created").length,
294
312
  updated: entries.filter((e) => e.status === "updated").length,
295
313
  skipped: entries.filter((e) => e.status === "skipped").length,
314
+ removed: entries.filter((e) => e.status === "removed").length,
296
315
  errors: entries.filter((e) => e.status === "error").length,
297
316
  };
298
317
  }
299
318
 
319
+ async function writeTarget(
320
+ target: InstructionTarget,
321
+ body: string,
322
+ managed: boolean,
323
+ tag: string,
324
+ ): Promise<InstructionInstallEntry> {
325
+ try {
326
+ const existing = await readFileOrNull(target.targetPath);
327
+ const next = managed ? upsertManagedBlock(existing, body, { tag }) : ensureTrailingNewline(body);
328
+
329
+ let status: InstructionStatus;
330
+ if (existing === null) {
331
+ status = "created";
332
+ } else if (existing === next) {
333
+ status = "skipped";
334
+ } else {
335
+ status = "updated";
336
+ }
337
+
338
+ if (status !== "skipped") {
339
+ await fs.mkdir(path.dirname(target.targetPath), { recursive: true });
340
+ await fs.writeFile(target.targetPath, next, { mode: 0o644 });
341
+ }
342
+ return { ...target, status };
343
+ } catch (err) {
344
+ return { ...target, status: "error", error: err instanceof Error ? err.message : String(err) };
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Pointer files (a workspace CLAUDE.md) for the requested runtimes that the
350
+ * install doesn't write because the opt-in is off, but that still need
351
+ * reconciling if they exist.
352
+ */
353
+ function unrequestedPointerTargets(options?: InstallInstructionsOptions): InstructionTarget[] {
354
+ if ((options?.location ?? "workspace") !== "workspace" || options?.includeNativeFiles) return [];
355
+ return resolveInstructionTargets({ ...options, includeNativeFiles: true }).filter(
356
+ (target) => target.importsFile !== undefined,
357
+ );
358
+ }
359
+
360
+ /**
361
+ * An existing pointer file with the opt-in off: delete it when nothing but our
362
+ * managed region is in it, otherwise keep the user's content and make sure it
363
+ * imports the project file. Returns null when there is no file.
364
+ */
365
+ async function reconcilePointer(
366
+ target: InstructionTarget,
367
+ tag: string,
368
+ ): Promise<InstructionInstallEntry | null> {
369
+ try {
370
+ const existing = await readFileOrNull(target.targetPath);
371
+ if (existing === null) return null;
372
+ if (stripManagedBlock(existing, { tag }) === null) {
373
+ await fs.rm(target.targetPath, { force: true });
374
+ return { ...target, status: "removed" };
375
+ }
376
+ const next = upsertManagedBlock(existing, `@${target.importsFile}`, { tag });
377
+ if (next === existing) return { ...target, status: "skipped" };
378
+ await fs.writeFile(target.targetPath, next);
379
+ return { ...target, status: "updated" };
380
+ } catch (err) {
381
+ return { ...target, status: "error", error: err instanceof Error ? err.message : String(err) };
382
+ }
383
+ }
384
+
300
385
  /**
301
386
  * Remove the managed region installed by {@link installInstructions}, preserving
302
387
  * any user-authored content outside the markers. If the file contains nothing
303
388
  * but the managed block, it is deleted. User-owned files (no managed region) are
304
- * left untouched and reported as "skipped".
389
+ * left untouched and reported as "skipped". Native files (CLAUDE.md, GEMINI.md)
390
+ * are always checked, whether or not they were installed with
391
+ * `includeNativeFiles`.
305
392
  */
306
393
  export async function removeInstructions(
307
394
  options?: RemoveInstructionsOptions,
@@ -311,6 +398,7 @@ export async function removeInstructions(
311
398
  runtimes: options?.runtimes,
312
399
  location: options?.location,
313
400
  cwd: options?.cwd,
401
+ includeNativeFiles: true,
314
402
  homeDir: options?.homeDir,
315
403
  });
316
404
  const entries: InstructionRemoveEntry[] = [];