trantor 0.17.68 → 0.17.71

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/bin/proposals.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  // trantor proposals --all every proposal, all statuses
6
6
  // trantor proposals approve <id> [--note "…"]
7
7
  // trantor proposals deny <id> --note "…" (a denial without a reason teaches nothing)
8
+ // trantor proposals revoke <id> [--note "…"] (withdraw a GRANT — not a denial, no denial memory)
8
9
  // [--hub <url>] target one hub when an id exists on several
9
10
  // Owner-signed: /proposal/decide is owner-gated hub-side — approval is the human's act alone.
10
11
  import { readFileSync } from "node:fs";
@@ -33,7 +34,7 @@ const post = async (hub, path, payload) => {
33
34
  return j;
34
35
  };
35
36
 
36
- const ICON = { pending: "⏳", approved: "✅", denied: "⛔", withdrawn: "↩️" };
37
+ const ICON = { pending: "⏳", approved: "✅", denied: "⛔", withdrawn: "↩️", revoked: "🚫" };
37
38
  const when = (ts) => ts ? new Date(ts).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
38
39
  function show(p, hub) {
39
40
  console.log(` #${p.id} ${ICON[p.status] || ""} ${p.status.toUpperCase()} · ${p.project} · from ${p.session} · ${when(p.ts)}`);
@@ -43,7 +44,7 @@ function show(p, hub) {
43
44
  if (p.status !== "pending") console.log(` decided ${when(p.decidedTs)} by ${p.decidedBy}${p.note ? ` — "${p.note}"` : ""}`);
44
45
  }
45
46
  function usage() {
46
- console.log('usage: trantor proposals [--all] | approve <id> [--note "…"] | deny <id> --note "…" [--hub <url>]');
47
+ console.log('usage: trantor proposals [--all] | approve <id> [--note "…"] | deny <id> --note "…" | revoke <id> [--note "…"] [--hub <url>]');
47
48
  process.exit(1);
48
49
  }
49
50
 
@@ -65,24 +66,27 @@ if (cmd === "list") {
65
66
  process.exit(0);
66
67
  }
67
68
 
68
- if (cmd === "approve" || cmd === "deny") {
69
+ if (cmd === "approve" || cmd === "deny" || cmd === "revoke") {
69
70
  const id = Number(argv[1]);
70
71
  if (!Number.isInteger(id) || id <= 0) usage();
71
72
  const note = val("note");
72
73
  if (cmd === "deny" && !note) { console.error('a denial needs a reason: deny <id> --note "why" — the note is what stops the agent re-proposing blind'); process.exit(1); }
73
- // an id is only unique per hub — find where THIS pending proposal lives before deciding
74
+ // an id is only unique per hub — find where THIS proposal lives before deciding
75
+ // (approve/deny act on a PENDING row; revoke acts on an APPROVED one)
76
+ const wantStatus = cmd === "revoke" ? "approved" : "pending";
74
77
  const holding = [];
75
78
  for (const hub of hubs) {
76
79
  try {
77
- const { proposals } = await get(hub, "/proposals?status=pending");
80
+ const { proposals } = await get(hub, `/proposals?status=${wantStatus}`);
78
81
  if (proposals?.some(p => p.id === id)) holding.push(hub);
79
82
  } catch {}
80
83
  }
81
- if (!holding.length) { console.error(`no PENDING proposal #${id} on ${hubs.length > 1 ? "any of your hubs" : hubs[0]}`); process.exit(1); }
82
- if (holding.length > 1) { console.error(`proposal #${id} is pending on several hubs (${holding.join(", ")}) — pick one with --hub <url>`); process.exit(1); }
84
+ if (!holding.length) { console.error(`no ${wantStatus.toUpperCase()} proposal #${id} on ${hubs.length > 1 ? "any of your hubs" : hubs[0]}`); process.exit(1); }
85
+ if (holding.length > 1) { console.error(`proposal #${id} is ${wantStatus} on several hubs (${holding.join(", ")}) — pick one with --hub <url>`); process.exit(1); }
83
86
  try {
84
87
  // `by` is the warn-mode fallback only — under enforce the hub stamps the SIGNER's name over it
85
- const { proposal } = await post(holding[0], "/proposal/decide", { id, status: cmd === "approve" ? "approved" : "denied", note, by: ownerId.name || "owner" });
88
+ const status = cmd === "approve" ? "approved" : cmd === "deny" ? "denied" : "revoked";
89
+ const { proposal } = await post(holding[0], "/proposal/decide", { id, status, note, by: ownerId.name || "owner" });
86
90
  console.log(`${ICON[proposal.status]} #${proposal.id} ${proposal.status} on ${holding[0]} — the proposer (${proposal.session}) has been told over the bus`);
87
91
  } catch (err) { console.error(`⚠ ${holding[0]}: ${err.message}`); process.exit(1); }
88
92
  process.exit(0);
@@ -106,7 +106,10 @@ async function maybeEarlyWarn(stdinRaw, session) {
106
106
  }
107
107
 
108
108
  async function main(stdinRaw) {
109
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
109
+ // input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
110
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
111
+ let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
112
+ const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
110
113
  // Mirror sessionstart.mjs: home-directory sessions aren't project work — don't register
111
114
  // them (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
112
115
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
@@ -33,20 +33,28 @@ const FETCH_TIMEOUT_MS = Number(process.env.RELAY_INBOX_TIMEOUT_MS || 1500);
33
33
  // additionalContext payload (the model still gets the readable message).
34
34
  function sanitize(s) { return String(s == null ? "" : s).replace(/[\x00-\x1f\x7f-\x9f]/g, " "); }
35
35
 
36
- async function getInbox(session, since, instance) {
37
- const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance });
36
+ async function getInbox(session, since, instance, project) {
37
+ const { ok, json } = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${since}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance, project });
38
38
  if (!ok || !json) throw new Error("hub unreachable");
39
39
  return json; // { messages: [...], cursor, superseded? }
40
40
  }
41
41
 
42
- // PostToolUse hands us the tool-input JSON on stdin. We don't need it, but we must DRAIN it:
43
- // a large tool input (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's
44
- // write if nobody reads. Consume + discard, with a short timeout so we never hang.
42
+ // PostToolUse hands us the tool-input JSON on stdin, and we MUST drain it: a large tool input
43
+ // (e.g. a big Write) can exceed the 64KB pipe buffer and block the parent's write if nobody reads.
44
+ // It used to drain into the void and resolve with NOTHING, so `main(stdinRaw)` always got
45
+ // undefined — which silently cost two things: `session_id` (so the per-instance cursor in
46
+ // docs/INSTANCE-KEYS-CONTRACT.md never actually keyed by instance) and `cwd` (so this hook could
47
+ // only ever guess its project from the process directory). Draining and KEEPING the bytes is the
48
+ // same protection, minus the amnesia.
45
49
  function drainStdin() {
46
50
  return new Promise(res => {
47
- try { process.stdin.resume(); process.stdin.on("data", () => {}); process.stdin.on("end", res); }
48
- catch { res(); }
49
- setTimeout(res, 80);
51
+ let d = "";
52
+ try {
53
+ process.stdin.setEncoding("utf8"); process.stdin.resume();
54
+ process.stdin.on("data", c => (d += c));
55
+ process.stdin.on("end", () => res(d));
56
+ } catch { res(d); }
57
+ setTimeout(() => res(d), 80);
50
58
  });
51
59
  }
52
60
 
@@ -64,7 +72,10 @@ async function main(stdinRaw) {
64
72
  // name, different session_id) has its own ledger and can't eat this session's messages.
65
73
  let instanceId = "";
66
74
  try { instanceId = String(JSON.parse(stdinRaw || "{}").session_id || ""); } catch {}
67
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
75
+ // input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
76
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
77
+ let _in = {}; try { _in = JSON.parse(stdinRaw || "{}"); } catch {}
78
+ const projectDir = _in.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
68
79
  // Mirror heartbeat.mjs / sessionstart.mjs: a home-directory session isn't project work and
69
80
  // isn't on the bus — nothing to deliver. Opt in with RELAY_SESSION / RELAY_PROJECT.
70
81
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return "{}";
@@ -94,7 +105,7 @@ async function main(stdinRaw) {
94
105
  // so we start listening "from now" instead of replaying the whole backlog of old broadcasts.
95
106
  if (!existsSync(cursorFile)) {
96
107
  try {
97
- const { cursor } = await getInbox(session, 0, instanceId);
108
+ const { cursor } = await getInbox(session, 0, instanceId, project);
98
109
  writeFileSync(cursorFile, String(cursor || 0));
99
110
  } catch {}
100
111
  return "{}";
@@ -105,7 +116,7 @@ async function main(stdinRaw) {
105
116
 
106
117
  let messages = [], next = cursor, superseded = false;
107
118
  try {
108
- const res = await getInbox(session, cursor, instanceId);
119
+ const res = await getInbox(session, cursor, instanceId, project);
109
120
  messages = Array.isArray(res.messages) ? res.messages : [];
110
121
  next = res.cursor || cursor;
111
122
  superseded = res.superseded === true;
package/hooks/lib/api.mjs CHANGED
@@ -49,6 +49,32 @@ export function sessionContext(projectDir) {
49
49
  return { session, project, projectDir: dir };
50
50
  }
51
51
 
52
+ // THE PROJECT A REQUEST IS ABOUT, which is not always the project the hook process is standing in.
53
+ //
54
+ // This distinction cost two diagnosis sessions. A hook stamps its payload with the project from
55
+ // Claude's session cwd (`input.cwd`), but the hub URL used to come from the HOOK PROCESS's cwd.
56
+ // Launch a session from ~/development and those disagree: every card says "crebral-health" and
57
+ // every one of them lands on the LOCAL hub, because "development" has no pin and falls through to
58
+ // the global default. Nothing errors, the seat looks healthy, and half the work records where
59
+ // nobody reads. So: the project travels WITH the request, explicit > payload > query > cwd.
60
+ function projectFromQuery(pathOrUrl) {
61
+ const m = String(pathOrUrl).match(/[?&]project=([^&]*)/);
62
+ try { return m ? decodeURIComponent(m[1]) : ""; } catch { return m ? m[1] : ""; }
63
+ }
64
+ function projectOf(explicit, payload, pathOrUrl) {
65
+ if (explicit) return explicit;
66
+ if (payload && typeof payload === "object" && payload.project) return String(payload.project);
67
+ return projectFromQuery(pathOrUrl);
68
+ }
69
+ // The signing identity for a request about `project`. An explicit RELAY_SESSION/RELAY_AGENT still
70
+ // wins (crew seats inherit them); otherwise the peer is named for the project being written, not
71
+ // for wherever the hook happens to be running.
72
+ function sessionFor(project) {
73
+ if (process.env.RELAY_SESSION) return process.env.RELAY_SESSION;
74
+ const p = project || sessionContext().project;
75
+ return process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${p}` : `${hostId()}:${p}`;
76
+ }
77
+
52
78
  // The keypair for a session name, memoised per process. loadOrCreate is itself idempotent + atomic,
53
79
  // but a hook may sign several requests in one run — avoid re-reading the file each time.
54
80
  const _idCache = new Map();
@@ -86,14 +112,19 @@ function enrolledPath(session) {
86
112
  const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
87
113
  return join(busDir, "keys", `${String(session).replace(/[^A-Za-z0-9_.-]/g, "_")}.enrolled`);
88
114
  }
89
- export async function ensureEnrolled(session, identity) {
115
+ export async function ensureEnrolled(session, identity, project) {
90
116
  if (!identity?.pubkey) return;
117
+ const hub = relayUrl(project);
91
118
  const stamp = enrolledPath(session);
92
- try { if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === identity.pubkey) return; } catch {}
119
+ // The stamp records the HUB as well as the key. It used to record only the key, so a session
120
+ // enrolled on one hub was considered enrolled everywhere — and its first request to a second
121
+ // hub went out as an unknown identity.
122
+ const mark = `${identity.pubkey}\t${hub}`;
123
+ try { if (existsSync(stamp) && readFileSync(stamp, "utf8").trim() === mark) return; } catch {}
93
124
  try {
94
125
  // sfetchJson is the FROZEN single call-site shape (lib/signed-fetch.mjs): it stringifies the
95
126
  // payload, sets content-type, and signs — so every hook signs identically with zero hand-rolling.
96
- const r = await sfetchJson(`${relayUrl()}/enroll`, {
127
+ const r = await sfetchJson(`${hub}/enroll`, {
97
128
  method: "POST",
98
129
  payload: { pubkey: identity.pubkey, name: session, kind: "agent" },
99
130
  identity,
@@ -101,7 +132,7 @@ export async function ensureEnrolled(session, identity) {
101
132
  });
102
133
  if (r.ok) {
103
134
  try { mkdirSync(join((process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus")), "keys"), { recursive: true }); } catch {}
104
- try { writeFileSync(stamp, identity.pubkey, { mode: 0o600 }); } catch {}
135
+ try { writeFileSync(stamp, mark, { mode: 0o600 }); } catch {}
105
136
  }
106
137
  } catch {}
107
138
  }
@@ -109,8 +140,8 @@ export async function ensureEnrolled(session, identity) {
109
140
  // Accept either a full URL (rare — a caller that already built one) or a hub-relative path. We do
110
141
  // NOT fold the origin into the signature (signed-fetch signs path+query only), so a request proxied
111
142
  // through a different host still verifies.
112
- function toUrl(pathOrUrl) {
113
- return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl()}${pathOrUrl}`;
143
+ function toUrl(pathOrUrl, project) {
144
+ return /^https?:\/\//.test(pathOrUrl) ? pathOrUrl : `${relayUrl(project || projectFromQuery(pathOrUrl))}${pathOrUrl}`;
114
145
  }
115
146
 
116
147
  // Unsigned GET → { ok, status, json|null }. Never throws.
@@ -123,8 +154,8 @@ function toUrl(pathOrUrl) {
123
154
  // /peers roster. So reads stay unsigned: accepted+flagged under the default `warn` mode, and the
124
155
  // roster stays global. Flipping individual reads to signed later is a one-liner once a read needs
125
156
  // enforce-mode attribution (add a signedGet that passes `identity` through sfetchJson with GET).
126
- export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
127
- const url = toUrl(pathOrUrl);
157
+ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, project } = {}) {
158
+ const url = toUrl(pathOrUrl, projectOf(project, null, pathOrUrl));
128
159
  try {
129
160
  const r = await fetch(url, { method: "GET", signal: AbortSignal.timeout(timeoutMs) });
130
161
  // parse the body on FAILURE too — a refusal's payload (denial note, queue guidance,
@@ -145,13 +176,14 @@ export async function getJSON(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}
145
176
  // First user: the overseer-warn hook's /overseer/context — a project-scoped read, so the
146
177
  // enforce hub's own-project scope filtering is the correct behavior, not a loss. Roster-style
147
178
  // reads (/peers, /catchup cross-project discovery) stay on getJSON on purpose — see above.
148
- export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
149
- const sess = session || sessionContext().session;
179
+ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
180
+ const proj = projectOf(project, null, pathOrUrl);
181
+ const sess = session || sessionFor(proj);
150
182
  const durable = loadIdentity(sess);
151
183
  const id = instance ? loadInstance(sess, instance) : durable;
152
- await ensureEnrolled(sess, durable); // instances never enroll — the DURABLE key does
184
+ await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
153
185
  try {
154
- const r = await sfetchJson(toUrl(pathOrUrl), {
186
+ const r = await sfetchJson(toUrl(pathOrUrl, proj), {
155
187
  method: "GET",
156
188
  identity: id,
157
189
  signal: AbortSignal.timeout(timeoutMs),
@@ -170,15 +202,16 @@ export async function signedGet(pathOrUrl, { timeoutMs = DEFAULT_TIMEOUT_MS, ses
170
202
  }
171
203
 
172
204
  // Signed POST → { ok, status, json|null }. Never throws.
173
- export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance } = {}) {
174
- const sess = session || sessionContext().session;
205
+ export async function signedPost(pathOrUrl, payload, { timeoutMs = DEFAULT_TIMEOUT_MS, session, instance, project } = {}) {
206
+ const proj = projectOf(project, payload, pathOrUrl);
207
+ const sess = session || sessionFor(proj);
175
208
  const durable = loadIdentity(sess);
176
209
  const id = instance ? loadInstance(sess, instance) : durable;
177
- await ensureEnrolled(sess, durable); // instances never enroll — the DURABLE key does
210
+ await ensureEnrolled(sess, durable, proj); // instances never enroll — the DURABLE key does
178
211
  try {
179
212
  // sfetchJson (FROZEN) stringifies the payload + signs with `id` in one call — the single shape
180
213
  // every client uses (lib/signed-fetch.mjs). We pass our memoised identity so it doesn't re-load.
181
- const r = await sfetchJson(toUrl(pathOrUrl), {
214
+ const r = await sfetchJson(toUrl(pathOrUrl, proj), {
182
215
  method: "POST",
183
216
  payload,
184
217
  identity: id,
@@ -18,7 +18,9 @@ function readStdin() {
18
18
 
19
19
  try {
20
20
  const input = JSON.parse((await readStdin()) || "{}");
21
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
21
+ // input.cwd FIRST — every hook must derive the project the SAME way, or two hooks in one
22
+ // session resolve two projects, two hubs, and half the work records where nobody reads.
23
+ const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
22
24
  const projectName = basename(projectDir);
23
25
  const transcript = input.transcript_path || "";
24
26
  const trigger = input.trigger || "auto";
@@ -3,11 +3,17 @@
3
3
  // so a REGULAR (non-crew) Claude session's OWN work shows IN PROGRESS on the board as it happens — not only
4
4
  // when it commits or dispatches a sub-agent. ONE rolling card per session (the hub re-titles it as the focus
5
5
  // shifts and closes it to "done" when the session goes offline). Trivial acks ("yes", "go ahead") don't
6
- // refocus. Fail-silent + fast: NO LLM call (the title is a heuristic clean of the prompt; Scrooge-summarized
7
- // titles are a follow-up). Never blocks or delays the turn.
6
+ // refocus. Fail-silent + fast: NO LLM call ON THE TURN PATH — the title is a heuristic clean of the
7
+ // prompt, posted immediately. A long prompt then hands its rewrite to bin/focus-title.mjs, spawned
8
+ // DETACHED so a cheap model can produce a readable board line a few seconds later without the user
9
+ // ever waiting on it. Never blocks or delays the turn.
8
10
  import { homedir } from "node:os";
11
+ import { join, dirname } from "node:path";
12
+ import { writeFileSync, mkdirSync } from "node:fs";
13
+ import { spawn } from "node:child_process";
14
+ import { fileURLToPath } from "node:url";
9
15
  import { resolveProject, hostId } from "../lib/project.mjs";
10
- import { signedPost } from "./lib/api.mjs";
16
+ import { signedPost, relayUrl } from "./lib/api.mjs";
11
17
 
12
18
  function readStdin() {
13
19
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -42,7 +48,29 @@ try {
42
48
  const project = resolveProject(cwd);
43
49
  const session = process.env.RELAY_SESSION
44
50
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
45
- await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session }, { session });
51
+ // The Claude Code session UUID. `session` above is a BUS id — per host+project — so without this
52
+ // two Claude sessions in one project share (and fight over) a single focus card, and sub-agent
53
+ // cards, whose `parent` is exactly this UUID, have nothing to nest under.
54
+ const cc = String(input.session_id || "").slice(0, 120);
55
+ const r = await signedPost("/focus", { session, project, title: titleFrom(trimmed), by: session, cc }, { session });
56
+
57
+ // Only pay a model when the heuristic actually mangles the prompt. A short, already-clear ask
58
+ // ("fix the login redirect") reads fine as-is and buying a rewrite for it is exactly the kind of
59
+ // reflexive spend the economics doctrine exists to stop.
60
+ const id = r?.json?.id;
61
+ if (id && trimmed.length > 90 && process.env.TRANTOR_NO_SCROOGE_TITLES !== "1") {
62
+ try {
63
+ const busDir = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
64
+ mkdirSync(busDir, { recursive: true });
65
+ const pf = join(busDir, `focus-prompt-${String(cc || session).replace(/[^A-Za-z0-9_.-]/g, "_")}.txt`);
66
+ writeFileSync(pf, trimmed);
67
+ const worker = join(dirname(dirname(fileURLToPath(import.meta.url))), "bin", "focus-title.mjs");
68
+ // Detached + unref'd + stdio ignored: the hook returns NOW. Nothing downstream waits on this,
69
+ // and a worker that dies takes the heuristic title with it, which is a fine outcome.
70
+ spawn(process.execPath, [worker, "--id", String(id), "--hub", relayUrl(project), "--prompt-file", pf, "--project", project],
71
+ { detached: true, stdio: "ignore" }).unref();
72
+ } catch {}
73
+ }
46
74
  } catch (e) {
47
75
  process.stderr.write(`[trantor] prompt-focus error: ${e?.message || e}\n`);
48
76
  }
@@ -15,7 +15,7 @@ import { resolveProject, hostId } from "../lib/project.mjs";
15
15
  import { formatSubagentManifest } from "../lib/subagent-manifest.mjs";
16
16
  import { updateAvailable, maybeNotifyDesktop, readConfig } from "./lib/update-check.mjs";
17
17
  import { maybeCheckBalances } from "./lib/balance-check.mjs";
18
- import { relayUrl, getJSON, signedPost } from "./lib/api.mjs";
18
+ import { relayUrl, getJSON, signedGet, signedPost } from "./lib/api.mjs";
19
19
 
20
20
  // Load the most recent UNCONSUMED handoff for this project (written by precompact.mjs
21
21
  // / the heartbeat early-warning). `claim` marks it consumed so exactly one session
@@ -129,7 +129,10 @@ try {
129
129
  let source = "", stdinObj = {};
130
130
  try { stdinObj = JSON.parse((await readStdin()) || "{}"); source = stdinObj.source || ""; } catch {}
131
131
  userTitle = (stdinObj && stdinObj.session_title) ? String(stdinObj.session_title) : ""; // user already named it
132
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
132
+ // input.cwd FIRST, matching hooks/prompt-focus.mjs. When they disagreed, two hooks in ONE
133
+ // session resolved two different projects — and therefore two different hubs — so half a
134
+ // session's work recorded on a hub nobody was reading.
135
+ const projectDir = stdinObj.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
133
136
  // Sessions started in the home directory itself aren't project work — registering
134
137
  // them spawns a phantom "<username>" project board on the dashboard. Set
135
138
  // RELAY_SESSION (or RELAY_PROJECT) to deliberately put a home-dir session on the bus.
@@ -142,7 +145,9 @@ try {
142
145
  sessionTitle = project; // baseline — enriched with the current work item after the catch-up fetch below
143
146
  const session = process.env.RELAY_SESSION
144
147
  || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
145
- const url = relayUrl();
148
+ // relayUrl() with no project resolves from the hook process's cwd, which is not always the
149
+ // project the session is about. Pass it, or every call below can address the wrong hub.
150
+ const url = relayUrl(project);
146
151
 
147
152
  // register self + post an initial presence status (no LLM turn — instant for others to read)
148
153
  await jpost(`${url}/register`, { session, project, status: `active in ${project}` }, session).catch(() => {});
@@ -162,6 +167,21 @@ try {
162
167
  additionalContext += `</trantor>\n`;
163
168
  }
164
169
 
170
+ // ── GRANTS: standing permissions the operator has APPROVED for this project ──────
171
+ // The mechanical half of governance: an approval used to live only in a one-shot DM that died
172
+ // with the session that received it. Injecting the active grants here means EVERY future seat
173
+ // (including the duty agent and orchestrators, whose runners fire this hook each turn) inherits
174
+ // the operator's recorded decisions instead of re-asking or acting around them.
175
+ try {
176
+ const { grants = [] } = await jget(`${url}/grants?project=${encodeURIComponent(project)}`, session);
177
+ if (grants.length) {
178
+ additionalContext += `<trantor-grants project="${sanitize(project)}">\n`;
179
+ additionalContext += `Standing permissions the operator has APPROVED (act within the stated bound without re-asking; everything outside it still needs a proposal):\n`;
180
+ for (const g of grants.slice(-10)) additionalContext += `- [#${g.id}${g.key ? ` ${sanitize(g.key)}` : ""}] ${sanitize(g.scope)} — WHEN: ${sanitize(g.condition)} — NOT covered: ${sanitize(g.exclusions)}\n`;
181
+ additionalContext += `</trantor-grants>\n`;
182
+ }
183
+ } catch {}
184
+
165
185
  // ── ADOPT live crews (intersession-ops S1+S2, contract #4215) ─────────────────
166
186
  // Every boot inventories leftover crew resources via the #4214 detection lib and steers the
167
187
  // session toward ADOPTING a live crew rather than `trantor up`-ing over it (replace-in-place
@@ -78,7 +78,7 @@ async function main() {
78
78
  let messages = [];
79
79
  try {
80
80
  // PEEK: look without claiming delivery. We may yet decide to let the stop through.
81
- const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
81
+ const peek = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}&peek=1`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
82
82
  if (!peek.ok) return allow();
83
83
  // Superseded twin (instance-keys contract): a newer instance claimed the baton — this session
84
84
  // stands down. Blocking ITS stop over messages the new instance will handle would trap it.
@@ -92,7 +92,7 @@ async function main() {
92
92
  // Committed now: claim delivery for real so neither inbox-deliver nor the deferred waker repeats it.
93
93
  let next = cursor;
94
94
  try {
95
- const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId });
95
+ const claim = await signedGet(`/inbox?session=${encodeURIComponent(session)}&since=${cursor}`, { timeoutMs: FETCH_TIMEOUT_MS, session, instance: instanceId, project });
96
96
  if (claim.ok) next = claim.json?.cursor || cursor;
97
97
  } catch {}
98
98
  try { writeFileSync(cursorFile, String(next)); } catch {}
@@ -53,8 +53,11 @@ try {
53
53
  // with the SubagentStop "done" card on any client that predates agent_id pairing (legacy fallback).
54
54
  const task = String(ti.prompt || ti.description || agentType).replace(/\s+/g, " ").trim().slice(0, 90);
55
55
  const title = `${agentType}: ${task}`.slice(0, 180);
56
+ // parent at CREATE time too, not only on the SubagentStart enrich — a card that spends its
57
+ // whole doing-life unparented is un-nestable exactly while it is the interesting one.
56
58
  await signedPost("/task", {
57
59
  project, title, status: "doing", agentType,
60
+ parent: String(input.session_id || "").slice(0, 120),
58
61
  assignee: `${agentType}:${project}`, by: `${hostId()}:${project}`,
59
62
  source: "cc-subagent", costKind: "subagent-notional", phase: "sub-agents",
60
63
  });
@@ -3,8 +3,8 @@
3
3
  // so SOLO work (no crew fired up) shows up live and accrues timeline history. The hub reconciles by
4
4
  // todo text (pending/in_progress/completed -> todo/doing/done). Fail-silent by contract: a bad hub,
5
5
  // a home-dir session, or any error must never block or break the tool flow.
6
- import { basename } from "node:path";
7
- import { homedir, hostname } from "node:os";
6
+ import { homedir } from "node:os";
7
+ import { resolveProject, hostId } from "../lib/project.mjs";
8
8
  import { signedPost } from "./lib/api.mjs";
9
9
  function readStdin() {
10
10
  return new Promise(res => { let d = ""; process.stdin.setEncoding("utf8");
@@ -13,21 +13,26 @@ function readStdin() {
13
13
  }
14
14
 
15
15
  async function main() {
16
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
16
+ // stdin FIRST: input.cwd is the session's own directory and it decides both the project and the
17
+ // hub, so it has to be read before either is resolved.
18
+ let input = {};
19
+ try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
20
+ const projectDir = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
17
21
  // Mirror sessionstart/heartbeat: home-directory sessions aren't project work — don't card them
18
22
  // (would spawn a phantom "<username>" board). Opt in with RELAY_SESSION/RELAY_PROJECT.
19
23
  if (!process.env.RELAY_SESSION && !process.env.RELAY_PROJECT && projectDir === homedir()) return;
20
-
21
- let input = {};
22
- try { input = JSON.parse((await readStdin()) || "{}"); } catch { return; }
23
24
  if (input.tool_name && input.tool_name !== "TodoWrite") return; // the matcher should scope us, but be safe
24
25
  const todos = input.tool_input?.todos;
25
26
  if (!Array.isArray(todos) || !todos.length) return;
26
27
 
27
28
  // Identity EXACTLY as mcp.mjs/heartbeat resolve it, so we card the same peer the relay registered.
28
- const project = process.env.RELAY_PROJECT || basename(projectDir);
29
+ // This said `basename(projectDir)` and `hostname()`, which is not the same thing at all:
30
+ // resolveProject keys off the GIT ROOT (a subdirectory forked its own lane) and hostId is the
31
+ // stable machine id, where hostname() drifts to "MacBook-Pro-M1.local" on some networks and
32
+ // splits one machine into two peers.
33
+ const project = resolveProject(projectDir);
29
34
  const session = process.env.RELAY_SESSION
30
- || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostname()}:${project}`);
35
+ || (process.env.RELAY_AGENT ? `${process.env.RELAY_AGENT}:${project}` : `${hostId()}:${project}`);
31
36
 
32
37
  await signedPost("/todos", { session, project, by: session, todos: todos.map(t => ({ content: t.content, status: t.status })) });
33
38
  }