@korso/shepherd 0.4.1 → 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 -42
- 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,35 +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
|
-
const segments = base.split("/").filter(Boolean);
|
|
472
|
-
const name = segments.length > 0 ? segments[segments.length - 1] : base;
|
|
473
|
-
return name.toLowerCase();
|
|
474
|
-
}
|
|
475
518
|
function detectRepo(cwd = process.cwd()) {
|
|
476
519
|
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
477
520
|
if (origin) {
|
|
@@ -674,6 +717,88 @@ async function buildChangeReport(cwd, config) {
|
|
|
674
717
|
};
|
|
675
718
|
}
|
|
676
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
|
+
|
|
677
802
|
// src/tools.ts
|
|
678
803
|
function formatLandscape(landscape) {
|
|
679
804
|
const lines = [];
|
|
@@ -793,7 +918,7 @@ function degradedResult(err) {
|
|
|
793
918
|
};
|
|
794
919
|
}
|
|
795
920
|
function registerTools(server, deps) {
|
|
796
|
-
const { hubClient, config, context, heartbeat } = deps;
|
|
921
|
+
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
797
922
|
let sessionId = null;
|
|
798
923
|
let agentName = null;
|
|
799
924
|
const joinBody = {
|
|
@@ -837,6 +962,14 @@ ${body}` : body;
|
|
|
837
962
|
return void 0;
|
|
838
963
|
}
|
|
839
964
|
}
|
|
965
|
+
function drainLocalInbox() {
|
|
966
|
+
if (!inboxFile) return [];
|
|
967
|
+
try {
|
|
968
|
+
return drainInbox(inboxFile);
|
|
969
|
+
} catch {
|
|
970
|
+
return [];
|
|
971
|
+
}
|
|
972
|
+
}
|
|
840
973
|
function withChangeRecords(landscape, body) {
|
|
841
974
|
let section = "";
|
|
842
975
|
try {
|
|
@@ -864,6 +997,10 @@ ${section}` : body;
|
|
|
864
997
|
const changeReport = await changeReportForBody();
|
|
865
998
|
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
866
999
|
const result = await hubClient.post("/work", body);
|
|
1000
|
+
result.landscape.announcements = mergeAnnouncements(
|
|
1001
|
+
result.landscape.announcements,
|
|
1002
|
+
drainLocalInbox()
|
|
1003
|
+
);
|
|
867
1004
|
const text = withIdentity(
|
|
868
1005
|
withChangeRecords(
|
|
869
1006
|
result.landscape,
|
|
@@ -871,7 +1008,7 @@ ${section}` : body;
|
|
|
871
1008
|
|
|
872
1009
|
` + formatLandscape(result.landscape) + `
|
|
873
1010
|
|
|
874
|
-
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.`
|
|
875
1012
|
)
|
|
876
1013
|
);
|
|
877
1014
|
return { content: [{ type: "text", text }] };
|
|
@@ -899,7 +1036,9 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
899
1036
|
const body = { sessionId, ...args };
|
|
900
1037
|
const result = await hubClient.post("/done", body);
|
|
901
1038
|
const base = "Work item released. Call work again before your next edit in a new area.";
|
|
902
|
-
const msgs = formatAnnouncements(
|
|
1039
|
+
const msgs = formatAnnouncements(
|
|
1040
|
+
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1041
|
+
);
|
|
903
1042
|
return {
|
|
904
1043
|
content: [
|
|
905
1044
|
{ type: "text", text: msgs ? `${base}
|
|
@@ -931,7 +1070,9 @@ ${msgs}` : base }
|
|
|
931
1070
|
const body = { sessionId, ...args };
|
|
932
1071
|
const result = await hubClient.post("/announce", body);
|
|
933
1072
|
const base = `Announcement sent (id: ${result.announcementId}).`;
|
|
934
|
-
const msgs = formatAnnouncements(
|
|
1073
|
+
const msgs = formatAnnouncements(
|
|
1074
|
+
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1075
|
+
);
|
|
935
1076
|
return {
|
|
936
1077
|
content: [
|
|
937
1078
|
{ type: "text", text: msgs ? `${base}
|
|
@@ -963,6 +1104,10 @@ ${msgs}` : base }
|
|
|
963
1104
|
const changeReport = await changeReportForBody();
|
|
964
1105
|
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
965
1106
|
const result = await hubClient.post("/sync", body);
|
|
1107
|
+
result.landscape.announcements = mergeAnnouncements(
|
|
1108
|
+
result.landscape.announcements,
|
|
1109
|
+
drainLocalInbox()
|
|
1110
|
+
);
|
|
966
1111
|
const text = withIdentity(
|
|
967
1112
|
withChangeRecords(result.landscape, formatLandscape(result.landscape))
|
|
968
1113
|
);
|
|
@@ -1012,7 +1157,9 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1012
1157
|
function createHeartbeat({
|
|
1013
1158
|
hubClient,
|
|
1014
1159
|
intervalSeconds,
|
|
1015
|
-
buildReport
|
|
1160
|
+
buildReport,
|
|
1161
|
+
deliverAnnouncements = false,
|
|
1162
|
+
onAnnouncements
|
|
1016
1163
|
}) {
|
|
1017
1164
|
let timer = null;
|
|
1018
1165
|
function stop() {
|
|
@@ -1030,8 +1177,20 @@ function createHeartbeat({
|
|
|
1030
1177
|
changeReport = void 0;
|
|
1031
1178
|
}
|
|
1032
1179
|
}
|
|
1033
|
-
const body =
|
|
1034
|
-
|
|
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
|
+
}
|
|
1035
1194
|
}
|
|
1036
1195
|
function start(sessionId) {
|
|
1037
1196
|
stop();
|
|
@@ -1071,6 +1230,8 @@ async function main() {
|
|
|
1071
1230
|
const config = loadConfig();
|
|
1072
1231
|
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
1073
1232
|
const context = await resolveContext(config);
|
|
1233
|
+
const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
|
|
1234
|
+
const inboxFile = inboxFilePath(inboxDir, process.cwd());
|
|
1074
1235
|
const heartbeat = createHeartbeat({
|
|
1075
1236
|
hubClient,
|
|
1076
1237
|
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
|
|
@@ -1082,13 +1243,15 @@ async function main() {
|
|
|
1082
1243
|
} catch {
|
|
1083
1244
|
return void 0;
|
|
1084
1245
|
}
|
|
1085
|
-
}
|
|
1246
|
+
},
|
|
1247
|
+
deliverAnnouncements: true,
|
|
1248
|
+
onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
|
|
1086
1249
|
});
|
|
1087
1250
|
const server = new McpServer(
|
|
1088
1251
|
{ name: "shepherd", version: "0.1.0" },
|
|
1089
1252
|
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
1090
1253
|
);
|
|
1091
|
-
const tools = registerTools(server, { hubClient, config, context, heartbeat });
|
|
1254
|
+
const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
|
|
1092
1255
|
const transport = new StdioServerTransport();
|
|
1093
1256
|
let shuttingDown = false;
|
|
1094
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",
|