@korso/shepherd 0.11.1 → 0.11.2

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 (3) hide show
  1. package/README.md +59 -30
  2. package/dist/index.js +596 -90
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -105,34 +105,45 @@ default `~/.shepherd/inbox`). That file is then drained by two paths:
105
105
  > given client on this machine it **writes to that client's own configuration
106
106
  > file in your home directory**, without a separate prompt:
107
107
  >
108
- > | Client | File it edits/creates | What it adds |
109
- > | ----------- | ------------------------------------------ | ---------------------------------------------------------------- |
110
- > | Claude Code | `~/.claude/settings.json` | `SessionStart` + `PreToolUse` hook entries |
111
- > | Codex | `~/.codex/config.toml` | a `[[hooks.UserPromptSubmit]]` block (+ `features.hooks = true`) |
112
- > | Cursor | `~/.cursor/hooks.json` | a `beforeSubmitPrompt` entry |
113
- > | Pi | `~/.pi/agent/extensions/shepherd-inbox.js` | copies the bundled extension |
108
+ > | Client | File it edits/creates | What it adds |
109
+ > | ----------- | ------------------------------------------ | --------------------------------------------------------- |
110
+ > | Claude Code | `~/.claude/settings.json` | `SessionStart` + `PreToolUse` hook entries |
111
+ > | Codex | `~/.codex/config.toml` | the hooks feature plus all three canonical event handlers |
112
+ > | Cursor | `~/.cursor/hooks.json` | a `beforeSubmitPrompt` entry |
113
+ > | Pi | `~/.pi/agent/extensions/shepherd-inbox.js` | copies the bundled extension |
114
114
  >
115
- > This edit is **additive only** (existing keys/entries are never modified,
116
- > removed, or reordered), **marker-guarded** (attempted **at most once per
117
- > machine+client**, recorded under `~/.shepherd/hooks/`; if you later remove the
118
- > hook it is **never re-added**), **version-pinned** (the installed command runs
119
- > the exact shipped build, not a floating `npx latest`), and **fail-open** (any
120
- > file it can't confidently parse is left untouched with a stderr notice).
115
+ > Fresh installs are **additive only** (existing keys/entries are never removed
116
+ > or reordered), **record-guarded** under `~/.shepherd/hooks/`,
117
+ > **version-pinned** (the installed command runs the exact shipped build, not a
118
+ > floating `npx latest`). Unsupported client config shapes are left untouched;
119
+ > installer and migration failures never block or break the agent session, and
120
+ > failure logs may be emitted to stderr. Codex also has a one-time, versioned
121
+ > migration, but only when it finds both a legacy auto-install record at
122
+ > `~/.shepherd/hooks/codex.json` and the exact Shepherd-owned legacy block. It
123
+ > preserves that block and appends only the missing handlers after saving a
124
+ > persistent backup at
125
+ > `~/.shepherd/hooks/backups/codex-config-before-v2.toml`. Ambiguous or manually
126
+ > removed hooks are left alone. Existing users receive the migration after they
127
+ > update `@korso/shepherd` and restart Codex so the updated MCP server starts.
121
128
  >
122
129
  > **To opt out entirely, set `SHEPHERD_NO_AUTO_HOOKS=1`** — the server then never
123
130
  > touches any client config, and you can wire the hook manually using the
124
131
  > per-client snippets below.
125
132
 
126
133
  On its first `initialize` handshake the server detects the connecting client
127
- and, for Claude Code / Codex / Pi, installs the delivery hook **once per
128
- machine**:
134
+ and installs the delivery hook **once per machine**:
129
135
 
130
136
  - **Claude Code** — merges the `SessionStart` + `PreToolUse` hook entries into
131
137
  `~/.claude/settings.json` (additive JSON merge; an unparseable file is left
132
138
  untouched).
133
- - **Codex** — appends the `[[hooks.UserPromptSubmit]]` block (and
134
- `features.hooks = true`) to `~/.codex/config.toml` (text-level append; it
135
- respects an explicit `hooks = false` and never rewrites existing content).
139
+ - **Codex** — enables `features.hooks` and appends canonical
140
+ `UserPromptSubmit`, `SessionStart`, and wildcard `PreToolUse` handlers to
141
+ `~/.codex/config.toml`. An explicit `hooks = false` is respected. Existing
142
+ installs are migrated only when a legacy `~/.shepherd/hooks/codex.json`
143
+ auto-install record and Shepherd's exact legacy `UserPromptSubmit` block are
144
+ both present. The migration retains that block, appends the two missing
145
+ handlers, and first writes a persistent backup. Update `@korso/shepherd` and
146
+ restart Codex to run it.
136
147
  - **Pi** — copies the bundled extension to
137
148
  `~/.pi/agent/extensions/shepherd-inbox.js`.
138
149
  - **Cursor** — merges a `beforeSubmitPrompt` entry into `~/.cursor/hooks.json`
@@ -140,10 +151,11 @@ machine**:
140
151
  verified to inject hook output into the agent's context, and wiring an
141
152
  unverified event would consume announcements without delivering them.
142
153
 
143
- A record under `~/.shepherd/hooks/` guarantees at-most-once: if you remove the
144
- hook, Shepherd won't re-add it. Everything is fail-open (an error just means no
145
- hook, never a broken session), and `SHEPHERD_NO_AUTO_HOOKS=1` disables the whole
146
- mechanism.
154
+ A record under `~/.shepherd/hooks/` guarantees at-most-once installation (and
155
+ tracks the current Codex migration version): if you remove the hook, Shepherd
156
+ won't re-add it. Installation and migration are fail-open: a failure never
157
+ blocks or breaks the agent session. `SHEPHERD_NO_AUTO_HOOKS=1` disables the
158
+ whole mechanism.
147
159
 
148
160
  Both paths read the **same** inbox file and de-duplicate by announcement id, so
149
161
  running both is safe (the hub hands each announcement to exactly one drain; the
@@ -225,24 +237,41 @@ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
225
237
  }
226
238
  ```
227
239
 
228
- ### Codex — `UserPromptSubmit` hook
240
+ ### Codex — `UserPromptSubmit` + `SessionStart` + `PreToolUse` hooks
229
241
 
230
- _(Installed automatically on first run — shown for reference/manual setup.)_
242
+ _(Installed automatically, with legacy Shepherd configs migrated once — shown
243
+ for reference/manual setup.)_
231
244
 
232
245
  Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
233
- `hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
234
- `UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
235
- or MCP calls. Hooks must be enabled with `features.hooks = true`. In
236
- `~/.codex/config.toml`:
246
+ `hookSpecificOutput.additionalContext` reply), so the **same bin** serves it.
247
+ `UserPromptSubmit` and `SessionStart` cover turn and session boundaries. In
248
+ local Codex testing, wildcard `PreToolUse` delivered before Bash,
249
+ `apply_patch`, and MCP calls. Other richer tool paths, including WebSearch, are
250
+ not guaranteed by the current Codex hook coverage. Hooks must be enabled with
251
+ `features.hooks = true`. In `~/.codex/config.toml`:
237
252
 
238
253
  ```toml
239
254
  [features]
240
255
  hooks = true
241
256
 
242
257
  [[hooks.UserPromptSubmit]]
243
- command = ["npx", "-y", "--package=@korso/shepherd", "shepherd-inbox-hook"]
244
- # On Windows use command_windows instead:
245
- # command_windows = ["cmd", "/c", "npx -y --package=@korso/shepherd shepherd-inbox-hook"]
258
+ [[hooks.UserPromptSubmit.hooks]]
259
+ type = "command"
260
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
261
+ timeout = 20
262
+
263
+ [[hooks.SessionStart]]
264
+ [[hooks.SessionStart.hooks]]
265
+ type = "command"
266
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
267
+ timeout = 20
268
+
269
+ [[hooks.PreToolUse]]
270
+ matcher = "*"
271
+ [[hooks.PreToolUse.hooks]]
272
+ type = "command"
273
+ command = "npx -y --package=@korso/shepherd shepherd-inbox-hook"
274
+ timeout = 20
246
275
  ```
247
276
 
248
277
  ### Pi — extension
package/dist/index.js CHANGED
@@ -2530,16 +2530,565 @@ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
2530
2530
 
2531
2531
  // src/hookInstall.ts
2532
2532
  import {
2533
- readFileSync as readFileSync6,
2534
- writeFileSync as writeFileSync6,
2535
- mkdirSync as mkdirSync5,
2533
+ readFileSync as readFileSync8,
2534
+ writeFileSync as writeFileSync7,
2535
+ mkdirSync as mkdirSync6,
2536
2536
  copyFileSync,
2537
- existsSync as existsSync4,
2538
- renameSync as renameSync2
2537
+ existsSync as existsSync6,
2538
+ renameSync as renameSync3
2539
2539
  } from "node:fs";
2540
2540
  import { homedir as homedir5 } from "node:os";
2541
- import { dirname as dirname6, join as join5 } from "node:path";
2541
+ import { dirname as dirname7, join as join7 } from "node:path";
2542
2542
  import { fileURLToPath } from "node:url";
2543
+
2544
+ // src/codexHookMigration.ts
2545
+ import { existsSync as existsSync5, readFileSync as readFileSync7 } from "node:fs";
2546
+ import { createHash as createHash3 } from "node:crypto";
2547
+ import { join as join6 } from "node:path";
2548
+ import { z as z5 } from "zod";
2549
+
2550
+ // src/codexHookInstall.ts
2551
+ import { parse, TomlDate } from "smol-toml";
2552
+ var CODEX_SHEPHERD_COMMENT = "# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.";
2553
+ function canonicalHandlerBlock(event, command, matcher) {
2554
+ return [
2555
+ "[[hooks." + event + "]]",
2556
+ ...matcher === void 0 ? [] : ["matcher = " + JSON.stringify(matcher)],
2557
+ "[[hooks." + event + ".hooks]]",
2558
+ 'type = "command"',
2559
+ "command = " + JSON.stringify(command),
2560
+ "timeout = 20",
2561
+ ""
2562
+ ].join("\n");
2563
+ }
2564
+ function canonicalHookBlock(command) {
2565
+ return [
2566
+ "",
2567
+ CODEX_SHEPHERD_COMMENT,
2568
+ canonicalHandlerBlock("UserPromptSubmit", command),
2569
+ canonicalHandlerBlock("SessionStart", command),
2570
+ canonicalHandlerBlock("PreToolUse", command, "*")
2571
+ ].join("\n");
2572
+ }
2573
+ function parseConfig2(source) {
2574
+ try {
2575
+ return parse(source, { integersAsBigInt: false });
2576
+ } catch {
2577
+ return null;
2578
+ }
2579
+ }
2580
+ function isTomlTable(value) {
2581
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof TomlDate);
2582
+ }
2583
+ function hasEnabledHooks(config) {
2584
+ const features = config["features"];
2585
+ return isTomlTable(features) && features["hooks"] === true;
2586
+ }
2587
+ function insertHooksFeature(source) {
2588
+ const featuresHeader = /^(\s*\[features\]\s*(?:#.*)?)$/gm;
2589
+ let match;
2590
+ while ((match = featuresHeader.exec(source)) !== null) {
2591
+ const insertionPoint = match.index + match[0].length;
2592
+ const candidate = source.slice(0, insertionPoint) + "\nhooks = true" + source.slice(insertionPoint);
2593
+ const config = parseConfig2(candidate);
2594
+ if (config !== null && hasEnabledHooks(config)) return candidate;
2595
+ }
2596
+ return null;
2597
+ }
2598
+ function installCandidate(source, config, command) {
2599
+ const features = config["features"];
2600
+ let candidate;
2601
+ if (isTomlTable(features)) {
2602
+ if (Object.prototype.hasOwnProperty.call(features, "hooks")) {
2603
+ candidate = features["hooks"] === true ? source + canonicalHookBlock(command) : null;
2604
+ } else {
2605
+ const withFeature = insertHooksFeature(source);
2606
+ candidate = withFeature === null ? null : withFeature + canonicalHookBlock(command);
2607
+ }
2608
+ } else if (features === void 0) {
2609
+ candidate = source + (source.length === 0 ? "" : "\n") + "[features]\nhooks = true\n" + canonicalHookBlock(command);
2610
+ } else {
2611
+ candidate = null;
2612
+ }
2613
+ const parsed = candidate === null ? null : parseConfig2(candidate);
2614
+ return parsed !== null && hasEnabledHooks(parsed) ? candidate : null;
2615
+ }
2616
+ function planCodexConfig(source, command) {
2617
+ const config = parseConfig2(source);
2618
+ if (config === null) return { kind: "skip", outcome: "unsupported-shape" };
2619
+ const features = config["features"];
2620
+ if (isTomlTable(features) && features["hooks"] !== void 0) {
2621
+ if (features["hooks"] === false) {
2622
+ return { kind: "skip", outcome: "opted-out" };
2623
+ }
2624
+ if (features["hooks"] !== true) {
2625
+ return { kind: "skip", outcome: "unsupported-shape" };
2626
+ }
2627
+ } else if (features !== void 0 && !isTomlTable(features)) {
2628
+ return { kind: "skip", outcome: "unsupported-shape" };
2629
+ }
2630
+ if (source.includes(canonicalHookBlock(command))) {
2631
+ return { kind: "already-canonical" };
2632
+ }
2633
+ const candidate = installCandidate(source, config, command);
2634
+ return candidate === null ? { kind: "skip", outcome: "unsupported-shape" } : { kind: "install", candidate };
2635
+ }
2636
+ function appendMissingCodexHandlers(source, command) {
2637
+ const handlers = [
2638
+ canonicalHandlerBlock("SessionStart", command),
2639
+ canonicalHandlerBlock("PreToolUse", command, "*")
2640
+ ].filter((handler) => !source.includes(handler));
2641
+ const candidate = handlers.length === 0 ? source : source + (source.endsWith("\n") ? "" : "\n") + handlers.join("");
2642
+ return parseConfig2(candidate) === null ? null : candidate;
2643
+ }
2644
+
2645
+ // src/codexHookFs.ts
2646
+ import {
2647
+ chmodSync,
2648
+ closeSync,
2649
+ existsSync as existsSync4,
2650
+ fchmodSync,
2651
+ fsyncSync,
2652
+ linkSync,
2653
+ mkdirSync as mkdirSync5,
2654
+ openSync,
2655
+ readFileSync as readFileSync6,
2656
+ renameSync as renameSync2,
2657
+ statSync as statSync2,
2658
+ unlinkSync,
2659
+ writeFileSync as writeFileSync6
2660
+ } from "node:fs";
2661
+ import { createHash as createHash2, randomUUID } from "node:crypto";
2662
+ import { basename as basename2, dirname as dirname6, join as join5 } from "node:path";
2663
+ import { z as z4 } from "zod";
2664
+ var STALE_LOCK_MS = 3e4;
2665
+ var lockSchema = z4.object({
2666
+ pid: z4.number().int().positive(),
2667
+ createdAt: z4.string(),
2668
+ owner: z4.string().min(1).optional()
2669
+ });
2670
+ function errorCode(error) {
2671
+ return error instanceof Error && "code" in error ? String(error.code) : void 0;
2672
+ }
2673
+ function modeOf(path3) {
2674
+ try {
2675
+ return statSync2(path3).mode & 511;
2676
+ } catch {
2677
+ return 384;
2678
+ }
2679
+ }
2680
+ function syncParent(path3) {
2681
+ if (process.platform === "win32") return;
2682
+ const descriptor = openSync(dirname6(path3), "r");
2683
+ try {
2684
+ fsyncSync(descriptor);
2685
+ } finally {
2686
+ closeSync(descriptor);
2687
+ }
2688
+ }
2689
+ function durableTemp(path3, contents, mode) {
2690
+ const descriptor = openSync(path3, "wx", mode);
2691
+ try {
2692
+ writeFileSync6(descriptor, contents);
2693
+ fchmodSync(descriptor, mode);
2694
+ fsyncSync(descriptor);
2695
+ } finally {
2696
+ closeSync(descriptor);
2697
+ }
2698
+ }
2699
+ function atomicWrite(path3, contents) {
2700
+ mkdirSync5(dirname6(path3), { recursive: true });
2701
+ const temporary = join5(
2702
+ dirname6(path3),
2703
+ "." + basename2(path3) + "." + process.pid + "." + randomUUID() + ".tmp"
2704
+ );
2705
+ try {
2706
+ durableTemp(temporary, contents, modeOf(path3));
2707
+ renameSync2(temporary, path3);
2708
+ syncParent(path3);
2709
+ } catch (error) {
2710
+ try {
2711
+ unlinkSync(temporary);
2712
+ } catch {
2713
+ }
2714
+ throw error;
2715
+ }
2716
+ }
2717
+ function processIsLive(pid) {
2718
+ try {
2719
+ process.kill(pid, 0);
2720
+ return true;
2721
+ } catch (error) {
2722
+ return errorCode(error) !== "ESRCH";
2723
+ }
2724
+ }
2725
+ function lockSnapshot(lockFile) {
2726
+ try {
2727
+ const bytes = readFileSync6(lockFile);
2728
+ const modifiedAt = statSync2(lockFile).mtimeMs;
2729
+ let owner = null;
2730
+ let reclaimable = false;
2731
+ try {
2732
+ const decoded = JSON.parse(bytes.toString("utf8"));
2733
+ const parsed = lockSchema.safeParse(decoded);
2734
+ if (parsed.success) {
2735
+ owner = parsed.data.owner ?? null;
2736
+ const createdAt = Date.parse(parsed.data.createdAt);
2737
+ const ageBasis = Number.isFinite(createdAt) ? createdAt : modifiedAt;
2738
+ reclaimable = Date.now() - ageBasis > STALE_LOCK_MS && !processIsLive(parsed.data.pid);
2739
+ }
2740
+ } catch {
2741
+ }
2742
+ return { bytes, owner, reclaimable };
2743
+ } catch {
2744
+ return null;
2745
+ }
2746
+ }
2747
+ function lockContents(owner) {
2748
+ return JSON.stringify({
2749
+ pid: process.pid,
2750
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2751
+ owner
2752
+ });
2753
+ }
2754
+ function createOwnedLock(lockFile, owner) {
2755
+ const temporary = lockFile + ".owner-" + owner + ".tmp";
2756
+ try {
2757
+ durableTemp(temporary, lockContents(owner), 384);
2758
+ linkSync(temporary, lockFile);
2759
+ return true;
2760
+ } catch {
2761
+ return false;
2762
+ } finally {
2763
+ try {
2764
+ unlinkSync(temporary);
2765
+ } catch {
2766
+ }
2767
+ }
2768
+ }
2769
+ function ownedBy(lockFile, owner) {
2770
+ try {
2771
+ const decoded = JSON.parse(readFileSync6(lockFile, "utf8"));
2772
+ const parsed = lockSchema.safeParse(decoded);
2773
+ return parsed.success && parsed.data.owner === owner;
2774
+ } catch {
2775
+ return false;
2776
+ }
2777
+ }
2778
+ function removeOwnedLock(lockFile, owner) {
2779
+ if (ownedBy(lockFile, owner)) unlinkSync(lockFile);
2780
+ }
2781
+ function snapshotMatches(current, expected) {
2782
+ return current !== null && current.reclaimable && current.bytes.equals(expected.bytes);
2783
+ }
2784
+ function nextReclaimClaim(rootClaim, snapshot) {
2785
+ const generation = createHash2("sha256").update(snapshot.bytes).digest("hex");
2786
+ return rootClaim + ".generation-" + generation;
2787
+ }
2788
+ function acquireReclaimClaim(rootClaim, owner) {
2789
+ let claimFile = rootClaim;
2790
+ for (let generation = 0; generation < 32; generation += 1) {
2791
+ if (createOwnedLock(claimFile, owner)) return claimFile;
2792
+ const snapshot = lockSnapshot(claimFile);
2793
+ if (snapshot === null || !snapshot.reclaimable || snapshot.owner === null) {
2794
+ return null;
2795
+ }
2796
+ claimFile = nextReclaimClaim(rootClaim, snapshot);
2797
+ }
2798
+ return null;
2799
+ }
2800
+ function replaceStaleLock(lockFile, claimFile, owner, expected) {
2801
+ const replacement = lockFile + ".replacement-" + owner;
2802
+ let published = false;
2803
+ try {
2804
+ durableTemp(replacement, lockContents(owner), 384);
2805
+ if (!ownedBy(claimFile, owner)) return false;
2806
+ if (!snapshotMatches(lockSnapshot(lockFile), expected)) return false;
2807
+ renameSync2(replacement, lockFile);
2808
+ published = true;
2809
+ syncParent(lockFile);
2810
+ if (ownedBy(claimFile, owner)) return true;
2811
+ removeOwnedLock(lockFile, owner);
2812
+ return false;
2813
+ } catch {
2814
+ if (published) removeOwnedLock(lockFile, owner);
2815
+ return false;
2816
+ } finally {
2817
+ try {
2818
+ unlinkSync(replacement);
2819
+ } catch {
2820
+ }
2821
+ }
2822
+ }
2823
+ function acquireMigrationLock(lockFile) {
2824
+ mkdirSync5(dirname6(lockFile), { recursive: true });
2825
+ const owner = randomUUID();
2826
+ if (createOwnedLock(lockFile, owner)) return owner;
2827
+ const snapshot = lockSnapshot(lockFile);
2828
+ if (snapshot === null || !snapshot.reclaimable) return null;
2829
+ const claimFile = acquireReclaimClaim(lockFile + ".reclaim", owner);
2830
+ if (claimFile === null) return null;
2831
+ try {
2832
+ return replaceStaleLock(lockFile, claimFile, owner, snapshot) ? owner : null;
2833
+ } finally {
2834
+ try {
2835
+ removeOwnedLock(claimFile, owner);
2836
+ } catch {
2837
+ }
2838
+ }
2839
+ }
2840
+ function releaseMigrationLock(lockFile, owner, log) {
2841
+ try {
2842
+ removeOwnedLock(lockFile, owner);
2843
+ } catch (error) {
2844
+ log(
2845
+ "[shepherd] Codex hook migration lock cleanup failed: " + String(error)
2846
+ );
2847
+ }
2848
+ }
2849
+ function validateBackup(backupFile, source) {
2850
+ if (!readFileSync6(backupFile).equals(source)) {
2851
+ throw new Error("existing Codex migration backup does not match config");
2852
+ }
2853
+ chmodSync(backupFile, 384);
2854
+ }
2855
+ function ensureMigrationBackup(backupFile, source) {
2856
+ const backupDirectory = dirname6(backupFile);
2857
+ mkdirSync5(backupDirectory, { recursive: true });
2858
+ syncParent(backupDirectory);
2859
+ if (existsSync4(backupFile)) {
2860
+ validateBackup(backupFile, source);
2861
+ syncParent(backupFile);
2862
+ return;
2863
+ }
2864
+ const temporary = backupFile + "." + process.pid + "." + randomUUID() + ".tmp";
2865
+ try {
2866
+ durableTemp(temporary, source, 384);
2867
+ try {
2868
+ linkSync(temporary, backupFile);
2869
+ } catch (error) {
2870
+ if (errorCode(error) !== "EEXIST") throw error;
2871
+ validateBackup(backupFile, source);
2872
+ }
2873
+ syncParent(backupFile);
2874
+ } finally {
2875
+ try {
2876
+ unlinkSync(temporary);
2877
+ } catch {
2878
+ }
2879
+ }
2880
+ }
2881
+
2882
+ // src/codexHookMigration.ts
2883
+ var MIGRATION_VERSION = 2;
2884
+ var migrationOutcomeSchema = z5.enum([
2885
+ "migrated",
2886
+ "already-canonical",
2887
+ "user-removed",
2888
+ "ambiguous",
2889
+ "opted-out",
2890
+ "unsupported-shape"
2891
+ ]);
2892
+ var recordSchema = z5.object({
2893
+ status: z5.string(),
2894
+ at: z5.string(),
2895
+ migrationVersion: z5.number().int().nonnegative().optional(),
2896
+ migrationOutcome: migrationOutcomeSchema.optional()
2897
+ }).passthrough();
2898
+ function migrationPaths(homeDir) {
2899
+ const hooksDir = join6(homeDir, ".shepherd", "hooks");
2900
+ return {
2901
+ hooksDir,
2902
+ recordFile: join6(hooksDir, "codex.json"),
2903
+ lockFile: join6(hooksDir, "codex-migration-v2.lock"),
2904
+ backupFile: join6(hooksDir, "backups", "codex-config-before-v2.toml"),
2905
+ configFile: join6(homeDir, ".codex", "config.toml")
2906
+ };
2907
+ }
2908
+ function readRecord(recordFile) {
2909
+ if (!existsSync5(recordFile)) return { kind: "none" };
2910
+ try {
2911
+ const decoded = JSON.parse(readFileSync7(recordFile, "utf8"));
2912
+ const parsed = recordSchema.safeParse(decoded);
2913
+ if (!parsed.success) return { kind: "corrupt" };
2914
+ const record = parsed.data;
2915
+ const version = parsed.data.migrationVersion;
2916
+ if (version === void 0 || version < MIGRATION_VERSION) {
2917
+ return { kind: "legacy", record };
2918
+ }
2919
+ if (version > MIGRATION_VERSION) return { kind: "future", record };
2920
+ return parsed.data.migrationOutcome === void 0 ? { kind: "corrupt" } : { kind: "current", record };
2921
+ } catch {
2922
+ return { kind: "corrupt" };
2923
+ }
2924
+ }
2925
+ function migrationRecord(prior, status, outcome) {
2926
+ return JSON.stringify(
2927
+ {
2928
+ ...prior,
2929
+ status: typeof prior?.["status"] === "string" ? prior["status"] : status,
2930
+ at: typeof prior?.["at"] === "string" ? prior["at"] : (/* @__PURE__ */ new Date()).toISOString(),
2931
+ migrationVersion: MIGRATION_VERSION,
2932
+ migrationOutcome: outcome
2933
+ },
2934
+ null,
2935
+ 2
2936
+ ) + "\n";
2937
+ }
2938
+ function fingerprint(bytes) {
2939
+ return createHash3("sha256").update(bytes).digest("hex");
2940
+ }
2941
+ function readConfigBytes(configFile) {
2942
+ return existsSync5(configFile) ? readFileSync7(configFile) : Buffer.alloc(0);
2943
+ }
2944
+ function decodeConfig(bytes) {
2945
+ try {
2946
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
2947
+ } catch {
2948
+ return null;
2949
+ }
2950
+ }
2951
+ function advanceRecord(recordFile, state, status, outcome) {
2952
+ atomicWrite(recordFile, migrationRecord(state.record, status, outcome));
2953
+ }
2954
+ function legacyHookBlock(commandValue) {
2955
+ return [
2956
+ "",
2957
+ CODEX_SHEPHERD_COMMENT,
2958
+ "[[hooks.UserPromptSubmit]]",
2959
+ "command = " + commandValue,
2960
+ ""
2961
+ ].join("\n");
2962
+ }
2963
+ function exactOwnedLegacyBlock(source, hooksDir) {
2964
+ const semver = "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)";
2965
+ const pinned = new RegExp(
2966
+ '^command = (\\["npx", "-y", "--package=@korso/shepherd@' + semver + '", "shepherd-inbox-hook"\\])$',
2967
+ "gm"
2968
+ );
2969
+ const candidates = Array.from(
2970
+ source.matchAll(pinned),
2971
+ (match) => legacyHookBlock(match[1])
2972
+ );
2973
+ candidates.push(
2974
+ legacyHookBlock(
2975
+ '["node", ' + JSON.stringify(join6(hooksDir, "shepherd-inbox-hook.mjs")) + "]"
2976
+ )
2977
+ );
2978
+ const exact = candidates.filter(
2979
+ (block) => source.indexOf(block) >= 0 && source.indexOf(block) === source.lastIndexOf(block)
2980
+ );
2981
+ return exact.length === 1 ? exact[0] : void 0;
2982
+ }
2983
+ function migrateLegacy(context, state, sourceBytes, source) {
2984
+ const { paths, command, log } = context;
2985
+ const candidate = appendMissingCodexHandlers(source, command);
2986
+ if (candidate === null) {
2987
+ advanceRecord(paths.recordFile, state, "skipped", "unsupported-shape");
2988
+ return "skipped";
2989
+ }
2990
+ const recordBytes = readFileSync7(paths.recordFile);
2991
+ const configChanged = candidate !== source;
2992
+ ensureMigrationBackup(paths.backupFile, sourceBytes);
2993
+ if (!readConfigBytes(paths.configFile).equals(sourceBytes)) return "skipped";
2994
+ try {
2995
+ if (configChanged) atomicWrite(paths.configFile, candidate);
2996
+ advanceRecord(paths.recordFile, state, "installed", "migrated");
2997
+ } catch (error) {
2998
+ if (!readConfigBytes(paths.configFile).equals(sourceBytes)) {
2999
+ atomicWrite(paths.configFile, sourceBytes);
3000
+ }
3001
+ if (!readFileSync7(paths.recordFile).equals(recordBytes)) {
3002
+ atomicWrite(paths.recordFile, recordBytes);
3003
+ }
3004
+ throw error;
3005
+ }
3006
+ log(
3007
+ "[shepherd] Migrated Codex hooks. Persistent backup: " + paths.backupFile + "; you may remove it after validation."
3008
+ );
3009
+ return "installed";
3010
+ }
3011
+ function processLockedConfig(context, expectedFingerprint) {
3012
+ const { paths, command, hookMarker } = context;
3013
+ const state = readRecord(paths.recordFile);
3014
+ if (state.kind === "current" || state.kind === "future") {
3015
+ return "already-attempted";
3016
+ }
3017
+ if (state.kind === "corrupt") return "skipped";
3018
+ const sourceBytes = readConfigBytes(paths.configFile);
3019
+ if (fingerprint(sourceBytes) !== expectedFingerprint) return "skipped";
3020
+ const source = decodeConfig(sourceBytes);
3021
+ if (source === null) {
3022
+ advanceRecord(paths.recordFile, state, "skipped", "unsupported-shape");
3023
+ return "skipped";
3024
+ }
3025
+ const plan = planCodexConfig(source, command);
3026
+ if (plan.kind === "skip") {
3027
+ advanceRecord(paths.recordFile, state, "skipped", plan.outcome);
3028
+ return "skipped";
3029
+ }
3030
+ if (plan.kind === "already-canonical") {
3031
+ advanceRecord(
3032
+ paths.recordFile,
3033
+ state,
3034
+ "already-present",
3035
+ "already-canonical"
3036
+ );
3037
+ return "already-present";
3038
+ }
3039
+ const ownedBlock = exactOwnedLegacyBlock(source, paths.hooksDir);
3040
+ if (state.kind === "legacy" && ownedBlock !== void 0) {
3041
+ return migrateLegacy(context, state, sourceBytes, source);
3042
+ }
3043
+ if (ownedBlock !== void 0 || source.includes(CODEX_SHEPHERD_COMMENT) || source.includes(hookMarker)) {
3044
+ advanceRecord(paths.recordFile, state, "already-present", "ambiguous");
3045
+ return "already-present";
3046
+ }
3047
+ if (state.kind === "legacy") {
3048
+ advanceRecord(paths.recordFile, state, "skipped", "user-removed");
3049
+ return "skipped";
3050
+ }
3051
+ atomicWrite(paths.configFile, plan.candidate);
3052
+ advanceRecord(paths.recordFile, state, "installed", "already-canonical");
3053
+ return "installed";
3054
+ }
3055
+ async function installCodexHooks({
3056
+ homeDir,
3057
+ command,
3058
+ hookMarker,
3059
+ log
3060
+ }) {
3061
+ const paths = migrationPaths(homeDir);
3062
+ const context = { paths, command, hookMarker, log };
3063
+ const initialRecord = readRecord(paths.recordFile);
3064
+ if (initialRecord.kind === "current" || initialRecord.kind === "future") {
3065
+ return "already-attempted";
3066
+ }
3067
+ if (initialRecord.kind === "corrupt") return "skipped";
3068
+ let initialBytes;
3069
+ try {
3070
+ initialBytes = readConfigBytes(paths.configFile);
3071
+ } catch (error) {
3072
+ log(
3073
+ "[shepherd] Codex hook migration could not read config: " + String(error)
3074
+ );
3075
+ return "skipped";
3076
+ }
3077
+ const expectedFingerprint = fingerprint(initialBytes);
3078
+ const owner = acquireMigrationLock(paths.lockFile);
3079
+ if (owner === null) return "skipped";
3080
+ try {
3081
+ await Promise.resolve();
3082
+ return processLockedConfig(context, expectedFingerprint);
3083
+ } catch (error) {
3084
+ log("[shepherd] Codex hook migration skipped: " + String(error));
3085
+ return "skipped";
3086
+ } finally {
3087
+ releaseMigrationLock(paths.lockFile, owner, log);
3088
+ }
3089
+ }
3090
+
3091
+ // src/hookInstall.ts
2543
3092
  function detectClient(clientName) {
2544
3093
  const name = (clientName ?? "").toLowerCase();
2545
3094
  if (!name) return "unknown";
@@ -2552,17 +3101,17 @@ function detectClient(clientName) {
2552
3101
  var HOOK_COMMAND = `npx -y --package=@korso/shepherd@${PACKAGE_VERSION} shepherd-inbox-hook`;
2553
3102
  var HOOK_MARKER = "shepherd-inbox-hook";
2554
3103
  function ensureHookScript(homeDir, hookScriptSource) {
2555
- const source = hookScriptSource ?? join5(dirname6(fileURLToPath(import.meta.url)), "inboxHook.js");
3104
+ const source = hookScriptSource ?? join7(dirname7(fileURLToPath(import.meta.url)), "inboxHook.js");
2556
3105
  try {
2557
- if (!existsSync4(source)) return null;
2558
- const dest = join5(homeDir, ".shepherd", "hooks", "shepherd-inbox-hook.mjs");
2559
- const next = readFileSync6(source);
2560
- const current = existsSync4(dest) ? readFileSync6(dest) : null;
3106
+ if (!existsSync6(source)) return null;
3107
+ const dest = join7(homeDir, ".shepherd", "hooks", "shepherd-inbox-hook.mjs");
3108
+ const next = readFileSync8(source);
3109
+ const current = existsSync6(dest) ? readFileSync8(dest) : null;
2561
3110
  if (current === null || !current.equals(next)) {
2562
- mkdirSync5(dirname6(dest), { recursive: true });
3111
+ mkdirSync6(dirname7(dest), { recursive: true });
2563
3112
  const tmp = dest + ".tmp";
2564
- writeFileSync6(tmp, next);
2565
- renameSync2(tmp, dest);
3113
+ writeFileSync7(tmp, next);
3114
+ renameSync3(tmp, dest);
2566
3115
  }
2567
3116
  return dest;
2568
3117
  } catch {
@@ -2572,16 +3121,6 @@ function ensureHookScript(homeDir, hookScriptSource) {
2572
3121
  function hookCommandFor(scriptPath) {
2573
3122
  return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
2574
3123
  }
2575
- function codexHookBlock(scriptPath) {
2576
- const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
2577
- return [
2578
- "",
2579
- "# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.",
2580
- "[[hooks.UserPromptSubmit]]",
2581
- `command = ${command}`,
2582
- ""
2583
- ].join("\n");
2584
- }
2585
3124
  async function autoInstallHooks({
2586
3125
  clientName,
2587
3126
  homeDir = homedir5(),
@@ -2597,20 +3136,32 @@ async function autoInstallHooks({
2597
3136
  return { client, status: "unsupported" };
2598
3137
  }
2599
3138
  const scriptPath = ensureHookScript(homeDir, hookScriptSource);
2600
- const recordFile = join5(homeDir, ".shepherd", "hooks", `${client}.json`);
2601
- if (existsSync4(recordFile)) return { client, status: "already-attempted" };
3139
+ const recordFile = join7(homeDir, ".shepherd", "hooks", `${client}.json`);
3140
+ if (client === "codex") {
3141
+ const status2 = await installCodexHooks({
3142
+ homeDir,
3143
+ command: hookCommandFor(scriptPath),
3144
+ hookMarker: HOOK_MARKER,
3145
+ log
3146
+ });
3147
+ if (status2 === "installed") {
3148
+ log(
3149
+ "[shepherd] Installed the announcement-delivery hook for codex (disable by removing it, or set SHEPHERD_NO_AUTO_HOOKS=1 to never auto-install)."
3150
+ );
3151
+ }
3152
+ return { client, status: status2 };
3153
+ }
3154
+ if (existsSync6(recordFile)) return { client, status: "already-attempted" };
2602
3155
  let status;
2603
3156
  if (client === "claude") {
2604
3157
  status = installClaude(homeDir, scriptPath, log);
2605
- } else if (client === "codex") {
2606
- status = installCodex(homeDir, scriptPath, log);
2607
3158
  } else if (client === "cursor") {
2608
3159
  status = installCursor(homeDir, scriptPath, log);
2609
3160
  } else {
2610
3161
  status = installPi(homeDir, extensionSource, log);
2611
3162
  }
2612
- mkdirSync5(dirname6(recordFile), { recursive: true });
2613
- writeFileSync6(
3163
+ mkdirSync6(dirname7(recordFile), { recursive: true });
3164
+ writeFileSync7(
2614
3165
  recordFile,
2615
3166
  JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
2616
3167
  "utf8"
@@ -2629,10 +3180,10 @@ async function autoInstallHooks({
2629
3180
  }
2630
3181
  }
2631
3182
  function installClaude(homeDir, scriptPath, log) {
2632
- const settingsFile = join5(homeDir, ".claude", "settings.json");
3183
+ const settingsFile = join7(homeDir, ".claude", "settings.json");
2633
3184
  let raw = "";
2634
- if (existsSync4(settingsFile)) {
2635
- raw = readFileSync6(settingsFile, "utf8");
3185
+ if (existsSync6(settingsFile)) {
3186
+ raw = readFileSync8(settingsFile, "utf8");
2636
3187
  if (raw.includes(HOOK_MARKER)) return "already-present";
2637
3188
  }
2638
3189
  let settings = {};
@@ -2675,60 +3226,15 @@ function installClaude(homeDir, scriptPath, log) {
2675
3226
  matcher: "*",
2676
3227
  hooks: [{ type: "command", command }]
2677
3228
  });
2678
- mkdirSync5(dirname6(settingsFile), { recursive: true });
2679
- writeFileSync6(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2680
- return "installed";
2681
- }
2682
- function installCodex(homeDir, scriptPath, log) {
2683
- const configFile = join5(homeDir, ".codex", "config.toml");
2684
- const manualHint = "Add the hook manually (see the dashboard's Connect screen).";
2685
- const hookBlock = codexHookBlock(scriptPath);
2686
- if (!existsSync4(configFile)) {
2687
- mkdirSync5(dirname6(configFile), { recursive: true });
2688
- writeFileSync6(configFile, `[features]
2689
- hooks = true
2690
- ${hookBlock}`, "utf8");
2691
- return "installed";
2692
- }
2693
- const toml = readFileSync6(configFile, "utf8");
2694
- if (toml.includes(HOOK_MARKER)) return "already-present";
2695
- if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
2696
- log(
2697
- `[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`
2698
- );
2699
- return "skipped";
2700
- }
2701
- if (/^\s*\[features\]/m.test(toml)) {
2702
- const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
2703
- if (hooksKey && hooksKey[1].trim() !== "true") {
2704
- log(
2705
- `[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`
2706
- );
2707
- return "skipped";
2708
- }
2709
- let updated = toml;
2710
- if (!hooksKey) {
2711
- updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
2712
- hooks = true`);
2713
- }
2714
- writeFileSync6(configFile, updated + hookBlock, "utf8");
2715
- return "installed";
2716
- }
2717
- writeFileSync6(
2718
- configFile,
2719
- `${toml}
2720
- [features]
2721
- hooks = true
2722
- ${hookBlock}`,
2723
- "utf8"
2724
- );
3229
+ mkdirSync6(dirname7(settingsFile), { recursive: true });
3230
+ writeFileSync7(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
2725
3231
  return "installed";
2726
3232
  }
2727
3233
  function installCursor(homeDir, scriptPath, log) {
2728
- const hooksFile = join5(homeDir, ".cursor", "hooks.json");
3234
+ const hooksFile = join7(homeDir, ".cursor", "hooks.json");
2729
3235
  let raw = "";
2730
- if (existsSync4(hooksFile)) {
2731
- raw = readFileSync6(hooksFile, "utf8");
3236
+ if (existsSync6(hooksFile)) {
3237
+ raw = readFileSync8(hooksFile, "utf8");
2732
3238
  if (raw.includes(HOOK_MARKER)) return "already-present";
2733
3239
  }
2734
3240
  let config = {};
@@ -2763,21 +3269,21 @@ function installCursor(homeDir, scriptPath, log) {
2763
3269
  return "skipped";
2764
3270
  }
2765
3271
  entries.push({ command: hookCommandFor(scriptPath) });
2766
- mkdirSync5(dirname6(hooksFile), { recursive: true });
2767
- writeFileSync6(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
3272
+ mkdirSync6(dirname7(hooksFile), { recursive: true });
3273
+ writeFileSync7(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
2768
3274
  return "installed";
2769
3275
  }
2770
3276
  function installPi(homeDir, extensionSource, log) {
2771
- const source = extensionSource ?? join5(dirname6(fileURLToPath(import.meta.url)), "inboxExtension.js");
2772
- const dest = join5(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
2773
- if (existsSync4(dest)) return "already-present";
2774
- if (!existsSync4(source)) {
3277
+ const source = extensionSource ?? join7(dirname7(fileURLToPath(import.meta.url)), "inboxExtension.js");
3278
+ const dest = join7(homeDir, ".pi", "agent", "extensions", "shepherd-inbox.js");
3279
+ if (existsSync6(dest)) return "already-present";
3280
+ if (!existsSync6(source)) {
2775
3281
  log(
2776
3282
  `[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`
2777
3283
  );
2778
3284
  return "skipped";
2779
3285
  }
2780
- mkdirSync5(dirname6(dest), { recursive: true });
3286
+ mkdirSync6(dirname7(dest), { recursive: true });
2781
3287
  copyFileSync(source, dest);
2782
3288
  return "installed";
2783
3289
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) advisory cross-session coordination tools (work/done/announce/sync, plus link/unlink/decline) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "homepage": "https://github.com/Korso-AI/shepherd#readme",
6
6
  "bugs": {
@@ -46,6 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@modelcontextprotocol/sdk": "^1.29",
49
+ "smol-toml": "1.7.0",
49
50
  "zod": "^3"
50
51
  },
51
52
  "devDependencies": {