@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 +105 -0
- package/dist/inboxExtension.js +93 -0
- package/dist/inboxHook.js +115 -0
- package/dist/index.js +205 -40
- package/package.json +3 -2
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 (~
|
|
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(
|
|
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(
|
|
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 =
|
|
1032
|
-
|
|
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.
|
|
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",
|