@korso/shepherd 0.11.0 → 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.
- package/README.md +59 -30
- package/dist/index.js +610 -90
- 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` |
|
|
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
|
-
>
|
|
116
|
-
>
|
|
117
|
-
>
|
|
118
|
-
>
|
|
119
|
-
>
|
|
120
|
-
>
|
|
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
|
|
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** —
|
|
134
|
-
`
|
|
135
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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`
|
|
240
|
+
### Codex — `UserPromptSubmit` + `SessionStart` + `PreToolUse` hooks
|
|
229
241
|
|
|
230
|
-
_(Installed automatically
|
|
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.
|
|
234
|
-
`UserPromptSubmit`
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
@@ -1723,8 +1723,12 @@ ${nudge}` : text;
|
|
|
1723
1723
|
syncToolSurface();
|
|
1724
1724
|
}
|
|
1725
1725
|
let joinFailure = null;
|
|
1726
|
+
let joinFailureTransient = false;
|
|
1727
|
+
let targetWorkspaceSlug = null;
|
|
1728
|
+
let gateRetry = null;
|
|
1726
1729
|
let joinInFlight = Promise.resolve();
|
|
1727
1730
|
async function activate(workspaceSlug) {
|
|
1731
|
+
targetWorkspaceSlug = workspaceSlug;
|
|
1728
1732
|
if (sessionId !== null && activeWorkspaceSlug === workspaceSlug) {
|
|
1729
1733
|
return { ok: true };
|
|
1730
1734
|
}
|
|
@@ -1744,6 +1748,7 @@ ${nudge}` : text;
|
|
|
1744
1748
|
const parsed = JoinResponse.safeParse(raw);
|
|
1745
1749
|
if (!parsed.success || !parsed.data.sessionId) {
|
|
1746
1750
|
joinFailure = "validation";
|
|
1751
|
+
joinFailureTransient = false;
|
|
1747
1752
|
console.error(
|
|
1748
1753
|
"[shepherd] join failed (validation): hub returned a malformed join response (no usable sessionId)"
|
|
1749
1754
|
);
|
|
@@ -1759,10 +1764,12 @@ ${nudge}` : text;
|
|
|
1759
1764
|
linked = true;
|
|
1760
1765
|
hostedWorkspaceRejected = false;
|
|
1761
1766
|
joinFailure = null;
|
|
1767
|
+
joinFailureTransient = false;
|
|
1762
1768
|
return { ok: true };
|
|
1763
1769
|
} catch (err) {
|
|
1764
1770
|
const reason = classifyActivateFailure(err);
|
|
1765
1771
|
joinFailure = classifyJoinFailure(err);
|
|
1772
|
+
joinFailureTransient = err instanceof HubUnreachable || err instanceof HubRequestError && reason === "unknown";
|
|
1766
1773
|
if (reason === "workspaceRejected") {
|
|
1767
1774
|
hostedWorkspaceRejected = true;
|
|
1768
1775
|
console.error(
|
|
@@ -1823,6 +1830,13 @@ ${nudge}` : text;
|
|
|
1823
1830
|
await awaitJoin();
|
|
1824
1831
|
if (!linked) return notLinked();
|
|
1825
1832
|
if (selfHostMismatch || hostedWorkspaceRejected) return workspaceMismatch();
|
|
1833
|
+
if (sessionId === null && targetWorkspaceSlug !== null && joinFailureTransient) {
|
|
1834
|
+
gateRetry ??= activate(targetWorkspaceSlug).finally(() => {
|
|
1835
|
+
gateRetry = null;
|
|
1836
|
+
});
|
|
1837
|
+
await gateRetry;
|
|
1838
|
+
if (hostedWorkspaceRejected) return workspaceMismatch();
|
|
1839
|
+
}
|
|
1826
1840
|
if (sessionId === null) return sessionNotReady();
|
|
1827
1841
|
return null;
|
|
1828
1842
|
}
|
|
@@ -2516,16 +2530,565 @@ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
|
|
|
2516
2530
|
|
|
2517
2531
|
// src/hookInstall.ts
|
|
2518
2532
|
import {
|
|
2519
|
-
readFileSync as
|
|
2520
|
-
writeFileSync as
|
|
2521
|
-
mkdirSync as
|
|
2533
|
+
readFileSync as readFileSync8,
|
|
2534
|
+
writeFileSync as writeFileSync7,
|
|
2535
|
+
mkdirSync as mkdirSync6,
|
|
2522
2536
|
copyFileSync,
|
|
2523
|
-
existsSync as
|
|
2524
|
-
renameSync as
|
|
2537
|
+
existsSync as existsSync6,
|
|
2538
|
+
renameSync as renameSync3
|
|
2525
2539
|
} from "node:fs";
|
|
2526
2540
|
import { homedir as homedir5 } from "node:os";
|
|
2527
|
-
import { dirname as
|
|
2541
|
+
import { dirname as dirname7, join as join7 } from "node:path";
|
|
2528
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
|
|
2529
3092
|
function detectClient(clientName) {
|
|
2530
3093
|
const name = (clientName ?? "").toLowerCase();
|
|
2531
3094
|
if (!name) return "unknown";
|
|
@@ -2538,17 +3101,17 @@ function detectClient(clientName) {
|
|
|
2538
3101
|
var HOOK_COMMAND = `npx -y --package=@korso/shepherd@${PACKAGE_VERSION} shepherd-inbox-hook`;
|
|
2539
3102
|
var HOOK_MARKER = "shepherd-inbox-hook";
|
|
2540
3103
|
function ensureHookScript(homeDir, hookScriptSource) {
|
|
2541
|
-
const source = hookScriptSource ??
|
|
3104
|
+
const source = hookScriptSource ?? join7(dirname7(fileURLToPath(import.meta.url)), "inboxHook.js");
|
|
2542
3105
|
try {
|
|
2543
|
-
if (!
|
|
2544
|
-
const dest =
|
|
2545
|
-
const next =
|
|
2546
|
-
const current =
|
|
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;
|
|
2547
3110
|
if (current === null || !current.equals(next)) {
|
|
2548
|
-
|
|
3111
|
+
mkdirSync6(dirname7(dest), { recursive: true });
|
|
2549
3112
|
const tmp = dest + ".tmp";
|
|
2550
|
-
|
|
2551
|
-
|
|
3113
|
+
writeFileSync7(tmp, next);
|
|
3114
|
+
renameSync3(tmp, dest);
|
|
2552
3115
|
}
|
|
2553
3116
|
return dest;
|
|
2554
3117
|
} catch {
|
|
@@ -2558,16 +3121,6 @@ function ensureHookScript(homeDir, hookScriptSource) {
|
|
|
2558
3121
|
function hookCommandFor(scriptPath) {
|
|
2559
3122
|
return scriptPath === null ? HOOK_COMMAND : `node "${scriptPath.replace(/\\/g, "/")}"`;
|
|
2560
3123
|
}
|
|
2561
|
-
function codexHookBlock(scriptPath) {
|
|
2562
|
-
const command = scriptPath === null ? `["npx", "-y", "--package=@korso/shepherd@${PACKAGE_VERSION}", "shepherd-inbox-hook"]` : `["node", ${JSON.stringify(scriptPath)}]`;
|
|
2563
|
-
return [
|
|
2564
|
-
"",
|
|
2565
|
-
"# Added by Shepherd: delivers teammate announcements to the agent. Remove to disable.",
|
|
2566
|
-
"[[hooks.UserPromptSubmit]]",
|
|
2567
|
-
`command = ${command}`,
|
|
2568
|
-
""
|
|
2569
|
-
].join("\n");
|
|
2570
|
-
}
|
|
2571
3124
|
async function autoInstallHooks({
|
|
2572
3125
|
clientName,
|
|
2573
3126
|
homeDir = homedir5(),
|
|
@@ -2583,20 +3136,32 @@ async function autoInstallHooks({
|
|
|
2583
3136
|
return { client, status: "unsupported" };
|
|
2584
3137
|
}
|
|
2585
3138
|
const scriptPath = ensureHookScript(homeDir, hookScriptSource);
|
|
2586
|
-
const recordFile =
|
|
2587
|
-
if (
|
|
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" };
|
|
2588
3155
|
let status;
|
|
2589
3156
|
if (client === "claude") {
|
|
2590
3157
|
status = installClaude(homeDir, scriptPath, log);
|
|
2591
|
-
} else if (client === "codex") {
|
|
2592
|
-
status = installCodex(homeDir, scriptPath, log);
|
|
2593
3158
|
} else if (client === "cursor") {
|
|
2594
3159
|
status = installCursor(homeDir, scriptPath, log);
|
|
2595
3160
|
} else {
|
|
2596
3161
|
status = installPi(homeDir, extensionSource, log);
|
|
2597
3162
|
}
|
|
2598
|
-
|
|
2599
|
-
|
|
3163
|
+
mkdirSync6(dirname7(recordFile), { recursive: true });
|
|
3164
|
+
writeFileSync7(
|
|
2600
3165
|
recordFile,
|
|
2601
3166
|
JSON.stringify({ status, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
|
|
2602
3167
|
"utf8"
|
|
@@ -2615,10 +3180,10 @@ async function autoInstallHooks({
|
|
|
2615
3180
|
}
|
|
2616
3181
|
}
|
|
2617
3182
|
function installClaude(homeDir, scriptPath, log) {
|
|
2618
|
-
const settingsFile =
|
|
3183
|
+
const settingsFile = join7(homeDir, ".claude", "settings.json");
|
|
2619
3184
|
let raw = "";
|
|
2620
|
-
if (
|
|
2621
|
-
raw =
|
|
3185
|
+
if (existsSync6(settingsFile)) {
|
|
3186
|
+
raw = readFileSync8(settingsFile, "utf8");
|
|
2622
3187
|
if (raw.includes(HOOK_MARKER)) return "already-present";
|
|
2623
3188
|
}
|
|
2624
3189
|
let settings = {};
|
|
@@ -2661,60 +3226,15 @@ function installClaude(homeDir, scriptPath, log) {
|
|
|
2661
3226
|
matcher: "*",
|
|
2662
3227
|
hooks: [{ type: "command", command }]
|
|
2663
3228
|
});
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
return "installed";
|
|
2667
|
-
}
|
|
2668
|
-
function installCodex(homeDir, scriptPath, log) {
|
|
2669
|
-
const configFile = join5(homeDir, ".codex", "config.toml");
|
|
2670
|
-
const manualHint = "Add the hook manually (see the dashboard's Connect screen).";
|
|
2671
|
-
const hookBlock = codexHookBlock(scriptPath);
|
|
2672
|
-
if (!existsSync4(configFile)) {
|
|
2673
|
-
mkdirSync5(dirname6(configFile), { recursive: true });
|
|
2674
|
-
writeFileSync6(configFile, `[features]
|
|
2675
|
-
hooks = true
|
|
2676
|
-
${hookBlock}`, "utf8");
|
|
2677
|
-
return "installed";
|
|
2678
|
-
}
|
|
2679
|
-
const toml = readFileSync6(configFile, "utf8");
|
|
2680
|
-
if (toml.includes(HOOK_MARKER)) return "already-present";
|
|
2681
|
-
if (/^\s*\[hooks\.UserPromptSubmit\]\s*$/m.test(toml)) {
|
|
2682
|
-
log(
|
|
2683
|
-
`[shepherd] ${configFile} defines [hooks.UserPromptSubmit] \u2014 not touching it. ${manualHint}`
|
|
2684
|
-
);
|
|
2685
|
-
return "skipped";
|
|
2686
|
-
}
|
|
2687
|
-
if (/^\s*\[features\]/m.test(toml)) {
|
|
2688
|
-
const hooksKey = /^\s*hooks\s*=\s*(.+)$/m.exec(toml);
|
|
2689
|
-
if (hooksKey && hooksKey[1].trim() !== "true") {
|
|
2690
|
-
log(
|
|
2691
|
-
`[shepherd] ${configFile} sets hooks = ${hooksKey[1].trim()} \u2014 respecting it. ${manualHint}`
|
|
2692
|
-
);
|
|
2693
|
-
return "skipped";
|
|
2694
|
-
}
|
|
2695
|
-
let updated = toml;
|
|
2696
|
-
if (!hooksKey) {
|
|
2697
|
-
updated = toml.replace(/^(\s*\[features\]\s*)$/m, `$1
|
|
2698
|
-
hooks = true`);
|
|
2699
|
-
}
|
|
2700
|
-
writeFileSync6(configFile, updated + hookBlock, "utf8");
|
|
2701
|
-
return "installed";
|
|
2702
|
-
}
|
|
2703
|
-
writeFileSync6(
|
|
2704
|
-
configFile,
|
|
2705
|
-
`${toml}
|
|
2706
|
-
[features]
|
|
2707
|
-
hooks = true
|
|
2708
|
-
${hookBlock}`,
|
|
2709
|
-
"utf8"
|
|
2710
|
-
);
|
|
3229
|
+
mkdirSync6(dirname7(settingsFile), { recursive: true });
|
|
3230
|
+
writeFileSync7(settingsFile, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
2711
3231
|
return "installed";
|
|
2712
3232
|
}
|
|
2713
3233
|
function installCursor(homeDir, scriptPath, log) {
|
|
2714
|
-
const hooksFile =
|
|
3234
|
+
const hooksFile = join7(homeDir, ".cursor", "hooks.json");
|
|
2715
3235
|
let raw = "";
|
|
2716
|
-
if (
|
|
2717
|
-
raw =
|
|
3236
|
+
if (existsSync6(hooksFile)) {
|
|
3237
|
+
raw = readFileSync8(hooksFile, "utf8");
|
|
2718
3238
|
if (raw.includes(HOOK_MARKER)) return "already-present";
|
|
2719
3239
|
}
|
|
2720
3240
|
let config = {};
|
|
@@ -2749,21 +3269,21 @@ function installCursor(homeDir, scriptPath, log) {
|
|
|
2749
3269
|
return "skipped";
|
|
2750
3270
|
}
|
|
2751
3271
|
entries.push({ command: hookCommandFor(scriptPath) });
|
|
2752
|
-
|
|
2753
|
-
|
|
3272
|
+
mkdirSync6(dirname7(hooksFile), { recursive: true });
|
|
3273
|
+
writeFileSync7(hooksFile, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
2754
3274
|
return "installed";
|
|
2755
3275
|
}
|
|
2756
3276
|
function installPi(homeDir, extensionSource, log) {
|
|
2757
|
-
const source = extensionSource ??
|
|
2758
|
-
const dest =
|
|
2759
|
-
if (
|
|
2760
|
-
if (!
|
|
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)) {
|
|
2761
3281
|
log(
|
|
2762
3282
|
`[shepherd] bundled Pi extension not found at ${source} \u2014 skipping auto-install.`
|
|
2763
3283
|
);
|
|
2764
3284
|
return "skipped";
|
|
2765
3285
|
}
|
|
2766
|
-
|
|
3286
|
+
mkdirSync6(dirname7(dest), { recursive: true });
|
|
2767
3287
|
copyFileSync(source, dest);
|
|
2768
3288
|
return "installed";
|
|
2769
3289
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@korso/shepherd",
|
|
3
|
-
"version": "0.11.
|
|
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": {
|