@korso/shepherd 0.4.0 → 0.4.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 CHANGED
@@ -59,6 +59,111 @@ override only to replace what's detected:
59
59
  | `PROGRAM` | defaults to `claude-code` | `codex` |
60
60
  | `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
61
61
  | `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
62
+ | `SHEPHERD_INBOX_DIR` | defaults to `~/.shepherd/inbox`. Override only to relocate the **announcement-push** inbox (see below); the background heartbeat writes incoming announcements here. If you set it, point your client hook/extension at the **same** dir | `~/.shepherd/inbox` |
63
+
64
+ ---
65
+
66
+ ## Announcement push (on by default)
67
+
68
+ Announcements reach an agent **without it having to ask**. The background
69
+ heartbeat pulls any pending announcements from the hub every beat and stages them
70
+ in a local **inbox file** (per working directory, under `SHEPHERD_INBOX_DIR`,
71
+ default `~/.shepherd/inbox`). That file is then drained by two paths:
72
+
73
+ 1. **Universal drainer (always on, every client).** Whenever the agent calls any
74
+ Shepherd tool (`work`/`sync`/`done`/`announce`), the result also includes
75
+ anything sitting in the inbox. So even with no hook configured, no announcement
76
+ is ever lost — the worst case is the old behaviour (delivered on the next
77
+ Shepherd tool call), never silent drops.
78
+ 2. **Passive client hook/extension (optional, per client).** To get announcements
79
+ **without** waiting for a Shepherd tool call — surfaced on the agent's next
80
+ action of any kind — wire up your client's hook below. This is the
81
+ "a subagent finished" style of notification.
82
+
83
+ Both paths read the **same** inbox file and de-duplicate by announcement id, so
84
+ running both is safe (the hub hands each announcement to exactly one drain; the
85
+ merge is just defensive). It's cheap: a **local file read — no network** (the
86
+ heartbeat already did the fetch), and it only adds to the model's context when
87
+ something is actually waiting.
88
+
89
+ It delivers to an agent **while it's active**; an idle agent picks messages up the
90
+ moment it next does anything. (Waking a fully-idle agent is out of scope — for
91
+ Claude Code that needs Channels; Codex/Pi have no equivalent.)
92
+
93
+ ### Claude Code — `PreToolUse` hook
94
+
95
+ `PreToolUse` fires before every tool, giving the most frequent passive delivery.
96
+ The hook needs no arguments — it resolves the same default inbox dir the server
97
+ uses (override both with `SHEPHERD_INBOX_DIR` if you relocated it):
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "shepherd": {
103
+ "command": "npx",
104
+ "args": ["-y", "@korso/shepherd"],
105
+ "env": {
106
+ "HUB_URL": "https://shepherd.example.com",
107
+ "TEAM_TOKEN": "tok_abc123"
108
+ }
109
+ }
110
+ },
111
+ "hooks": {
112
+ "PreToolUse": [
113
+ {
114
+ "matcher": "*",
115
+ "hooks": [
116
+ { "type": "command", "command": "npx -y -p @korso/shepherd shepherd-inbox-hook" }
117
+ ]
118
+ }
119
+ ]
120
+ }
121
+ }
122
+ ```
123
+
124
+ ### Codex — `UserPromptSubmit` hook
125
+
126
+ Codex uses the **same** hook contract as Claude Code (JSON on stdin, a
127
+ `hookSpecificOutput.additionalContext` reply), so the **same bin** serves it. Use
128
+ `UserPromptSubmit` — Codex's `PreToolUse` only fires for Bash, not `apply_patch`
129
+ or MCP calls. Hooks must be enabled with `features.hooks = true`. In
130
+ `~/.codex/config.toml`:
131
+
132
+ ```toml
133
+ [features]
134
+ hooks = true
135
+
136
+ [[hooks.UserPromptSubmit]]
137
+ command = ["npx", "-y", "-p", "@korso/shepherd", "shepherd-inbox-hook"]
138
+ # On Windows use command_windows instead:
139
+ # command_windows = ["cmd", "/c", "npx -y -p @korso/shepherd shepherd-inbox-hook"]
140
+ ```
141
+
142
+ ### Pi — extension
143
+
144
+ Pi has no stdin/stdout hook; it loads in-process extensions. Ship the bundled
145
+ extension into Pi's extensions dir:
146
+
147
+ ```sh
148
+ # global, applies everywhere:
149
+ mkdir -p ~/.pi/agent/extensions
150
+ cp "$(npm root -g)/@korso/shepherd/dist/inboxExtension.js" ~/.pi/agent/extensions/shepherd-inbox.js
151
+ # …or per-project: copy into .pi/extensions/ in the repo root.
152
+ ```
153
+
154
+ It runs on every user turn (`before_agent_start`), drains the same inbox, and
155
+ injects pending announcements. (Or load it ad hoc with
156
+ `pi -e /abs/path/to/dist/inboxExtension.js`.)
157
+
158
+ ### Notes
159
+
160
+ Every path is **fail-open**: a missing dir, unreachable hub, or any error means
161
+ nothing is surfaced and the tool call / turn proceeds normally — coordination
162
+ never blocks the agent. The inbox is keyed per working directory; two sessions in
163
+ the exact same directory share it (a benign edge — they're the same repo). If you
164
+ override `SHEPHERD_INBOX_DIR` on the server, set it on the hook/extension to the
165
+ same value (the Claude/Codex bin and the Pi extension both read
166
+ `SHEPHERD_INBOX_DIR`, or you can pass the dir as the first CLI arg to the bin).
62
167
 
63
168
  ---
64
169
 
@@ -0,0 +1,93 @@
1
+ // src/inbox.ts
2
+ import { createHash } from "crypto";
3
+ import {
4
+ appendFileSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ existsSync
10
+ } from "fs";
11
+ import { homedir, tmpdir } from "os";
12
+ import { dirname, join, resolve } from "path";
13
+ function defaultInboxDir() {
14
+ let base = "";
15
+ try {
16
+ base = homedir();
17
+ } catch {
18
+ base = "";
19
+ }
20
+ if (!base) base = tmpdir();
21
+ return join(base, ".shepherd", "inbox");
22
+ }
23
+ function inboxFilePath(dir, cwd) {
24
+ let normalized = resolve(cwd);
25
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
26
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
27
+ return join(dir, `${hash}.jsonl`);
28
+ }
29
+ function drainInbox(filePath) {
30
+ const tmp = `${filePath}.draining`;
31
+ let raw = "";
32
+ try {
33
+ if (existsSync(tmp)) {
34
+ raw += readFileSync(tmp, "utf8");
35
+ rmSync(tmp, { force: true });
36
+ }
37
+ } catch {
38
+ }
39
+ try {
40
+ if (existsSync(filePath)) {
41
+ renameSync(filePath, tmp);
42
+ raw += readFileSync(tmp, "utf8");
43
+ rmSync(tmp, { force: true });
44
+ }
45
+ } catch {
46
+ }
47
+ if (!raw.trim()) return [];
48
+ const seen = /* @__PURE__ */ new Set();
49
+ const out = [];
50
+ for (const line of raw.split("\n")) {
51
+ const trimmed = line.trim();
52
+ if (!trimmed) continue;
53
+ try {
54
+ const parsed = JSON.parse(trimmed);
55
+ if (typeof parsed?.id !== "number" || seen.has(parsed.id)) continue;
56
+ seen.add(parsed.id);
57
+ out.push(parsed);
58
+ } catch {
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+ function formatInboxAnnouncements(announcements) {
64
+ if (!announcements || announcements.length === 0) return "";
65
+ const count = announcements.length;
66
+ const lines = [
67
+ `[Shepherd] ${count} new announcement${count === 1 ? "" : "s"} from your teammates:`
68
+ ];
69
+ for (const a of announcements) {
70
+ const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
71
+ lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
72
+ }
73
+ return lines.join("\n");
74
+ }
75
+
76
+ // src/inboxExtension.ts
77
+ function shepherdInbox(pi) {
78
+ pi.on("before_agent_start", (_event, ctx) => {
79
+ try {
80
+ const dir = process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
81
+ const cwd = ctx?.cwd ?? process.cwd();
82
+ const announcements = drainInbox(inboxFilePath(dir, cwd));
83
+ const content = formatInboxAnnouncements(announcements);
84
+ if (!content) return void 0;
85
+ return { message: { customType: "shepherd-inbox", content, display: true } };
86
+ } catch {
87
+ return void 0;
88
+ }
89
+ });
90
+ }
91
+ export {
92
+ shepherdInbox as default
93
+ };
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/inbox.ts
4
+ import { createHash } from "crypto";
5
+ import {
6
+ appendFileSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ existsSync
12
+ } from "fs";
13
+ import { homedir, tmpdir } from "os";
14
+ import { dirname, join, resolve } from "path";
15
+ function defaultInboxDir() {
16
+ let base = "";
17
+ try {
18
+ base = homedir();
19
+ } catch {
20
+ base = "";
21
+ }
22
+ if (!base) base = tmpdir();
23
+ return join(base, ".shepherd", "inbox");
24
+ }
25
+ function inboxFilePath(dir, cwd) {
26
+ let normalized = resolve(cwd);
27
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
28
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
29
+ return join(dir, `${hash}.jsonl`);
30
+ }
31
+ function drainInbox(filePath) {
32
+ const tmp = `${filePath}.draining`;
33
+ let raw = "";
34
+ try {
35
+ if (existsSync(tmp)) {
36
+ raw += readFileSync(tmp, "utf8");
37
+ rmSync(tmp, { force: true });
38
+ }
39
+ } catch {
40
+ }
41
+ try {
42
+ if (existsSync(filePath)) {
43
+ renameSync(filePath, tmp);
44
+ raw += readFileSync(tmp, "utf8");
45
+ rmSync(tmp, { force: true });
46
+ }
47
+ } catch {
48
+ }
49
+ if (!raw.trim()) return [];
50
+ const seen = /* @__PURE__ */ new Set();
51
+ const out = [];
52
+ for (const line of raw.split("\n")) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed) continue;
55
+ try {
56
+ const parsed = JSON.parse(trimmed);
57
+ if (typeof parsed?.id !== "number" || seen.has(parsed.id)) continue;
58
+ seen.add(parsed.id);
59
+ out.push(parsed);
60
+ } catch {
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ function formatInboxAnnouncements(announcements) {
66
+ if (!announcements || announcements.length === 0) return "";
67
+ const count = announcements.length;
68
+ const lines = [
69
+ `[Shepherd] ${count} new announcement${count === 1 ? "" : "s"} from your teammates:`
70
+ ];
71
+ for (const a of announcements) {
72
+ const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
73
+ lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+ function buildHookOutput(rawStdin, inboxDir, drain = drainInbox) {
78
+ if (!inboxDir) return "";
79
+ let input;
80
+ try {
81
+ input = JSON.parse(rawStdin);
82
+ } catch {
83
+ return "";
84
+ }
85
+ if (!input || typeof input.cwd !== "string" || input.cwd.length === 0) return "";
86
+ const announcements = drain(inboxFilePath(inboxDir, input.cwd));
87
+ const text = formatInboxAnnouncements(announcements);
88
+ if (!text) return "";
89
+ return JSON.stringify({
90
+ hookSpecificOutput: {
91
+ hookEventName: input.hook_event_name || "PreToolUse",
92
+ additionalContext: text
93
+ }
94
+ });
95
+ }
96
+
97
+ // src/inboxHook.ts
98
+ async function readStdin() {
99
+ const chunks = [];
100
+ for await (const chunk of process.stdin) {
101
+ chunks.push(chunk);
102
+ }
103
+ return Buffer.concat(chunks).toString("utf8");
104
+ }
105
+ async function main() {
106
+ try {
107
+ const raw = await readStdin();
108
+ const inboxDir = process.argv[2] || process.env["SHEPHERD_INBOX_DIR"] || defaultInboxDir();
109
+ const out = buildHookOutput(raw, inboxDir);
110
+ if (out) process.stdout.write(out);
111
+ } catch {
112
+ }
113
+ process.exit(0);
114
+ }
115
+ void main();
package/dist/index.js CHANGED
@@ -20,7 +20,14 @@ var ConfigSchema = z.object({
20
20
  PROGRAM: z.string().min(1).optional(),
21
21
  MODEL: z.string().min(1).optional(),
22
22
  // Heartbeat cadence in seconds; coerced from string env var.
23
- HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60)
23
+ HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60),
24
+ // Opt-in: directory for the local announcement inbox. When set, the background
25
+ // heartbeat delivers pending announcements into a per-cwd file here, which the
26
+ // `shepherd-inbox-hook` (configured with the SAME dir) drains into the agent's
27
+ // context on its next action. Unset → no inbox, announcements flow only via
28
+ // work/sync/done/announce tool results as before. Both the MCP server and the
29
+ // hook must agree on this path.
30
+ SHEPHERD_INBOX_DIR: z.string().min(1).optional()
24
31
  });
25
32
  function parseConfig(env) {
26
33
  return ConfigSchema.parse({
@@ -33,7 +40,8 @@ function parseConfig(env) {
33
40
  HUMAN: env["HUMAN"],
34
41
  PROGRAM: env["PROGRAM"],
35
42
  MODEL: env["MODEL"],
36
- HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"]
43
+ HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"],
44
+ SHEPHERD_INBOX_DIR: env["SHEPHERD_INBOX_DIR"]
37
45
  });
38
46
  }
39
47
  function loadConfig(env = process.env) {
@@ -210,6 +218,39 @@ function generateName() {
210
218
  return randomAdj + randomNoun;
211
219
  }
212
220
 
221
+ // ../shared/dist/repo.js
222
+ function normalizeRemoteUrl(url) {
223
+ let s = url.trim();
224
+ if (!s)
225
+ return null;
226
+ s = s.replace(/\.git$/, "");
227
+ const scp = s.match(/^[^/@]+@[^:]+:(.+)$/);
228
+ if (scp) {
229
+ s = scp[1];
230
+ } else {
231
+ s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
232
+ const slash = s.indexOf("/");
233
+ if (slash !== -1) {
234
+ s = s.slice(slash + 1);
235
+ }
236
+ }
237
+ s = s.replace(/^\/+|\/+$/g, "");
238
+ const segments = s.split("/").filter(Boolean);
239
+ if (segments.length < 2)
240
+ return null;
241
+ const owner = segments[segments.length - 2];
242
+ const repo = segments[segments.length - 1];
243
+ return `${owner}/${repo}`;
244
+ }
245
+ function canonicalizeRepo(input) {
246
+ const s = input.trim();
247
+ const looksLikeUrl = /:\/\//.test(s) || /^[^/@]+@[^:]+:/.test(s);
248
+ const base = looksLikeUrl ? normalizeRemoteUrl(s) ?? s : s.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
249
+ const segments = base.split("/").filter(Boolean);
250
+ const name = segments.length > 0 ? segments[segments.length - 1] : base;
251
+ return name.toLowerCase();
252
+ }
253
+
213
254
  // ../shared/dist/contract.js
214
255
  import { z as z2 } from "zod";
215
256
  var IsoTimestamp = z2.string();
@@ -312,6 +353,10 @@ var WorkspaceAnnouncement = z2.object({
312
353
  body: z2.string(),
313
354
  targetAgentName: z2.string().nullable(),
314
355
  repo: z2.string(),
356
+ // True when the message was sent by the human operator from the dashboard
357
+ // (no agent session). The dashboard renders these as "me" (right-aligned).
358
+ // Defaulted for version-skew safety with older hubs.
359
+ fromAdmin: z2.boolean().default(false),
315
360
  createdAt: IsoTimestamp
316
361
  });
317
362
  var WorkspaceLandscapeResponse = z2.object({
@@ -322,6 +367,22 @@ var WorkspaceLandscapeResponse = z2.object({
322
367
  // the hub rather than the (possibly skewed) browser clock.
323
368
  serverTime: IsoTimestamp
324
369
  });
370
+ var WorkspaceAnnounceRequest = z2.object({
371
+ body: z2.string().min(1).max(8192),
372
+ // Direct-message a single agent (by the exact name shown in the landscape).
373
+ // Absent/null => broadcast. The hub resolves the target's repo server-side.
374
+ targetAgentName: z2.string().min(1).nullable().optional(),
375
+ // For a broadcast, the repo to scope the message to (matches the dashboard's
376
+ // selected repo). Absent/null => fan out to every repo in the workspace.
377
+ // Ignored for a DM (the target's own repo is used).
378
+ repo: z2.string().min(1).nullable().optional()
379
+ });
380
+ var WorkspaceAnnounceResponse = z2.object({
381
+ ok: z2.literal(true),
382
+ // One id per inserted row: a single id for a DM or repo-scoped broadcast, or
383
+ // several when an all-repos broadcast fans out across repos.
384
+ announcementIds: z2.array(DbId)
385
+ });
325
386
  var JoinRequest = z2.object({
326
387
  workspace: z2.string().min(1),
327
388
  repo: z2.string().min(1),
@@ -389,10 +450,21 @@ var HeartbeatRequest = z2.object({
389
450
  // change records fresh (commits surface within ~one heartbeat interval, not
390
451
  // only when it next calls work/sync). Processed presence-style: it refreshes
391
452
  // change records but, like the rest of heartbeat, does NOT renew claim TTLs.
392
- changeReport: ChangeReport.optional()
453
+ changeReport: ChangeReport.optional(),
454
+ // Opt-in: when set, the heartbeat ALSO delivers (and marks delivered) any
455
+ // pending announcements for the caller, returned in the response. The MCP
456
+ // client only sets this when it has somewhere model-visible to surface them
457
+ // (a local inbox file drained by a hook) — otherwise the long-standing
458
+ // invariant holds: heartbeat must NOT consume announcements the model can't
459
+ // see. Absent for older clients, so default delivery is unchanged.
460
+ deliverAnnouncements: z2.boolean().optional()
393
461
  });
394
462
  var HeartbeatResponse = z2.object({
395
- ok: z2.literal(true)
463
+ ok: z2.literal(true),
464
+ // Pending announcements for the caller, delivered only when the request set
465
+ // `deliverAnnouncements`. Defaulted to [] for version-skew safety with older
466
+ // hubs (which return just { ok: true }).
467
+ announcements: z2.array(Announcement).default([])
396
468
  });
397
469
  var LeaveRequest = z2.object({
398
470
  sessionId: z2.string().uuid()
@@ -443,33 +515,6 @@ function runGitExitOk(cwd, args) {
443
515
  function isValidSha(sha) {
444
516
  return /^[0-9a-f]{4,64}$/.test(sha);
445
517
  }
446
- function normalizeRemoteUrl(url) {
447
- let s = url.trim();
448
- if (!s) return null;
449
- s = s.replace(/\.git$/, "");
450
- const scp = s.match(/^[^/@]+@[^:]+:(.+)$/);
451
- if (scp) {
452
- s = scp[1];
453
- } else {
454
- s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
455
- const slash = s.indexOf("/");
456
- if (slash !== -1) {
457
- s = s.slice(slash + 1);
458
- }
459
- }
460
- s = s.replace(/^\/+|\/+$/g, "");
461
- const segments = s.split("/").filter(Boolean);
462
- if (segments.length < 2) return null;
463
- const owner = segments[segments.length - 2];
464
- const repo = segments[segments.length - 1];
465
- return `${owner}/${repo}`;
466
- }
467
- function canonicalizeRepo(input) {
468
- const s = input.trim();
469
- const looksLikeUrl = /:\/\//.test(s) || /^[^/@]+@[^:]+:/.test(s);
470
- const base = looksLikeUrl ? normalizeRemoteUrl(s) ?? s : s.replace(/\.git$/, "").replace(/^\/+|\/+$/g, "");
471
- return base.toLowerCase();
472
- }
473
518
  function detectRepo(cwd = process.cwd()) {
474
519
  const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
475
520
  if (origin) {
@@ -672,6 +717,88 @@ async function buildChangeReport(cwd, config) {
672
717
  };
673
718
  }
674
719
 
720
+ // src/inbox.ts
721
+ import { createHash } from "crypto";
722
+ import {
723
+ appendFileSync,
724
+ mkdirSync,
725
+ readFileSync,
726
+ renameSync,
727
+ rmSync,
728
+ existsSync
729
+ } from "fs";
730
+ import { homedir, tmpdir } from "os";
731
+ import { dirname, join, resolve } from "path";
732
+ function defaultInboxDir() {
733
+ let base = "";
734
+ try {
735
+ base = homedir();
736
+ } catch {
737
+ base = "";
738
+ }
739
+ if (!base) base = tmpdir();
740
+ return join(base, ".shepherd", "inbox");
741
+ }
742
+ function inboxFilePath(dir, cwd) {
743
+ let normalized = resolve(cwd);
744
+ if (process.platform === "win32") normalized = normalized.toLowerCase();
745
+ const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
746
+ return join(dir, `${hash}.jsonl`);
747
+ }
748
+ function appendAnnouncements(filePath, announcements) {
749
+ if (!announcements || announcements.length === 0) return;
750
+ try {
751
+ mkdirSync(dirname(filePath), { recursive: true });
752
+ const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
753
+ appendFileSync(filePath, payload, "utf8");
754
+ } catch {
755
+ }
756
+ }
757
+ function drainInbox(filePath) {
758
+ const tmp = `${filePath}.draining`;
759
+ let raw = "";
760
+ try {
761
+ if (existsSync(tmp)) {
762
+ raw += readFileSync(tmp, "utf8");
763
+ rmSync(tmp, { force: true });
764
+ }
765
+ } catch {
766
+ }
767
+ try {
768
+ if (existsSync(filePath)) {
769
+ renameSync(filePath, tmp);
770
+ raw += readFileSync(tmp, "utf8");
771
+ rmSync(tmp, { force: true });
772
+ }
773
+ } catch {
774
+ }
775
+ if (!raw.trim()) return [];
776
+ const seen = /* @__PURE__ */ new Set();
777
+ const out = [];
778
+ for (const line of raw.split("\n")) {
779
+ const trimmed = line.trim();
780
+ if (!trimmed) continue;
781
+ try {
782
+ const parsed = JSON.parse(trimmed);
783
+ if (typeof parsed?.id !== "number" || seen.has(parsed.id)) continue;
784
+ seen.add(parsed.id);
785
+ out.push(parsed);
786
+ } catch {
787
+ }
788
+ }
789
+ return out;
790
+ }
791
+ function mergeAnnouncements(...lists) {
792
+ const byId = /* @__PURE__ */ new Map();
793
+ for (const list of lists) {
794
+ if (!list) continue;
795
+ for (const a of list) {
796
+ if (!byId.has(a.id)) byId.set(a.id, a);
797
+ }
798
+ }
799
+ return [...byId.values()].sort((x, y) => x.id - y.id);
800
+ }
801
+
675
802
  // src/tools.ts
676
803
  function formatLandscape(landscape) {
677
804
  const lines = [];
@@ -791,7 +918,7 @@ function degradedResult(err) {
791
918
  };
792
919
  }
793
920
  function registerTools(server, deps) {
794
- const { hubClient, config, context, heartbeat } = deps;
921
+ const { hubClient, config, context, heartbeat, inboxFile } = deps;
795
922
  let sessionId = null;
796
923
  let agentName = null;
797
924
  const joinBody = {
@@ -835,6 +962,14 @@ ${body}` : body;
835
962
  return void 0;
836
963
  }
837
964
  }
965
+ function drainLocalInbox() {
966
+ if (!inboxFile) return [];
967
+ try {
968
+ return drainInbox(inboxFile);
969
+ } catch {
970
+ return [];
971
+ }
972
+ }
838
973
  function withChangeRecords(landscape, body) {
839
974
  let section = "";
840
975
  try {
@@ -862,6 +997,10 @@ ${section}` : body;
862
997
  const changeReport = await changeReportForBody();
863
998
  const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
864
999
  const result = await hubClient.post("/work", body);
1000
+ result.landscape.announcements = mergeAnnouncements(
1001
+ result.landscape.announcements,
1002
+ drainLocalInbox()
1003
+ );
865
1004
  const text = withIdentity(
866
1005
  withChangeRecords(
867
1006
  result.landscape,
@@ -869,7 +1008,7 @@ ${section}` : body;
869
1008
 
870
1009
  ` + formatLandscape(result.landscape) + `
871
1010
 
872
- You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
1011
+ You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~60 min). Calling work or sync renews it.`
873
1012
  )
874
1013
  );
875
1014
  return { content: [{ type: "text", text }] };
@@ -897,7 +1036,9 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
897
1036
  const body = { sessionId, ...args };
898
1037
  const result = await hubClient.post("/done", body);
899
1038
  const base = "Work item released. Call work again before your next edit in a new area.";
900
- const msgs = formatAnnouncements(result.announcements ?? []);
1039
+ const msgs = formatAnnouncements(
1040
+ mergeAnnouncements(result.announcements, drainLocalInbox())
1041
+ );
901
1042
  return {
902
1043
  content: [
903
1044
  { type: "text", text: msgs ? `${base}
@@ -929,7 +1070,9 @@ ${msgs}` : base }
929
1070
  const body = { sessionId, ...args };
930
1071
  const result = await hubClient.post("/announce", body);
931
1072
  const base = `Announcement sent (id: ${result.announcementId}).`;
932
- const msgs = formatAnnouncements(result.announcements ?? []);
1073
+ const msgs = formatAnnouncements(
1074
+ mergeAnnouncements(result.announcements, drainLocalInbox())
1075
+ );
933
1076
  return {
934
1077
  content: [
935
1078
  { type: "text", text: msgs ? `${base}
@@ -961,6 +1104,10 @@ ${msgs}` : base }
961
1104
  const changeReport = await changeReportForBody();
962
1105
  const body = { sessionId, ...changeReport ? { changeReport } : {} };
963
1106
  const result = await hubClient.post("/sync", body);
1107
+ result.landscape.announcements = mergeAnnouncements(
1108
+ result.landscape.announcements,
1109
+ drainLocalInbox()
1110
+ );
964
1111
  const text = withIdentity(
965
1112
  withChangeRecords(result.landscape, formatLandscape(result.landscape))
966
1113
  );
@@ -1010,7 +1157,9 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
1010
1157
  function createHeartbeat({
1011
1158
  hubClient,
1012
1159
  intervalSeconds,
1013
- buildReport
1160
+ buildReport,
1161
+ deliverAnnouncements = false,
1162
+ onAnnouncements
1014
1163
  }) {
1015
1164
  let timer = null;
1016
1165
  function stop() {
@@ -1028,8 +1177,20 @@ function createHeartbeat({
1028
1177
  changeReport = void 0;
1029
1178
  }
1030
1179
  }
1031
- const body = changeReport ? { sessionId, changeReport } : { sessionId };
1032
- await hubClient.post("/heartbeat", body);
1180
+ const body = { sessionId };
1181
+ if (changeReport) body.changeReport = changeReport;
1182
+ if (deliverAnnouncements) body.deliverAnnouncements = true;
1183
+ const response = await hubClient.post("/heartbeat", body);
1184
+ const delivered = response?.announcements;
1185
+ if (onAnnouncements && Array.isArray(delivered) && delivered.length > 0) {
1186
+ try {
1187
+ onAnnouncements(delivered);
1188
+ } catch (err) {
1189
+ console.error(
1190
+ `[shepherd] inbox delivery failed: ${err instanceof Error ? err.message : String(err)}`
1191
+ );
1192
+ }
1193
+ }
1033
1194
  }
1034
1195
  function start(sessionId) {
1035
1196
  stop();
@@ -1069,6 +1230,8 @@ async function main() {
1069
1230
  const config = loadConfig();
1070
1231
  const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
1071
1232
  const context = await resolveContext(config);
1233
+ const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
1234
+ const inboxFile = inboxFilePath(inboxDir, process.cwd());
1072
1235
  const heartbeat = createHeartbeat({
1073
1236
  hubClient,
1074
1237
  intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
@@ -1080,13 +1243,15 @@ async function main() {
1080
1243
  } catch {
1081
1244
  return void 0;
1082
1245
  }
1083
- }
1246
+ },
1247
+ deliverAnnouncements: true,
1248
+ onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
1084
1249
  });
1085
1250
  const server = new McpServer(
1086
1251
  { name: "shepherd", version: "0.1.0" },
1087
1252
  { instructions: SHEPHERD_INSTRUCTIONS }
1088
1253
  );
1089
- const tools = registerTools(server, { hubClient, config, context, heartbeat });
1254
+ const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
1090
1255
  const transport = new StdioServerTransport();
1091
1256
  let shuttingDown = false;
1092
1257
  const shutdown = async () => {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) four advisory cross-session coordination tools (work/done/announce/sync) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
8
- "shepherd-mcp": "dist/index.js"
8
+ "shepherd-mcp": "dist/index.js",
9
+ "shepherd-inbox-hook": "dist/inboxHook.js"
9
10
  },
10
11
  "files": [
11
12
  "dist",