@korso/shepherd 0.4.1 → 0.4.3
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 +217 -44
- 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,14 @@ 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),
|
|
360
|
+
// True when an agent addressed the message TO the operator (the mirror of
|
|
361
|
+
// fromAdmin). The dashboard renders these as "<agent> → admin". Not delivered
|
|
362
|
+
// to other agents. Defaulted for version-skew safety with older hubs.
|
|
363
|
+
toAdmin: z2.boolean().default(false),
|
|
315
364
|
createdAt: IsoTimestamp
|
|
316
365
|
});
|
|
317
366
|
var WorkspaceLandscapeResponse = z2.object({
|
|
@@ -322,6 +371,22 @@ var WorkspaceLandscapeResponse = z2.object({
|
|
|
322
371
|
// the hub rather than the (possibly skewed) browser clock.
|
|
323
372
|
serverTime: IsoTimestamp
|
|
324
373
|
});
|
|
374
|
+
var WorkspaceAnnounceRequest = z2.object({
|
|
375
|
+
body: z2.string().min(1).max(8192),
|
|
376
|
+
// Direct-message a single agent (by the exact name shown in the landscape).
|
|
377
|
+
// Absent/null => broadcast. The hub resolves the target's repo server-side.
|
|
378
|
+
targetAgentName: z2.string().min(1).nullable().optional(),
|
|
379
|
+
// For a broadcast, the repo to scope the message to (matches the dashboard's
|
|
380
|
+
// selected repo). Absent/null => fan out to every repo in the workspace.
|
|
381
|
+
// Ignored for a DM (the target's own repo is used).
|
|
382
|
+
repo: z2.string().min(1).nullable().optional()
|
|
383
|
+
});
|
|
384
|
+
var WorkspaceAnnounceResponse = z2.object({
|
|
385
|
+
ok: z2.literal(true),
|
|
386
|
+
// One id per inserted row: a single id for a DM or repo-scoped broadcast, or
|
|
387
|
+
// several when an all-repos broadcast fans out across repos.
|
|
388
|
+
announcementIds: z2.array(DbId)
|
|
389
|
+
});
|
|
325
390
|
var JoinRequest = z2.object({
|
|
326
391
|
workspace: z2.string().min(1),
|
|
327
392
|
repo: z2.string().min(1),
|
|
@@ -360,7 +425,13 @@ var AnnounceRequest = z2.object({
|
|
|
360
425
|
sessionId: z2.string().uuid(),
|
|
361
426
|
body: z2.string().min(1).max(8192),
|
|
362
427
|
// absent or null => broadcast to all agents in the workspace
|
|
363
|
-
targetAgentName: z2.string().nullable().optional()
|
|
428
|
+
targetAgentName: z2.string().nullable().optional(),
|
|
429
|
+
// true => address the human operator (the dashboard) instead of agents. The
|
|
430
|
+
// mirror of the operator's admin → agent DM: it shows in the workspace feed as
|
|
431
|
+
// "<agent> → admin" and is NOT delivered to other agents. Mutually exclusive
|
|
432
|
+
// with targetAgentName (the hub rejects setting both). Defaulted/optional for
|
|
433
|
+
// version skew with older clients.
|
|
434
|
+
toAdmin: z2.boolean().optional()
|
|
364
435
|
});
|
|
365
436
|
var AnnounceResponse = z2.object({
|
|
366
437
|
ok: z2.literal(true),
|
|
@@ -389,10 +460,21 @@ var HeartbeatRequest = z2.object({
|
|
|
389
460
|
// change records fresh (commits surface within ~one heartbeat interval, not
|
|
390
461
|
// only when it next calls work/sync). Processed presence-style: it refreshes
|
|
391
462
|
// change records but, like the rest of heartbeat, does NOT renew claim TTLs.
|
|
392
|
-
changeReport: ChangeReport.optional()
|
|
463
|
+
changeReport: ChangeReport.optional(),
|
|
464
|
+
// Opt-in: when set, the heartbeat ALSO delivers (and marks delivered) any
|
|
465
|
+
// pending announcements for the caller, returned in the response. The MCP
|
|
466
|
+
// client only sets this when it has somewhere model-visible to surface them
|
|
467
|
+
// (a local inbox file drained by a hook) — otherwise the long-standing
|
|
468
|
+
// invariant holds: heartbeat must NOT consume announcements the model can't
|
|
469
|
+
// see. Absent for older clients, so default delivery is unchanged.
|
|
470
|
+
deliverAnnouncements: z2.boolean().optional()
|
|
393
471
|
});
|
|
394
472
|
var HeartbeatResponse = z2.object({
|
|
395
|
-
ok: z2.literal(true)
|
|
473
|
+
ok: z2.literal(true),
|
|
474
|
+
// Pending announcements for the caller, delivered only when the request set
|
|
475
|
+
// `deliverAnnouncements`. Defaulted to [] for version-skew safety with older
|
|
476
|
+
// hubs (which return just { ok: true }).
|
|
477
|
+
announcements: z2.array(Announcement).default([])
|
|
396
478
|
});
|
|
397
479
|
var LeaveRequest = z2.object({
|
|
398
480
|
sessionId: z2.string().uuid()
|
|
@@ -443,35 +525,6 @@ function runGitExitOk(cwd, args) {
|
|
|
443
525
|
function isValidSha(sha) {
|
|
444
526
|
return /^[0-9a-f]{4,64}$/.test(sha);
|
|
445
527
|
}
|
|
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
528
|
function detectRepo(cwd = process.cwd()) {
|
|
476
529
|
const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
|
|
477
530
|
if (origin) {
|
|
@@ -674,6 +727,88 @@ async function buildChangeReport(cwd, config) {
|
|
|
674
727
|
};
|
|
675
728
|
}
|
|
676
729
|
|
|
730
|
+
// src/inbox.ts
|
|
731
|
+
import { createHash } from "crypto";
|
|
732
|
+
import {
|
|
733
|
+
appendFileSync,
|
|
734
|
+
mkdirSync,
|
|
735
|
+
readFileSync,
|
|
736
|
+
renameSync,
|
|
737
|
+
rmSync,
|
|
738
|
+
existsSync
|
|
739
|
+
} from "fs";
|
|
740
|
+
import { homedir, tmpdir } from "os";
|
|
741
|
+
import { dirname, join, resolve } from "path";
|
|
742
|
+
function defaultInboxDir() {
|
|
743
|
+
let base = "";
|
|
744
|
+
try {
|
|
745
|
+
base = homedir();
|
|
746
|
+
} catch {
|
|
747
|
+
base = "";
|
|
748
|
+
}
|
|
749
|
+
if (!base) base = tmpdir();
|
|
750
|
+
return join(base, ".shepherd", "inbox");
|
|
751
|
+
}
|
|
752
|
+
function inboxFilePath(dir, cwd) {
|
|
753
|
+
let normalized = resolve(cwd);
|
|
754
|
+
if (process.platform === "win32") normalized = normalized.toLowerCase();
|
|
755
|
+
const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
756
|
+
return join(dir, `${hash}.jsonl`);
|
|
757
|
+
}
|
|
758
|
+
function appendAnnouncements(filePath, announcements) {
|
|
759
|
+
if (!announcements || announcements.length === 0) return;
|
|
760
|
+
try {
|
|
761
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
762
|
+
const payload = announcements.map((a) => JSON.stringify(a)).join("\n") + "\n";
|
|
763
|
+
appendFileSync(filePath, payload, "utf8");
|
|
764
|
+
} catch {
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
function drainInbox(filePath) {
|
|
768
|
+
const tmp = `${filePath}.draining`;
|
|
769
|
+
let raw = "";
|
|
770
|
+
try {
|
|
771
|
+
if (existsSync(tmp)) {
|
|
772
|
+
raw += readFileSync(tmp, "utf8");
|
|
773
|
+
rmSync(tmp, { force: true });
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
if (existsSync(filePath)) {
|
|
779
|
+
renameSync(filePath, tmp);
|
|
780
|
+
raw += readFileSync(tmp, "utf8");
|
|
781
|
+
rmSync(tmp, { force: true });
|
|
782
|
+
}
|
|
783
|
+
} catch {
|
|
784
|
+
}
|
|
785
|
+
if (!raw.trim()) return [];
|
|
786
|
+
const seen = /* @__PURE__ */ new Set();
|
|
787
|
+
const out = [];
|
|
788
|
+
for (const line of raw.split("\n")) {
|
|
789
|
+
const trimmed = line.trim();
|
|
790
|
+
if (!trimmed) continue;
|
|
791
|
+
try {
|
|
792
|
+
const parsed = JSON.parse(trimmed);
|
|
793
|
+
if (typeof parsed?.id !== "number" || seen.has(parsed.id)) continue;
|
|
794
|
+
seen.add(parsed.id);
|
|
795
|
+
out.push(parsed);
|
|
796
|
+
} catch {
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
function mergeAnnouncements(...lists) {
|
|
802
|
+
const byId = /* @__PURE__ */ new Map();
|
|
803
|
+
for (const list of lists) {
|
|
804
|
+
if (!list) continue;
|
|
805
|
+
for (const a of list) {
|
|
806
|
+
if (!byId.has(a.id)) byId.set(a.id, a);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return [...byId.values()].sort((x, y) => x.id - y.id);
|
|
810
|
+
}
|
|
811
|
+
|
|
677
812
|
// src/tools.ts
|
|
678
813
|
function formatLandscape(landscape) {
|
|
679
814
|
const lines = [];
|
|
@@ -793,7 +928,7 @@ function degradedResult(err) {
|
|
|
793
928
|
};
|
|
794
929
|
}
|
|
795
930
|
function registerTools(server, deps) {
|
|
796
|
-
const { hubClient, config, context, heartbeat } = deps;
|
|
931
|
+
const { hubClient, config, context, heartbeat, inboxFile } = deps;
|
|
797
932
|
let sessionId = null;
|
|
798
933
|
let agentName = null;
|
|
799
934
|
const joinBody = {
|
|
@@ -837,6 +972,14 @@ ${body}` : body;
|
|
|
837
972
|
return void 0;
|
|
838
973
|
}
|
|
839
974
|
}
|
|
975
|
+
function drainLocalInbox() {
|
|
976
|
+
if (!inboxFile) return [];
|
|
977
|
+
try {
|
|
978
|
+
return drainInbox(inboxFile);
|
|
979
|
+
} catch {
|
|
980
|
+
return [];
|
|
981
|
+
}
|
|
982
|
+
}
|
|
840
983
|
function withChangeRecords(landscape, body) {
|
|
841
984
|
let section = "";
|
|
842
985
|
try {
|
|
@@ -864,6 +1007,10 @@ ${section}` : body;
|
|
|
864
1007
|
const changeReport = await changeReportForBody();
|
|
865
1008
|
const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
|
|
866
1009
|
const result = await hubClient.post("/work", body);
|
|
1010
|
+
result.landscape.announcements = mergeAnnouncements(
|
|
1011
|
+
result.landscape.announcements,
|
|
1012
|
+
drainLocalInbox()
|
|
1013
|
+
);
|
|
867
1014
|
const text = withIdentity(
|
|
868
1015
|
withChangeRecords(
|
|
869
1016
|
result.landscape,
|
|
@@ -871,7 +1018,7 @@ ${section}` : body;
|
|
|
871
1018
|
|
|
872
1019
|
` + formatLandscape(result.landscape) + `
|
|
873
1020
|
|
|
874
|
-
You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~
|
|
1021
|
+
You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~60 min). Calling work or sync renews it.`
|
|
875
1022
|
)
|
|
876
1023
|
);
|
|
877
1024
|
return { content: [{ type: "text", text }] };
|
|
@@ -899,7 +1046,9 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
|
|
|
899
1046
|
const body = { sessionId, ...args };
|
|
900
1047
|
const result = await hubClient.post("/done", body);
|
|
901
1048
|
const base = "Work item released. Call work again before your next edit in a new area.";
|
|
902
|
-
const msgs = formatAnnouncements(
|
|
1049
|
+
const msgs = formatAnnouncements(
|
|
1050
|
+
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1051
|
+
);
|
|
903
1052
|
return {
|
|
904
1053
|
content: [
|
|
905
1054
|
{ type: "text", text: msgs ? `${base}
|
|
@@ -919,7 +1068,7 @@ ${msgs}` : base }
|
|
|
919
1068
|
"announce",
|
|
920
1069
|
{
|
|
921
1070
|
title: "Broadcast a message to teammates",
|
|
922
|
-
description: "Broadcast a heads-up to the other agents,
|
|
1071
|
+
description: "Broadcast a heads-up to the other agents, direct a finding to a specific agent, or reply to the human operator. This is awareness only \u2014 not a task assignment. To direct it to an agent, pass that agent's name (exactly as shown in the landscape) as targetAgentName; to reply to the operator (the dashboard), pass toAdmin: true; omit both to broadcast to everyone in the workspace. targetAgentName and toAdmin are mutually exclusive. Delivery is best-effort: the recipient sees it on their next work/sync, once.",
|
|
923
1072
|
inputSchema: AnnounceAgentInput.shape
|
|
924
1073
|
},
|
|
925
1074
|
async (args) => {
|
|
@@ -931,7 +1080,9 @@ ${msgs}` : base }
|
|
|
931
1080
|
const body = { sessionId, ...args };
|
|
932
1081
|
const result = await hubClient.post("/announce", body);
|
|
933
1082
|
const base = `Announcement sent (id: ${result.announcementId}).`;
|
|
934
|
-
const msgs = formatAnnouncements(
|
|
1083
|
+
const msgs = formatAnnouncements(
|
|
1084
|
+
mergeAnnouncements(result.announcements, drainLocalInbox())
|
|
1085
|
+
);
|
|
935
1086
|
return {
|
|
936
1087
|
content: [
|
|
937
1088
|
{ type: "text", text: msgs ? `${base}
|
|
@@ -963,6 +1114,10 @@ ${msgs}` : base }
|
|
|
963
1114
|
const changeReport = await changeReportForBody();
|
|
964
1115
|
const body = { sessionId, ...changeReport ? { changeReport } : {} };
|
|
965
1116
|
const result = await hubClient.post("/sync", body);
|
|
1117
|
+
result.landscape.announcements = mergeAnnouncements(
|
|
1118
|
+
result.landscape.announcements,
|
|
1119
|
+
drainLocalInbox()
|
|
1120
|
+
);
|
|
966
1121
|
const text = withIdentity(
|
|
967
1122
|
withChangeRecords(result.landscape, formatLandscape(result.landscape))
|
|
968
1123
|
);
|
|
@@ -1012,7 +1167,9 @@ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
|
|
|
1012
1167
|
function createHeartbeat({
|
|
1013
1168
|
hubClient,
|
|
1014
1169
|
intervalSeconds,
|
|
1015
|
-
buildReport
|
|
1170
|
+
buildReport,
|
|
1171
|
+
deliverAnnouncements = false,
|
|
1172
|
+
onAnnouncements
|
|
1016
1173
|
}) {
|
|
1017
1174
|
let timer = null;
|
|
1018
1175
|
function stop() {
|
|
@@ -1030,8 +1187,20 @@ function createHeartbeat({
|
|
|
1030
1187
|
changeReport = void 0;
|
|
1031
1188
|
}
|
|
1032
1189
|
}
|
|
1033
|
-
const body =
|
|
1034
|
-
|
|
1190
|
+
const body = { sessionId };
|
|
1191
|
+
if (changeReport) body.changeReport = changeReport;
|
|
1192
|
+
if (deliverAnnouncements) body.deliverAnnouncements = true;
|
|
1193
|
+
const response = await hubClient.post("/heartbeat", body);
|
|
1194
|
+
const delivered = response?.announcements;
|
|
1195
|
+
if (onAnnouncements && Array.isArray(delivered) && delivered.length > 0) {
|
|
1196
|
+
try {
|
|
1197
|
+
onAnnouncements(delivered);
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
console.error(
|
|
1200
|
+
`[shepherd] inbox delivery failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1035
1204
|
}
|
|
1036
1205
|
function start(sessionId) {
|
|
1037
1206
|
stop();
|
|
@@ -1071,6 +1240,8 @@ async function main() {
|
|
|
1071
1240
|
const config = loadConfig();
|
|
1072
1241
|
const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
|
|
1073
1242
|
const context = await resolveContext(config);
|
|
1243
|
+
const inboxDir = config.SHEPHERD_INBOX_DIR ?? defaultInboxDir();
|
|
1244
|
+
const inboxFile = inboxFilePath(inboxDir, process.cwd());
|
|
1074
1245
|
const heartbeat = createHeartbeat({
|
|
1075
1246
|
hubClient,
|
|
1076
1247
|
intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
|
|
@@ -1082,13 +1253,15 @@ async function main() {
|
|
|
1082
1253
|
} catch {
|
|
1083
1254
|
return void 0;
|
|
1084
1255
|
}
|
|
1085
|
-
}
|
|
1256
|
+
},
|
|
1257
|
+
deliverAnnouncements: true,
|
|
1258
|
+
onAnnouncements: (announcements) => appendAnnouncements(inboxFile, announcements)
|
|
1086
1259
|
});
|
|
1087
1260
|
const server = new McpServer(
|
|
1088
1261
|
{ name: "shepherd", version: "0.1.0" },
|
|
1089
1262
|
{ instructions: SHEPHERD_INSTRUCTIONS }
|
|
1090
1263
|
);
|
|
1091
|
-
const tools = registerTools(server, { hubClient, config, context, heartbeat });
|
|
1264
|
+
const tools = registerTools(server, { hubClient, config, context, heartbeat, inboxFile });
|
|
1092
1265
|
const transport = new StdioServerTransport();
|
|
1093
1266
|
let shuttingDown = false;
|
|
1094
1267
|
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.3",
|
|
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",
|