@korso/shepherd 0.2.0 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +69 -50
  2. package/dist/index.js +720 -43
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,9 +8,9 @@ Shepherd's stdio MCP server. Gives any MCP-capable agent (Claude Code, Codex, et
8
8
 
9
9
  ## CRITICAL: WORKSPACE must match the hub exactly
10
10
 
11
- > **Everyone must set `WORKSPACE` to the identical string, and that string must equal the hub's `ALLOWED_WORKSPACE` env var.**
11
+ > **`WORKSPACE` defaults to `default`. If you override it, the value must equal the hub's `ALLOWED_WORKSPACE` env var exactly.**
12
12
 
13
- If `WORKSPACE` does not match, the server's automatic `join` call to the hub (fired at startup) returns HTTP 400. Coordination then degrades: every tool reports "session not ready … proceeding uncoordinated" instead of a landscape. This is the most common silent onboarding mistake — if your agent never sees teammates, check `WORKSPACE` first.
13
+ The server fires an automatic `join` call to the hub at startup. If the workspace it sends does not match the hub's `ALLOWED_WORKSPACE`, that call returns HTTP 400 and coordination degrades: every tool reports "session not ready … proceeding uncoordinated" instead of a landscape. The safe default is to **leave `WORKSPACE` unset** so it resolves to `default` — only set it when a maintainer points you at a different workspace. If your agent never sees teammates, check `WORKSPACE` (and `TEAM_TOKEN`) first.
14
14
 
15
15
  ---
16
16
 
@@ -32,22 +32,33 @@ first fetch, and `@korso/shepherd@latest` picks up updates automatically.
32
32
 
33
33
  ---
34
34
 
35
- ## 2. Required environment variables
35
+ ## 2. Environment variables
36
36
 
37
- Every instance of the MCP server needs these eight env vars set in its client config:
37
+ **Only two are required:**
38
38
 
39
39
  | Variable | Description | Example |
40
40
  |---|---|---|
41
41
  | `HUB_URL` | Base URL of the deployed hub | `https://shepherd.example.com` |
42
42
  | `TEAM_TOKEN` | Shared bearer token accepted by the hub | `tok_abc123` |
43
- | `WORKSPACE` | **Must match hub's `ALLOWED_WORKSPACE` exactly** | `shepherd` |
44
- | `REPO` | Repository slug (used for scoping claims) | `shepherd` |
45
- | `BRANCH` | Git branch name | `main` |
46
- | `HUMAN` | Founder name — identifies you in the presence feed | `daichi` |
47
- | `PROGRAM` | Agent program name | `claude-code` |
48
- | `MODEL` | Model ID being used | `claude-sonnet-4-6` |
49
43
 
50
- All eight are required. Missing any one causes an immediate startup failure with a clear error on stderr listing which vars are absent.
44
+ Missing either causes an immediate startup failure with a clear error on stderr
45
+ listing which vars are absent. (No other var triggers this.)
46
+
47
+ **Everything else is optional** — each identity field is resolved at startup as
48
+ **env var → git detection → fallback**, so a plain `npx -y @korso/shepherd` with
49
+ just the two required vars produces a valid, fully-identified session. Set an
50
+ override only to replace what's detected:
51
+
52
+ | Variable | If omitted | Example |
53
+ |---|---|---|
54
+ | `WORKSPACE` | defaults to `default` (**must match hub's `ALLOWED_WORKSPACE` if overridden**) | `shepherd` |
55
+ | `REPO` | `git remote origin` → `owner/repo`, else repo folder name, else `unknown-repo` | `Korsoai/shepherd` |
56
+ | `BRANCH` | `git rev-parse --abbrev-ref HEAD`, else `HEAD` | `main` |
57
+ | `BASE_BRANCH` | `origin/HEAD`, else `origin/main` / `origin/master` (used for the change-awareness heads-up) | `origin/main` |
58
+ | `HUMAN` | git `user.name`, else local-part of `user.email`, else a generated name | `daichi` |
59
+ | `PROGRAM` | defaults to `claude-code` | `codex` |
60
+ | `MODEL` | omitted — **never auto-detected**, so set it if you want it shown | `claude-sonnet-4-6` |
61
+ | `HEARTBEAT_INTERVAL_SECONDS` | defaults to `60` | `30` |
51
62
 
52
63
  ---
53
64
 
@@ -60,19 +71,19 @@ All eight are required. Missing any one causes an immediate startup failure with
60
71
  > everywhere) or a project-root `.mcp.json`. Confirm with `claude mcp list`,
61
72
  > which should show `shepherd … ✔ Connected`.
62
73
 
63
- Recommended — register once at user scope (works identically on Windows/macOS/Linux):
74
+ Recommended — register once at user scope. Written as a **single line** so it
75
+ pastes cleanly into PowerShell, cmd, bash, and zsh (on PowerShell the bash `\`
76
+ line-continuation does not work). Minimal: just the two required vars (identity
77
+ is auto-detected from git):
64
78
 
65
- ```sh
66
- claude mcp add shepherd -s user \
67
- -e HUB_URL=https://shepherd.example.com \
68
- -e TEAM_TOKEN=tok_abc123 \
69
- -e WORKSPACE=shepherd \
70
- -e REPO=shepherd -e BRANCH=main -e HUMAN=daichi \
71
- -e PROGRAM=claude-code -e MODEL=claude-sonnet-4-6 \
72
- -- npx -y @korso/shepherd
79
+ ```powershell
80
+ claude mcp add shepherd -s user -e HUB_URL=https://shepherd.example.com -e TEAM_TOKEN=tok_abc123 -- npx -y @korso/shepherd
73
81
  ```
74
82
 
75
- Alternative — a `.mcp.json` at the **root of the repo you're working in**:
83
+ Add any optional overrides from §2 with extra `-e` flags (e.g. `-e MODEL=claude-sonnet-4-6 -e HUMAN=daichi`).
84
+
85
+ Alternative — a `.mcp.json` at the **root of the repo you're working in**
86
+ (optional overrides shown commented-style; drop the ones you don't need):
76
87
 
77
88
  ```json
78
89
  {
@@ -83,11 +94,6 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
83
94
  "env": {
84
95
  "HUB_URL": "https://shepherd.example.com",
85
96
  "TEAM_TOKEN": "tok_abc123",
86
- "WORKSPACE": "shepherd",
87
- "REPO": "shepherd",
88
- "BRANCH": "main",
89
- "HUMAN": "daichi",
90
- "PROGRAM": "claude-code",
91
97
  "MODEL": "claude-sonnet-4-6"
92
98
  }
93
99
  }
@@ -100,9 +106,29 @@ Alternative — a `.mcp.json` at the **root of the repo you're working in**:
100
106
  > on Linux (Postgres), so the Windows-native durability concerns from the spike
101
107
  > don't apply to clients.
102
108
 
103
- ### Codex (`~/.codex/config.json` or `codex.json`)
109
+ ### Codex (`~/.codex/config.toml`)
110
+
111
+ Codex uses the same MCP stdio protocol but configures it in **TOML**, not JSON —
112
+ at `~/.codex/config.toml` (global) or `.codex/config.toml` in a trusted project.
113
+ The table is `mcp_servers` with an **underscore** (`mcp-servers`/`mcpServers` are
114
+ silently ignored). Either run `codex mcp add`:
104
115
 
105
- Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
116
+ ```sh
117
+ codex mcp add shepherd --env HUB_URL=https://shepherd.example.com --env TEAM_TOKEN=tok_abc123 --env PROGRAM=codex -- npx -y @korso/shepherd
118
+ ```
119
+
120
+ …or add the table directly:
121
+
122
+ ```toml
123
+ [mcp_servers.shepherd]
124
+ command = "npx"
125
+ args = ["-y", "@korso/shepherd"]
126
+ env = { HUB_URL = "https://shepherd.example.com", TEAM_TOKEN = "tok_abc123", PROGRAM = "codex", MODEL = "o4-mini" }
127
+ ```
128
+
129
+ ### Pi (`~/.pi/agent/mcp.json` or `.pi/mcp.json`)
130
+
131
+ Pi uses a JSON `mcpServers` block (project config overrides global):
106
132
 
107
133
  ```json
108
134
  {
@@ -113,12 +139,7 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
113
139
  "env": {
114
140
  "HUB_URL": "https://shepherd.example.com",
115
141
  "TEAM_TOKEN": "tok_abc123",
116
- "WORKSPACE": "shepherd",
117
- "REPO": "shepherd",
118
- "BRANCH": "main",
119
- "HUMAN": "alex",
120
- "PROGRAM": "codex",
121
- "MODEL": "o4-mini"
142
+ "PROGRAM": "pi"
122
143
  }
123
144
  }
124
145
  }
@@ -129,34 +150,31 @@ Codex uses the same MCP stdio protocol. Add a server entry under `mcpServers`:
129
150
 
130
151
  ## 4. Verify the server starts (quick smoke test)
131
152
 
132
- Run with all env vars set to confirm it connects and idles on stdin:
153
+ Run with the two required vars set to confirm it connects and idles on stdin.
154
+ PowerShell (set env vars, then run):
133
155
 
134
- ```sh
135
- HUB_URL=https://shepherd.example.com \
136
- TEAM_TOKEN=tok_abc123 \
137
- WORKSPACE=shepherd \
138
- REPO=shepherd \
139
- BRANCH=main \
140
- HUMAN=daichi \
141
- PROGRAM=claude-code \
142
- MODEL=claude-sonnet-4-6 \
156
+ ```powershell
157
+ $env:HUB_URL = "https://shepherd.example.com"
158
+ $env:TEAM_TOKEN = "tok_abc123"
143
159
  npx -y @korso/shepherd
144
160
  ```
145
161
 
162
+ bash/zsh: `HUB_URL=https://shepherd.example.com TEAM_TOKEN=tok_abc123 npx -y @korso/shepherd`
163
+
146
164
  No stderr output and the process blocking on stdin = healthy. Press Ctrl+C to exit.
147
165
 
148
- **Missing env vars:** if you deliberately omit a var, you will see:
166
+ **Missing env vars:** if you omit `HUB_URL` or `TEAM_TOKEN`, you will see:
149
167
 
150
168
  ```
151
169
  [shepherd] Configuration error — missing or invalid env vars:
152
170
  HUB_URL: HUB_URL is required
153
171
  TEAM_TOKEN: TEAM_TOKEN is required
154
- ...
155
172
  ```
156
173
 
157
- and the process exits 1 immediately. This is by design.
174
+ and the process exits 1 immediately. This is by design. The optional identity
175
+ vars never cause this — they fall back to git detection / defaults.
158
176
 
159
- **Wrong WORKSPACE:** the server starts and connects, but the startup auto-join is rejected (400), so every tool call (`work`, `sync`, etc.) reports "session not ready … proceeding uncoordinated". Check that your `WORKSPACE` value exactly matches the hub's `ALLOWED_WORKSPACE`, then restart.
177
+ **Wrong WORKSPACE:** if you override `WORKSPACE` to a value the hub doesn't allow, the server starts and connects but the startup auto-join is rejected (400), so every tool call (`work`, `sync`, etc.) reports "session not ready … proceeding uncoordinated". Either leave `WORKSPACE` unset (resolves to `default`) or set it to exactly match the hub's `ALLOWED_WORKSPACE`, then restart.
160
178
 
161
179
  ---
162
180
 
@@ -197,7 +215,8 @@ npm publish --workspace=@korso/shepherd # prepublishOnly runs tsup automatical
197
215
 
198
216
  | Symptom | Likely cause | Fix |
199
217
  |---|---|---|
200
- | `Configuration error — missing or invalid env vars` | One or more of the 8 env vars is absent | Add the missing vars to your client's `env` block |
201
- | Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually `WORKSPACE` mismatch (or stale `TEAM_TOKEN`) | Set `WORKSPACE` to exactly match the hub's `ALLOWED_WORKSPACE`; re-check `TEAM_TOKEN`; restart |
218
+ | `Configuration error — missing or invalid env vars` | `HUB_URL` or `TEAM_TOKEN` is absent (only these two are required) | Add the missing var(s) to your client's `env` block |
219
+ | Tools report "session not ready … proceeding uncoordinated" | Startup auto-join rejected — usually a stale `TEAM_TOKEN`, or a `WORKSPACE` override the hub doesn't allow | Re-check `TEAM_TOKEN`; leave `WORKSPACE` unset (→ `default`) or match the hub's `ALLOWED_WORKSPACE`; restart |
220
+ | Agent shows up under a surprising name/repo/branch | Identity auto-detected from git | Override with `HUMAN`/`REPO`/`BRANCH`/`MODEL` env vars (§2) |
202
221
  | `npm error 404 … @korso/shepherd` | Package not published yet, or name typo | `npm view @korso/shepherd version` to confirm it's live |
203
222
  | Process exits immediately with no error | Rare; check for node version incompatibility | Requires Node 18+ (ESM support) |
package/dist/index.js CHANGED
@@ -7,14 +7,20 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  // src/config.ts
8
8
  import { z } from "zod";
9
9
  var ConfigSchema = z.object({
10
+ // Hard-required: connection credentials.
10
11
  HUB_URL: z.string().min(1, "HUB_URL is required"),
11
12
  TEAM_TOKEN: z.string().min(1, "TEAM_TOKEN is required"),
12
- WORKSPACE: z.string().min(1, "WORKSPACE is required"),
13
- REPO: z.string().min(1, "REPO is required"),
14
- BRANCH: z.string().min(1, "BRANCH is required"),
15
- HUMAN: z.string().min(1, "HUMAN is required"),
16
- PROGRAM: z.string().min(1, "PROGRAM is required"),
17
- MODEL: z.string().min(1, "MODEL is required")
13
+ // Optional overrides — resolveContext will apply defaults for any that are absent.
14
+ // WORKSPACE default is applied in resolveContext (auto-detected from cwd basename).
15
+ WORKSPACE: z.string().min(1).optional(),
16
+ REPO: z.string().min(1).optional(),
17
+ BRANCH: z.string().min(1).optional(),
18
+ BASE_BRANCH: z.string().min(1).optional(),
19
+ HUMAN: z.string().min(1).optional(),
20
+ PROGRAM: z.string().min(1).optional(),
21
+ MODEL: z.string().min(1).optional(),
22
+ // Heartbeat cadence in seconds; coerced from string env var.
23
+ HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().int().positive().default(60)
18
24
  });
19
25
  function parseConfig(env) {
20
26
  return ConfigSchema.parse({
@@ -23,9 +29,11 @@ function parseConfig(env) {
23
29
  WORKSPACE: env["WORKSPACE"],
24
30
  REPO: env["REPO"],
25
31
  BRANCH: env["BRANCH"],
32
+ BASE_BRANCH: env["BASE_BRANCH"],
26
33
  HUMAN: env["HUMAN"],
27
34
  PROGRAM: env["PROGRAM"],
28
- MODEL: env["MODEL"]
35
+ MODEL: env["MODEL"],
36
+ HEARTBEAT_INTERVAL_SECONDS: env["HEARTBEAT_INTERVAL_SECONDS"]
29
37
  });
30
38
  }
31
39
  function loadConfig(env = process.env) {
@@ -71,12 +79,12 @@ function createHubClient({
71
79
  }) {
72
80
  const baseUrl = hubUrl.replace(/\/$/, "");
73
81
  return {
74
- async post(path, body) {
82
+ async post(path2, body) {
75
83
  const controller = new AbortController();
76
84
  const timer = setTimeout(() => controller.abort(), timeoutMs);
77
85
  let response;
78
86
  try {
79
- response = await fetch(`${baseUrl}${path}`, {
87
+ response = await fetch(`${baseUrl}${path2}`, {
80
88
  method: "POST",
81
89
  headers: {
82
90
  "Authorization": `Bearer ${teamToken}`,
@@ -87,7 +95,7 @@ function createHubClient({
87
95
  });
88
96
  } catch (err) {
89
97
  clearTimeout(timer);
90
- const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path})` : `Hub unreachable at ${baseUrl}${path}: ${String(err)}`;
98
+ const message = err instanceof DOMException && err.name === "AbortError" ? `Hub request timed out after ${timeoutMs}ms (${path2})` : `Hub unreachable at ${baseUrl}${path2}: ${String(err)}`;
91
99
  throw new HubUnreachable(message, err);
92
100
  } finally {
93
101
  clearTimeout(timer);
@@ -95,7 +103,7 @@ function createHubClient({
95
103
  if (!response.ok) {
96
104
  throw new HubRequestError(
97
105
  response.status,
98
- `Hub returned HTTP ${response.status} for ${path}`
106
+ `Hub returned HTTP ${response.status} for ${path2}`
99
107
  );
100
108
  }
101
109
  return response.json();
@@ -103,10 +111,145 @@ function createHubClient({
103
111
  };
104
112
  }
105
113
 
114
+ // ../shared/dist/names.js
115
+ var adjectives = [
116
+ "Able",
117
+ "Agile",
118
+ "Artful",
119
+ "Avid",
120
+ "Balanced",
121
+ "Brave",
122
+ "Bright",
123
+ "Brisk",
124
+ "Calm",
125
+ "Clear",
126
+ "Clever",
127
+ "Crisp",
128
+ "Daring",
129
+ "Diligent",
130
+ "Deft",
131
+ "Deep",
132
+ "Dynamic",
133
+ "Eager",
134
+ "Earnest",
135
+ "Elegant",
136
+ "Energetic",
137
+ "Fair",
138
+ "Faithful",
139
+ "Fertile",
140
+ "Fierce",
141
+ "Firm",
142
+ "Fleet",
143
+ "Frank",
144
+ "Fresh",
145
+ "Friendly",
146
+ "Frisky",
147
+ "Gentle",
148
+ "Giant",
149
+ "Gifted",
150
+ "Global",
151
+ "Golden",
152
+ "Good",
153
+ "Grace",
154
+ "Grand",
155
+ "Green"
156
+ ];
157
+ var nouns = [
158
+ "Anchor",
159
+ "Arrow",
160
+ "Beacon",
161
+ "Bear",
162
+ "Beast",
163
+ "Bell",
164
+ "Blade",
165
+ "Blaze",
166
+ "Bridge",
167
+ "Bronze",
168
+ "Brook",
169
+ "Builder",
170
+ "Buzz",
171
+ "Castle",
172
+ "Cedar",
173
+ "Chain",
174
+ "Charm",
175
+ "Chase",
176
+ "Cliff",
177
+ "Cloud",
178
+ "Coast",
179
+ "Compass",
180
+ "Crown",
181
+ "Crystal",
182
+ "Current",
183
+ "Eagle",
184
+ "Earth",
185
+ "Echo",
186
+ "Edge",
187
+ "Element",
188
+ "Ember",
189
+ "Engine",
190
+ "Fable",
191
+ "Falcon",
192
+ "Fate",
193
+ "Fawn",
194
+ "Feather",
195
+ "Fiber",
196
+ "Field",
197
+ "Fire",
198
+ "Fisher",
199
+ "Flame",
200
+ "Flash",
201
+ "Fleet",
202
+ "Flight",
203
+ "Flint",
204
+ "Flood",
205
+ "Flow"
206
+ ];
207
+ function generateName() {
208
+ const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)];
209
+ const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
210
+ return randomAdj + randomNoun;
211
+ }
212
+
106
213
  // ../shared/dist/contract.js
107
214
  import { z as z2 } from "zod";
108
215
  var IsoTimestamp = z2.string();
109
216
  var DbId = z2.number();
217
+ var ChangeRecord = z2.object({
218
+ agentName: z2.string(),
219
+ human: z2.string(),
220
+ branch: z2.string(),
221
+ kind: z2.enum(["committed", "uncommitted"]),
222
+ commitSha: z2.string().nullable(),
223
+ message: z2.string().nullable(),
224
+ paths: z2.array(z2.string()).min(1),
225
+ authorIsLive: z2.boolean(),
226
+ authorLastActiveAt: IsoTimestamp,
227
+ updatedAt: IsoTimestamp
228
+ });
229
+ var ChangeReportEntry = z2.object({
230
+ kind: z2.enum(["committed", "uncommitted"]),
231
+ // A git object id (lowercase hex, 4–64 chars) for `committed` entries, or null
232
+ // for `uncommitted`. This value is forwarded by the hub to OTHER clients, which
233
+ // feed it straight into local `git` argument vectors (isAncestor/hasCommit/
234
+ // changedLineRanges). Validating the shape at the wire boundary stops an
235
+ // attacker-controlled, flag-like value (e.g. "--output=...") from being parsed
236
+ // by git as an option on a teammate's machine (argument injection). gitContext
237
+ // re-validates defensively as well.
238
+ sha: z2.string().regex(/^[0-9a-f]{4,64}$/).nullable(),
239
+ message: z2.string().nullable(),
240
+ paths: z2.array(z2.string()).min(1).max(500)
241
+ });
242
+ var ChangeReport = z2.object({
243
+ branch: z2.string(),
244
+ baseBranch: z2.string(),
245
+ head: z2.string(),
246
+ truncated: z2.boolean().default(false),
247
+ // The only producer (gitContext.unlandedCommits) emits at most MAX_COMMITS
248
+ // (100) committed entries + 1 uncommitted, so this ceiling is generous. If
249
+ // MAX_COMMITS is ever raised above ~599, raise this in lockstep or the hub
250
+ // will start 400-rejecting otherwise-valid reports.
251
+ entries: z2.array(ChangeReportEntry).max(600)
252
+ });
110
253
  var Claim = z2.object({
111
254
  workItemId: z2.string().uuid(),
112
255
  agentName: z2.string(),
@@ -134,7 +277,50 @@ var Landscape = z2.object({
134
277
  // claim is live. Optional with a default so an older client talking to a
135
278
  // newer hub (or vice-versa) never fails validation on its absence.
136
279
  yourClaims: z2.array(Claim).default([]),
137
- announcements: z2.array(Announcement)
280
+ announcements: z2.array(Announcement),
281
+ // Per-agent change records for the workspace. Defaulted for version-skew safety.
282
+ changeRecords: z2.array(ChangeRecord).default([])
283
+ });
284
+ var WorkspaceAgent = z2.object({
285
+ name: z2.string(),
286
+ human: z2.string(),
287
+ program: z2.string(),
288
+ // model is nullable in the DB (may be unknown when an agent first joins).
289
+ model: z2.string().nullable(),
290
+ // repo/branch/lastHeartbeatAt come from the agent's most-recent session and
291
+ // are null when the agent has no session yet.
292
+ repo: z2.string().nullable(),
293
+ branch: z2.string().nullable(),
294
+ lastHeartbeatAt: IsoTimestamp.nullable(),
295
+ presence: z2.enum(["live", "offline"])
296
+ });
297
+ var TaskStatus = z2.enum(["active", "done", "dropped"]);
298
+ var WorkspaceTask = z2.object({
299
+ agentName: z2.string(),
300
+ program: z2.string(),
301
+ model: z2.string().nullable(),
302
+ repo: z2.string(),
303
+ intent: z2.string(),
304
+ pathGlobs: z2.array(z2.string()),
305
+ status: TaskStatus,
306
+ createdAt: IsoTimestamp,
307
+ endedAt: IsoTimestamp.nullable()
308
+ });
309
+ var WorkspaceAnnouncement = z2.object({
310
+ fromAgentName: z2.string(),
311
+ fromHuman: z2.string(),
312
+ body: z2.string(),
313
+ targetAgentName: z2.string().nullable(),
314
+ repo: z2.string(),
315
+ createdAt: IsoTimestamp
316
+ });
317
+ var WorkspaceLandscapeResponse = z2.object({
318
+ agents: z2.array(WorkspaceAgent),
319
+ tasks: z2.array(WorkspaceTask),
320
+ announcements: z2.array(WorkspaceAnnouncement),
321
+ // The server's clock, so the client computes "expires in / last seen" against
322
+ // the hub rather than the (possibly skewed) browser clock.
323
+ serverTime: IsoTimestamp
138
324
  });
139
325
  var JoinRequest = z2.object({
140
326
  workspace: z2.string().min(1),
@@ -142,7 +328,7 @@ var JoinRequest = z2.object({
142
328
  branch: z2.string().min(1),
143
329
  human: z2.string().min(1),
144
330
  program: z2.string().min(1),
145
- model: z2.string().min(1)
331
+ model: z2.string().min(1).optional()
146
332
  });
147
333
  var JoinResponse = z2.object({
148
334
  agentName: z2.string(),
@@ -152,7 +338,8 @@ var WorkRequest = z2.object({
152
338
  sessionId: z2.string().uuid(),
153
339
  intent: z2.string().min(1).max(2048),
154
340
  pathGlobs: z2.array(z2.string().min(1).max(512)).min(1).max(64),
155
- ttlSeconds: z2.number().int().positive().optional()
341
+ ttlSeconds: z2.number().int().positive().optional(),
342
+ changeReport: ChangeReport.optional()
156
343
  });
157
344
  var WorkResponse = z2.object({
158
345
  workItemId: z2.string().uuid(),
@@ -163,7 +350,11 @@ var DoneRequest = z2.object({
163
350
  workItemId: z2.string().uuid()
164
351
  });
165
352
  var DoneResponse = z2.object({
166
- ok: z2.literal(true)
353
+ ok: z2.literal(true),
354
+ // Pending announcements for the caller, delivered as a side effect of done so
355
+ // a message lands the moment a teammate finishes a unit of work (not only on
356
+ // their next work/sync). Defaulted for version-skew safety with older hubs.
357
+ announcements: z2.array(Announcement).default([])
167
358
  });
168
359
  var AnnounceRequest = z2.object({
169
360
  sessionId: z2.string().uuid(),
@@ -174,19 +365,312 @@ var AnnounceRequest = z2.object({
174
365
  var AnnounceResponse = z2.object({
175
366
  ok: z2.literal(true),
176
367
  // bigint PK serialised as number; see DbId note above
177
- announcementId: DbId
368
+ announcementId: DbId,
369
+ // Pending announcements for the caller, delivered as a side effect of announce
370
+ // (a turn where the agent is already reading hub output) so inbound messages
371
+ // surface promptly. Excludes the just-sent one. Defaulted for version skew.
372
+ announcements: z2.array(Announcement).default([])
178
373
  });
179
374
  var SyncRequest = z2.object({
180
- sessionId: z2.string().uuid()
375
+ sessionId: z2.string().uuid(),
376
+ changeReport: ChangeReport.optional()
181
377
  });
182
378
  var SyncResponse = z2.object({
183
379
  landscape: Landscape
184
380
  });
185
- var WorkAgentInput = WorkRequest.omit({ sessionId: true });
381
+ var WorkAgentInput = WorkRequest.omit({ sessionId: true, changeReport: true });
186
382
  var AnnounceAgentInput = AnnounceRequest.omit({ sessionId: true });
187
383
  var DoneAgentInput = DoneRequest.omit({ sessionId: true });
188
384
  var JoinAgentInput = z2.object({});
189
385
  var SyncAgentInput = z2.object({});
386
+ var HeartbeatRequest = z2.object({
387
+ sessionId: z2.string().uuid(),
388
+ // Optional change report so the BACKGROUND heartbeat keeps an agent's durable
389
+ // change records fresh (commits surface within ~one heartbeat interval, not
390
+ // only when it next calls work/sync). Processed presence-style: it refreshes
391
+ // change records but, like the rest of heartbeat, does NOT renew claim TTLs.
392
+ changeReport: ChangeReport.optional()
393
+ });
394
+ var HeartbeatResponse = z2.object({
395
+ ok: z2.literal(true)
396
+ });
397
+ var LeaveRequest = z2.object({
398
+ sessionId: z2.string().uuid()
399
+ });
400
+ var LeaveResponse = z2.object({
401
+ ok: z2.literal(true)
402
+ });
403
+
404
+ // src/gitContext.ts
405
+ import { execFileSync } from "child_process";
406
+ import * as path from "path";
407
+ var GIT_TIMEOUT_MS = 2e3;
408
+ var MAX_COMMITS = 100;
409
+ var MAX_PATHS_PER_COMMIT = 500;
410
+ var MAX_DIRTY_PATHS = 500;
411
+ var MAX_LINE_RANGE_PATHS = 50;
412
+ function runGit(cwd, args) {
413
+ try {
414
+ const out = execFileSync("git", args, {
415
+ cwd,
416
+ encoding: "utf8",
417
+ timeout: GIT_TIMEOUT_MS,
418
+ // Keep git from prompting for credentials/editors and hanging the timeout.
419
+ windowsHide: true,
420
+ maxBuffer: 64 * 1024 * 1024,
421
+ // Capture stdout; silence stderr so failures stay quiet (we fail open).
422
+ stdio: ["ignore", "pipe", "ignore"]
423
+ });
424
+ return out.trim();
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+ function runGitExitOk(cwd, args) {
430
+ try {
431
+ execFileSync("git", args, {
432
+ cwd,
433
+ encoding: "utf8",
434
+ timeout: GIT_TIMEOUT_MS,
435
+ windowsHide: true,
436
+ stdio: "ignore"
437
+ });
438
+ return true;
439
+ } catch {
440
+ return false;
441
+ }
442
+ }
443
+ function isValidSha(sha) {
444
+ return /^[0-9a-f]{4,64}$/.test(sha);
445
+ }
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
+ function detectRepo(cwd = process.cwd()) {
474
+ const origin = runGit(cwd, ["config", "--get", "remote.origin.url"]);
475
+ if (origin) {
476
+ const normalized = normalizeRemoteUrl(origin);
477
+ if (normalized) return normalized;
478
+ }
479
+ const top = runGit(cwd, ["rev-parse", "--show-toplevel"]);
480
+ if (top) {
481
+ const base = path.basename(top);
482
+ if (base) return base;
483
+ }
484
+ return null;
485
+ }
486
+ function detectBranch(cwd = process.cwd()) {
487
+ const branch = runGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
488
+ if (branch === null || branch === "") return null;
489
+ return branch;
490
+ }
491
+ function detectHuman(cwd = process.cwd()) {
492
+ if (!runGitExitOk(cwd, ["rev-parse", "--is-inside-work-tree"])) {
493
+ return null;
494
+ }
495
+ const name = runGit(cwd, ["config", "user.name"]);
496
+ if (name) return name;
497
+ const email = runGit(cwd, ["config", "user.email"]);
498
+ if (email) {
499
+ const local = email.split("@")[0];
500
+ if (local) return local;
501
+ }
502
+ return null;
503
+ }
504
+ function detectBaseBranch(cwd = process.cwd()) {
505
+ const symref = runGit(cwd, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
506
+ if (symref) {
507
+ const stripped = symref.replace(/^refs\/remotes\//, "");
508
+ if (stripped) return stripped;
509
+ }
510
+ for (const candidate of ["origin/main", "origin/master"]) {
511
+ if (runGitExitOk(cwd, ["rev-parse", "--verify", "--quiet", `refs/remotes/${candidate}`])) {
512
+ return candidate;
513
+ }
514
+ }
515
+ return null;
516
+ }
517
+ function headSha(cwd = process.cwd()) {
518
+ const sha = runGit(cwd, ["rev-parse", "HEAD"]);
519
+ if (sha === null || sha === "") return null;
520
+ return sha;
521
+ }
522
+ function unlandedCommits(cwd = process.cwd(), baseBranch) {
523
+ if (!baseBranch || baseBranch.startsWith("-")) {
524
+ return { commits: [], truncated: false };
525
+ }
526
+ const out = runGit(cwd, [
527
+ "log",
528
+ `${baseBranch}..HEAD`,
529
+ "--name-only",
530
+ `--max-count=${MAX_COMMITS}`,
531
+ "--format=%x01%H%x00%s"
532
+ ]);
533
+ if (out === null) {
534
+ return { commits: [], truncated: false };
535
+ }
536
+ if (out === "") {
537
+ return { commits: [], truncated: false };
538
+ }
539
+ let truncated = false;
540
+ const commits = [];
541
+ const records = out.split("").filter((r) => r.length > 0);
542
+ for (const record of records) {
543
+ const newlineIdx = record.indexOf("\n");
544
+ const header = newlineIdx === -1 ? record : record.slice(0, newlineIdx);
545
+ const rest = newlineIdx === -1 ? "" : record.slice(newlineIdx + 1);
546
+ const nulIdx = header.indexOf("\0");
547
+ const sha = (nulIdx === -1 ? header : header.slice(0, nulIdx)).trim();
548
+ const message = nulIdx === -1 ? "" : header.slice(nulIdx + 1);
549
+ if (!sha) continue;
550
+ let paths = rest.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
551
+ if (paths.length > MAX_PATHS_PER_COMMIT) {
552
+ paths = paths.slice(0, MAX_PATHS_PER_COMMIT);
553
+ truncated = true;
554
+ }
555
+ if (paths.length === 0) continue;
556
+ commits.push({ sha, message, paths });
557
+ }
558
+ if (commits.length >= MAX_COMMITS) {
559
+ truncated = true;
560
+ }
561
+ return { commits, truncated };
562
+ }
563
+ function dirtyPaths(cwd = process.cwd()) {
564
+ const out = runGit(cwd, ["status", "--porcelain", "-z", "--untracked-files=all"]);
565
+ if (out === null) {
566
+ return { paths: [], truncated: false };
567
+ }
568
+ const seen = /* @__PURE__ */ new Set();
569
+ const fields = out.split("\0").filter((f) => f.length > 0);
570
+ for (let i = 0; i < fields.length; i++) {
571
+ const field = fields[i];
572
+ const status = field.slice(0, 2);
573
+ const rest = field.slice(2).replace(/^\s+/, "");
574
+ if (rest) seen.add(rest);
575
+ if (/[RC]/.test(status)) {
576
+ const src = fields[i + 1];
577
+ if (src) {
578
+ seen.add(src);
579
+ i++;
580
+ }
581
+ }
582
+ }
583
+ let paths = Array.from(seen);
584
+ let truncated = false;
585
+ if (paths.length > MAX_DIRTY_PATHS) {
586
+ paths = paths.slice(0, MAX_DIRTY_PATHS);
587
+ truncated = true;
588
+ }
589
+ return { paths, truncated };
590
+ }
591
+ function isAncestor(cwd = process.cwd(), sha) {
592
+ if (!isValidSha(sha)) return false;
593
+ return runGitExitOk(cwd, ["merge-base", "--is-ancestor", sha, "HEAD"]);
594
+ }
595
+ function hasCommit(cwd = process.cwd(), sha) {
596
+ if (!isValidSha(sha)) return false;
597
+ return runGitExitOk(cwd, ["cat-file", "-e", `${sha}^{commit}`]);
598
+ }
599
+ function parseHunkRanges(diff) {
600
+ const ranges = [];
601
+ const re = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm;
602
+ let m;
603
+ while ((m = re.exec(diff)) !== null) {
604
+ const start = parseInt(m[1], 10);
605
+ const count = m[2] === void 0 ? 1 : parseInt(m[2], 10);
606
+ if (count <= 0) {
607
+ ranges.push({ start, end: start });
608
+ } else {
609
+ ranges.push({ start, end: start + count - 1 });
610
+ }
611
+ }
612
+ return ranges;
613
+ }
614
+ function changedLineRanges(cwd = process.cwd(), sha, paths) {
615
+ if (!isValidSha(sha) || !paths || paths.length === 0) return {};
616
+ const result = {};
617
+ const capped = paths.length > MAX_LINE_RANGE_PATHS ? paths.slice(0, MAX_LINE_RANGE_PATHS) : paths;
618
+ for (const p of capped) {
619
+ let diff = runGit(cwd, ["diff", "--unified=0", `${sha}~1`, sha, "--", p]);
620
+ if (diff === null) {
621
+ diff = runGit(cwd, ["show", "--unified=0", "--format=", sha, "--", p]);
622
+ }
623
+ if (diff === null || diff === "") continue;
624
+ const ranges = parseHunkRanges(diff);
625
+ if (ranges.length > 0) {
626
+ result[p] = ranges;
627
+ }
628
+ }
629
+ return result;
630
+ }
631
+
632
+ // src/changeReport.ts
633
+ var UNRESOLVED_BASE = "(unknown)";
634
+ async function buildChangeReport(cwd, config) {
635
+ const branch = detectBranch(cwd);
636
+ const head = headSha(cwd);
637
+ if (branch === null && head === null) {
638
+ return void 0;
639
+ }
640
+ const base = config.BASE_BRANCH ?? detectBaseBranch(cwd);
641
+ const entries = [];
642
+ let truncated = false;
643
+ const dirty = dirtyPaths(cwd);
644
+ if (dirty.truncated) truncated = true;
645
+ if (dirty.paths.length > 0) {
646
+ entries.push({
647
+ kind: "uncommitted",
648
+ sha: null,
649
+ message: null,
650
+ paths: dirty.paths
651
+ });
652
+ }
653
+ if (base) {
654
+ const unlanded = unlandedCommits(cwd, base);
655
+ if (unlanded.truncated) truncated = true;
656
+ for (const c of unlanded.commits) {
657
+ if (c.paths.length === 0) continue;
658
+ entries.push({
659
+ kind: "committed",
660
+ sha: c.sha,
661
+ message: c.message,
662
+ paths: c.paths
663
+ });
664
+ }
665
+ }
666
+ return {
667
+ branch: branch ?? "HEAD",
668
+ baseBranch: base ?? UNRESOLVED_BASE,
669
+ head: head ?? "",
670
+ truncated,
671
+ entries
672
+ };
673
+ }
190
674
 
191
675
  // src/tools.ts
192
676
  function formatLandscape(landscape) {
@@ -233,6 +717,68 @@ function formatLandscape(landscape) {
233
717
  }
234
718
  return lines.join("\n");
235
719
  }
720
+ function formatAnnouncements(announcements) {
721
+ if (!announcements || announcements.length === 0) return "";
722
+ const lines = ["Messages for you:"];
723
+ for (const a of announcements) {
724
+ const target = a.targetAgentName ? ` \u2192 ${a.targetAgentName}` : " (broadcast)";
725
+ lines.push(` [${a.fromAgentName}${target}] ${a.body}`);
726
+ }
727
+ return lines.join("\n");
728
+ }
729
+ function relativeAge(iso) {
730
+ const then = Date.parse(iso);
731
+ if (Number.isNaN(then)) return "recently";
732
+ const ms = Date.now() - then;
733
+ if (ms < 0) return "just now";
734
+ const mins = Math.floor(ms / 6e4);
735
+ if (mins < 1) return "just now";
736
+ if (mins < 60) return `${mins}m ago`;
737
+ const hours = Math.floor(mins / 60);
738
+ if (hours < 24) return `${hours}h ago`;
739
+ const days = Math.floor(hours / 24);
740
+ return `${days}d ago`;
741
+ }
742
+ function presence(rec) {
743
+ return rec.authorIsLive ? "active now" : `offline, last seen ${relativeAge(rec.authorLastActiveAt)}`;
744
+ }
745
+ function formatChangeRecords(records, cwd = process.cwd()) {
746
+ if (!records || records.length === 0) return "";
747
+ let lineRangeBudget = MAX_LINE_RANGE_PATHS;
748
+ const lines = [];
749
+ for (const rec of records) {
750
+ if (rec.kind === "committed") {
751
+ const sha = rec.commitSha;
752
+ if (sha && isAncestor(cwd, sha)) continue;
753
+ const present = sha ? hasCommit(cwd, sha) : false;
754
+ const state = present ? "landed, not yet in your branch \u2014 pull/rebase" : "not yet on your base \u2014 unpushed, coordinate";
755
+ const intent = rec.message ?? "(work in progress)";
756
+ lines.push(
757
+ ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 committed (${state}): "${intent}"`
758
+ );
759
+ lines.push(` files: ${rec.paths.join(", ")}`);
760
+ if (sha && present && lineRangeBudget > 0) {
761
+ const budgetedPaths = rec.paths.slice(0, lineRangeBudget);
762
+ lineRangeBudget -= budgetedPaths.length;
763
+ const ranges = changedLineRanges(cwd, sha, budgetedPaths);
764
+ for (const p of Object.keys(ranges)) {
765
+ const spans = ranges[p].map((r) => r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`);
766
+ if (spans.length > 0) {
767
+ lines.push(` ${p}: lines ${spans.join(", ")} (for context)`);
768
+ }
769
+ }
770
+ }
771
+ } else {
772
+ const claim = rec.message ?? "uncommitted edits in progress";
773
+ lines.push(
774
+ ` ${rec.agentName} / ${rec.human} (${presence(rec)}) \u2014 ${claim} (uncommitted, may change)`
775
+ );
776
+ lines.push(` files: ${rec.paths.join(", ")}`);
777
+ }
778
+ }
779
+ if (lines.length === 0) return "";
780
+ return "Unlanded changes touching your area (awareness only \u2014 these are not blockers):\n" + lines.join("\n");
781
+ }
236
782
  function degradedResult(err) {
237
783
  const detail = err instanceof HubUnreachable || err instanceof HubRequestError ? err.message : String(err);
238
784
  return {
@@ -245,20 +791,23 @@ function degradedResult(err) {
245
791
  };
246
792
  }
247
793
  function registerTools(server, deps) {
248
- const { hubClient, config } = deps;
794
+ const { hubClient, config, context, heartbeat } = deps;
249
795
  let sessionId = null;
250
796
  let agentName = null;
251
797
  const joinBody = {
252
- workspace: config.WORKSPACE,
253
- repo: config.REPO,
254
- branch: config.BRANCH,
255
- human: config.HUMAN,
256
- program: config.PROGRAM,
257
- model: config.MODEL
798
+ workspace: context.workspace,
799
+ repo: context.repo,
800
+ branch: context.branch,
801
+ human: context.human,
802
+ program: context.program
258
803
  };
804
+ if (context.model !== void 0) {
805
+ joinBody.model = context.model;
806
+ }
259
807
  const joinInFlight = hubClient.post("/join", joinBody).then((r) => {
260
808
  sessionId = r.sessionId;
261
809
  agentName = r.agentName;
810
+ heartbeat.start(r.sessionId);
262
811
  }).catch(() => {
263
812
  });
264
813
  async function awaitJoin() {
@@ -278,12 +827,30 @@ function registerTools(server, deps) {
278
827
  return agentName ? `You are ${agentName}.
279
828
 
280
829
  ${body}` : body;
830
+ }
831
+ async function changeReportForBody() {
832
+ try {
833
+ return await buildChangeReport(process.cwd(), config) ?? void 0;
834
+ } catch {
835
+ return void 0;
836
+ }
837
+ }
838
+ function withChangeRecords(landscape, body) {
839
+ let section = "";
840
+ try {
841
+ section = formatChangeRecords(landscape.changeRecords ?? [], process.cwd());
842
+ } catch {
843
+ section = "";
844
+ }
845
+ return section ? `${body}
846
+
847
+ ${section}` : body;
281
848
  }
282
849
  server.registerTool(
283
850
  "work",
284
851
  {
285
852
  title: "Claim a unit of work",
286
- description: 'Claim a unit of work BEFORE you start editing files in an area of the codebase (per unit of work, NOT per edit). Pass a one-line `intent` and the `pathGlobs` covering the files you expect to touch \u2014 scope them as specifically as you reasonably can (e.g. ["src/auth/**"], not ["src/**"] and not a single file). It atomically checks whether a teammate is already in those files and claims them for you, returning any conflicts and what others are working on. Hold one claim across all edits in that area; don\'t re-claim per file.',
853
+ description: 'Claim a unit of work BEFORE you start producing or changing files in an area of the codebase \u2014 source OR a plan/design doc (per unit of work, NOT per edit). Authoring a plan counts: claim the doc\'s path before you write it. Pass a one-line `intent` and the `pathGlobs` covering the files you expect to touch \u2014 scope them as specifically as you reasonably can (e.g. ["src/auth/**"], not ["src/**"] and not a single file). It atomically checks whether a teammate is already in those files and claims them for you, returning any conflicts and what others are working on. Hold one claim across all edits in that area; don\'t re-claim per file.',
287
854
  inputSchema: WorkAgentInput.shape
288
855
  },
289
856
  async (args) => {
@@ -292,14 +859,18 @@ ${body}` : body;
292
859
  return sessionNotReady();
293
860
  }
294
861
  try {
295
- const body = { sessionId, ...args };
862
+ const changeReport = await changeReportForBody();
863
+ const body = { sessionId, ...args, ...changeReport ? { changeReport } : {} };
296
864
  const result = await hubClient.post("/work", body);
297
865
  const text = withIdentity(
298
- `Work claimed (workItemId: ${result.workItemId})
866
+ withChangeRecords(
867
+ result.landscape,
868
+ `Work claimed (workItemId: ${result.workItemId})
299
869
 
300
870
  ` + formatLandscape(result.landscape) + `
301
871
 
302
872
  You hold this claim until you call done (workItemId: ${result.workItemId}) or it expires (~30 min). Calling work or sync renews it.`
873
+ )
303
874
  );
304
875
  return { content: [{ type: "text", text }] };
305
876
  } catch (err) {
@@ -324,13 +895,14 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
324
895
  }
325
896
  try {
326
897
  const body = { sessionId, ...args };
327
- await hubClient.post("/done", body);
898
+ const result = await hubClient.post("/done", body);
899
+ const base = "Work item released. Call work again before your next edit in a new area.";
900
+ const msgs = formatAnnouncements(result.announcements ?? []);
328
901
  return {
329
902
  content: [
330
- {
331
- type: "text",
332
- text: "Work item released. Call work again before your next edit in a new area."
333
- }
903
+ { type: "text", text: msgs ? `${base}
904
+
905
+ ${msgs}` : base }
334
906
  ]
335
907
  };
336
908
  } catch (err) {
@@ -356,12 +928,13 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
356
928
  try {
357
929
  const body = { sessionId, ...args };
358
930
  const result = await hubClient.post("/announce", body);
931
+ const base = `Announcement sent (id: ${result.announcementId}).`;
932
+ const msgs = formatAnnouncements(result.announcements ?? []);
359
933
  return {
360
934
  content: [
361
- {
362
- type: "text",
363
- text: `Announcement sent (id: ${result.announcementId}).`
364
- }
935
+ { type: "text", text: msgs ? `${base}
936
+
937
+ ${msgs}` : base }
365
938
  ]
366
939
  };
367
940
  } catch (err) {
@@ -385,9 +958,12 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
385
958
  return sessionNotReady();
386
959
  }
387
960
  try {
388
- const body = { sessionId };
961
+ const changeReport = await changeReportForBody();
962
+ const body = { sessionId, ...changeReport ? { changeReport } : {} };
389
963
  const result = await hubClient.post("/sync", body);
390
- const text = withIdentity(formatLandscape(result.landscape));
964
+ const text = withIdentity(
965
+ withChangeRecords(result.landscape, formatLandscape(result.landscape))
966
+ );
391
967
  return { content: [{ type: "text", text }] };
392
968
  } catch (err) {
393
969
  if (err instanceof HubUnreachable || err instanceof HubRequestError) {
@@ -397,7 +973,76 @@ You hold this claim until you call done (workItemId: ${result.workItemId}) or it
397
973
  }
398
974
  }
399
975
  );
400
- return { ready: joinInFlight };
976
+ async function leave() {
977
+ try {
978
+ await joinInFlight;
979
+ if (!sessionId) return;
980
+ await hubClient.post("/leave", { sessionId });
981
+ } catch (err) {
982
+ console.error(
983
+ `[shepherd] leave failed: ${err instanceof Error ? err.message : String(err)}`
984
+ );
985
+ }
986
+ }
987
+ return { ready: joinInFlight, leave };
988
+ }
989
+
990
+ // src/resolveContext.ts
991
+ var defaultDeps = {
992
+ detectRepo,
993
+ detectBranch,
994
+ detectHuman
995
+ };
996
+ var DEFAULT_WORKSPACE = "default";
997
+ async function resolveContext(config, cwd = process.cwd(), deps = defaultDeps) {
998
+ const repo = canonicalizeRepo(
999
+ config.REPO ?? deps.detectRepo(cwd) ?? "unknown-repo"
1000
+ );
1001
+ const branch = config.BRANCH ?? deps.detectBranch(cwd) ?? "HEAD";
1002
+ const human = config.HUMAN ?? deps.detectHuman(cwd) ?? generateName();
1003
+ const program = config.PROGRAM ?? "claude-code";
1004
+ const model = config.MODEL ?? void 0;
1005
+ const workspace = config.WORKSPACE ?? DEFAULT_WORKSPACE;
1006
+ return { workspace, repo, branch, human, program, model };
1007
+ }
1008
+
1009
+ // src/heartbeat.ts
1010
+ function createHeartbeat({
1011
+ hubClient,
1012
+ intervalSeconds,
1013
+ buildReport
1014
+ }) {
1015
+ let timer = null;
1016
+ function stop() {
1017
+ if (timer !== null) {
1018
+ clearInterval(timer);
1019
+ timer = null;
1020
+ }
1021
+ }
1022
+ async function beat(sessionId) {
1023
+ let changeReport;
1024
+ if (buildReport) {
1025
+ try {
1026
+ changeReport = await buildReport();
1027
+ } catch {
1028
+ changeReport = void 0;
1029
+ }
1030
+ }
1031
+ const body = changeReport ? { sessionId, changeReport } : { sessionId };
1032
+ await hubClient.post("/heartbeat", body);
1033
+ }
1034
+ function start(sessionId) {
1035
+ stop();
1036
+ timer = setInterval(() => {
1037
+ void beat(sessionId).catch((err) => {
1038
+ console.error(
1039
+ `[shepherd] heartbeat failed: ${err instanceof Error ? err.message : String(err)}`
1040
+ );
1041
+ });
1042
+ }, intervalSeconds * 1e3);
1043
+ timer.unref();
1044
+ }
1045
+ return { start, stop };
401
1046
  }
402
1047
 
403
1048
  // src/instructions.ts
@@ -405,7 +1050,7 @@ var SHEPHERD_INSTRUCTIONS = `You are connected to Shepherd, the shared coordinat
405
1050
 
406
1051
  Follow this procedure on every session, proactively and without being asked:
407
1052
 
408
- 1. Before you start working on an AREA of the codebase, call \`work\` ONCE. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
1053
+ 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
409
1054
 
410
1055
  2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
411
1056
 
@@ -415,18 +1060,50 @@ Follow this procedure on every session, proactively and without being asked:
415
1060
 
416
1061
  5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
417
1062
 
418
- Skip \`work\` entirely for read-only exploration. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.`;
1063
+ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
1064
+
1065
+ Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
419
1066
 
420
1067
  // src/index.ts
421
1068
  async function main() {
422
1069
  const config = loadConfig();
423
1070
  const hubClient = createHubClient({ hubUrl: config.HUB_URL, teamToken: config.TEAM_TOKEN });
1071
+ const context = await resolveContext(config);
1072
+ const heartbeat = createHeartbeat({
1073
+ hubClient,
1074
+ intervalSeconds: config.HEARTBEAT_INTERVAL_SECONDS,
1075
+ // Attach a best-effort change report to each beat so commits surface to
1076
+ // teammates within ~one interval. Fail-open: any git error → presence-only.
1077
+ buildReport: async () => {
1078
+ try {
1079
+ return await buildChangeReport(process.cwd(), config) ?? void 0;
1080
+ } catch {
1081
+ return void 0;
1082
+ }
1083
+ }
1084
+ });
424
1085
  const server = new McpServer(
425
1086
  { name: "shepherd", version: "0.1.0" },
426
1087
  { instructions: SHEPHERD_INSTRUCTIONS }
427
1088
  );
428
- registerTools(server, { hubClient, config });
1089
+ const tools = registerTools(server, { hubClient, config, context, heartbeat });
429
1090
  const transport = new StdioServerTransport();
1091
+ let shuttingDown = false;
1092
+ const shutdown = async () => {
1093
+ if (shuttingDown) return;
1094
+ shuttingDown = true;
1095
+ heartbeat.stop();
1096
+ await tools.leave();
1097
+ };
1098
+ process.once("SIGINT", () => {
1099
+ void shutdown().finally(() => process.exit(0));
1100
+ });
1101
+ process.once("SIGTERM", () => {
1102
+ void shutdown().finally(() => process.exit(0));
1103
+ });
1104
+ transport.onclose = () => {
1105
+ void shutdown();
1106
+ };
430
1107
  await server.connect(transport);
431
1108
  }
432
1109
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
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",