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/.claude-plugin/plugin.json +1 -1
- package/bin/adopt.mjs +35 -2
- package/bin/bridge.mjs +135 -0
- package/bin/crew-runner.mjs +105 -15
- package/bin/crew.sh +13 -1
- package/bin/doctor.mjs +32 -1
- package/bin/duty.mjs +1 -1
- package/bin/focus-title.mjs +57 -0
- package/bin/orchestrate.mjs +1 -1
- package/bin/proposals.mjs +12 -8
- package/hooks/heartbeat.mjs +4 -1
- package/hooks/inbox-deliver.mjs +22 -11
- package/hooks/lib/api.mjs +49 -16
- package/hooks/precompact.mjs +3 -1
- package/hooks/prompt-focus.mjs +32 -4
- package/hooks/sessionstart.mjs +23 -3
- package/hooks/stop-inbox.mjs +2 -2
- package/hooks/subagent-start.mjs +3 -0
- package/hooks/todo-sync.mjs +13 -8
- package/hub.mjs +110 -15
- package/lib/splitbrain.mjs +130 -0
- package/mcp.mjs +9 -6
- package/package.json +3 -3
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
|
|
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,
|
|
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
|
|
82
|
-
if (holding.length > 1) { console.error(`proposal #${id} is
|
|
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
|
|
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);
|
package/hooks/heartbeat.mjs
CHANGED
|
@@ -106,7 +106,10 @@ async function maybeEarlyWarn(stdinRaw, session) {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function main(stdinRaw) {
|
|
109
|
-
|
|
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;
|
package/hooks/inbox-deliver.mjs
CHANGED
|
@@ -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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(`${
|
|
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,
|
|
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
|
|
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);
|
|
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
|
|
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);
|
|
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,
|
package/hooks/precompact.mjs
CHANGED
|
@@ -18,7 +18,9 @@ function readStdin() {
|
|
|
18
18
|
|
|
19
19
|
try {
|
|
20
20
|
const input = JSON.parse((await readStdin()) || "{}");
|
|
21
|
-
|
|
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";
|
package/hooks/prompt-focus.mjs
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/hooks/sessionstart.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/hooks/stop-inbox.mjs
CHANGED
|
@@ -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 {}
|
package/hooks/subagent-start.mjs
CHANGED
|
@@ -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
|
});
|
package/hooks/todo-sync.mjs
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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}` : `${
|
|
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
|
}
|