trantor 0.18.109 → 0.18.110

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.109",
3
+ "version": "0.18.110",
4
4
  "description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
5
5
  "mcpServers": {
6
6
  "relay": {
package/README.md CHANGED
@@ -417,9 +417,16 @@ rate, not work rate.
417
417
  ```
418
418
  trantor setup | doctor | connect | profile | provider | models
419
419
  | new <name> | up <agents…> | swap <old> <new> | down | seat-why <agent> | ui | advise | hub | watch
420
- | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks
420
+ | adopt <project> | reconcile | duty | orchestrate | patrol | app | backfill | init-hooks | pair
421
421
  ```
422
422
 
423
+ - **`trantor pair`** pairs the iPhone and iPad companion app (`ios/`, an operator console for
424
+ fleet decisions). It mints a single-use, ten-minute invite with the `approver` role (decides
425
+ holds, proposals and gates; never deletes projects or mints invites) and prints a
426
+ `trantor://enroll` link. `--sim` opens it in the booted simulator, `--json` prints it for
427
+ scripts, and `--role owner` widens the scope. The desktop app's Settings shows the same invite
428
+ as a QR code. It signs as the operator's owner identity (`TRANTOR_IDENTITY`, default `sasha@mac`).
429
+
423
430
  - **`trantor new <name>`** — project genesis in one command: makes the dir under your dev root
424
431
  (`TRANTOR_DEV_ROOT` or `~/development`), git on main (or `--from <git-url>`, or `--adopt` an
425
432
  existing folder), installs the auto-card hook, posts the brief to the hub, and opens the first
package/bin/cli.mjs CHANGED
@@ -187,6 +187,7 @@ switch (cmd) {
187
187
  else console.error(`Invite failed: ${j.error || r.statusText}`);
188
188
  break;
189
189
  }
190
+ case "pair": run("bin/pair.mjs"); break;
190
191
  case "enroll": {
191
192
  const token = args[0];
192
193
  if (!token) { console.error("usage: trantor enroll <token>"); process.exit(1); }
@@ -256,6 +257,7 @@ switch (cmd) {
256
257
  trantor down tear the crew down (kills processes, closes windows, no dialogs)
257
258
  trantor prune drop dead crew-window tracking rows (ghost workspaces/panes) without spawning anything
258
259
  trantor ui open the live dashboard (board + flow views)
260
+ trantor pair pair a phone: mint a trantor://enroll link for the companion app — [--role owner] [--sim] [--json]
259
261
  trantor app the DESKTOP app: status | install | update — pulls the latest DMG from GitHub Releases
260
262
  trantor catchup "where are we?" — the continuous board + git, with a synthesized brief
261
263
  trantor agents what this session's sub-agents did (task · returned? · files written · survived on disk) — [<sessionId>] [--json]
package/bin/pair.mjs ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // trantor pair — mint a phone-pairing invite and print it as a trantor://enroll link.
3
+ // Same owner signing as `trantor invite`, but scoped for the companion app: every project,
4
+ // the approver role, single use, ten minutes. The phone scans the QR (desktop Settings) or
5
+ // opens the link (this command, --sim) and enrolls itself.
6
+ import { join, dirname } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
11
+ const args = process.argv.slice(2);
12
+ const asJson = args.includes("--json");
13
+ const openSim = args.includes("--sim");
14
+ const roleIdx = args.indexOf("--role");
15
+ const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
16
+ if (!role || role.startsWith("--")) {
17
+ console.error("usage: trantor pair [--role owner] [--sim] [--json]");
18
+ process.exit(1);
19
+ }
20
+
21
+ const { load, signRequest } = await import(join(ROOT, "lib/identity.mjs"));
22
+ const { resolveHubInfo } = await import(join(ROOT, "lib/project.mjs"));
23
+
24
+ const hub = resolveHubInfo().url.replace(/\/+$/, "");
25
+ // The operator's owner key, the same one the desktop app signs with (hub.rs owner_identity).
26
+ // Loaded, never minted: a fresh key is unknown to the hub and every invite would 401.
27
+ const owner = process.env.TRANTOR_IDENTITY || "sasha@mac";
28
+ const id = load(owner);
29
+ if (!id) { console.error(`error: no key for the owner identity "${owner}". Set TRANTOR_IDENTITY, or run \`trantor keys status\``); process.exit(1); }
30
+
31
+ const payload = { scopes: [{ project: "*", role }], ttlSec: 600, kind: "human" };
32
+ const body = JSON.stringify(payload);
33
+ const sig = signRequest(id, { method: "POST", path: "/invite", body });
34
+ let r;
35
+ try {
36
+ r = await fetch(`${hub}/invite`, { method: "POST", headers: { "content-type": "application/json", ...sig }, body });
37
+ } catch (e) {
38
+ console.error(`error: hub unreachable at ${hub} — ${e.message}`);
39
+ process.exit(1);
40
+ }
41
+ const j = await r.json().catch(() => ({}));
42
+
43
+ // The hub may predate the approver role (#12113): an explicit 400 names it, an older hub is
44
+ // quieter — cleanScope folds unknown roles down to read and echoes the folded scope. Either
45
+ // way a link minted here would pair the phone into near-nothing, so refuse it instead.
46
+ const echoed = Array.isArray(j.scopes) ? j.scopes[0]?.role : undefined;
47
+ const roleRefused = !r.ok
48
+ ? /role|approver/i.test(String(j.error || "")) || r.status === 400
49
+ : echoed !== role;
50
+ if (roleRefused) {
51
+ const why = r.ok
52
+ ? `this hub does not know the "${role}" role yet — it answered with "${echoed}", which would pair the phone near-read-only`
53
+ : `hub refused the invite: ${j.error || r.statusText}`;
54
+ if (asJson) console.log(JSON.stringify({ error: why, hint: "retry with --role owner" }));
55
+ else console.error(`Pairing refused: ${why}\nRetry with --role owner.`);
56
+ process.exit(1);
57
+ }
58
+ if (!r.ok) {
59
+ if (asJson) console.log(JSON.stringify({ error: j.error || r.statusText }));
60
+ else console.error(`Pairing failed: ${j.error || r.statusText}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ const link = `trantor://enroll?hub=${encodeURIComponent(hub)}&token=${j.token}`;
65
+ const expiresAt = Number(j.expiresAt) || Date.now() + 600000;
66
+ const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
67
+
68
+ if (asJson) {
69
+ console.log(JSON.stringify({ link, token: j.token, hub, expiresAt }));
70
+ } else {
71
+ console.log(`Pair link (single use):\n${link}\nExpires in ${mins} minute${mins === 1 ? "" : "s"}.`);
72
+ }
73
+
74
+ if (openSim) {
75
+ if (process.platform !== "darwin") {
76
+ console.error("--sim needs macOS (xcrun simctl)");
77
+ process.exit(1);
78
+ }
79
+ const sim = spawnSync("xcrun", ["simctl", "openurl", "booted", link], { stdio: "pipe" });
80
+ if (sim.status !== 0) {
81
+ console.error(`simctl could not open the link: ${String(sim.stderr).trim() || "no booted simulator?"}`);
82
+ process.exit(1);
83
+ }
84
+ if (!asJson) console.log("Opened in the booted simulator.");
85
+ }
package/hub/auth.mjs CHANGED
@@ -53,15 +53,16 @@ function cmpSemver(a, b) {
53
53
  }
54
54
  const AUTH_HEADERS = ["x-trantor-pubkey", "x-trantor-sig", "x-trantor-ts", "x-trantor-nonce"];
55
55
  const PUBLIC_ENDPOINTS = new Set(["/", "/ui", "/health", "/enroll"]);
56
- const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/proposal/decide"]);
56
+ const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy"]);
57
+ const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
57
58
  const READ_ENDPOINTS = new Set(["/peers", "/tasks", "/events", "/inbox", "/peer", "/card", "/stream", "/history", "/projects", "/catchup", "/phases", "/recent", "/handoffs", "/verify-gates", "/claims", "/proposals", "/grants", "/overseer/context", "/overseer/status"]);
58
- const roleRank = { read: 1, write: 2, owner: 3 };
59
+ const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
59
60
  const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
60
61
  const authPath = (u) => `${u.pathname}${u.search || ""}`;
61
62
  function cleanScope(s) {
62
63
  if (!s || typeof s !== "object") return null;
63
64
  const project = canon(String(s.project || s.proj || "*").slice(0, 80)) || "*";
64
- const role = ["read", "write", "owner"].includes(s.role) ? s.role : "read";
65
+ const role = ["read", "write", "approver", "owner"].includes(s.role) ? s.role : "read";
65
66
  return { project, role };
66
67
  }
67
68
  function defaultScopesFor(identity, b) {
@@ -69,7 +70,7 @@ function defaultScopesFor(identity, b) {
69
70
  if (explicit.length) return explicit.slice(0, 20);
70
71
  const name = String(b.name || identity.name || "");
71
72
  const project = canon(String(b.project || (name.includes(":") ? name.split(":").pop() : "*")).slice(0, 80)) || "*";
72
- const role = ["read", "write", "owner"].includes(b.role) ? b.role : (String(b.kind || identity.kind || "") === "agent" ? "write" : "owner");
73
+ const role = ["read", "write", "approver", "owner"].includes(b.role) ? b.role : (String(b.kind || identity.kind || "") === "agent" ? "write" : "owner");
73
74
  return [{ project, role }];
74
75
  }
75
76
  function findIdentity(pubkey) {
@@ -247,6 +248,8 @@ function authorize(auth, method, P, project) {
247
248
  if (AUTH_MODE === "off" || PUBLIC_ENDPOINTS.has(P)) return { ok: true };
248
249
  if (auth?.warning && AUTH_MODE === "warn") return { ok: true };
249
250
  if (!auth?.identity) return { ok: false, code: 401, error: "signature required" };
251
+ // Device management is self-keyed, not project-scoped; the route enforces the signer boundary.
252
+ if (DEVICE_ENDPOINTS.has(P)) return { ok: true };
250
253
  const need = OWNER_ENDPOINTS.has(P) ? "owner" : (method === "POST" ? "write" : (READ_ENDPOINTS.has(P) ? "read" : "read"));
251
254
  if (isDutyIdentity(auth) && (need === "read" || P === "/send" || P === "/duty/failure")) return { ok: true };
252
255
  return scopeAllows(auth.identity, project, need) ? { ok: true } : { ok: false, code: 403, error: "forbidden" };
package/hub/events.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: Event payloads preserve the established card wire contract, including numeric cost fields from legacy durable rows. */
2
2
  import { publicView } from "../lib/identity.mjs";
3
3
 
4
- export function createEventRuntime({ state, markDirty, AUTH_MODE, ONLINE_MS, canon }) {
4
+ export function createEventRuntime({ state, markDirty, AUTH_MODE, ONLINE_MS, canon, notifier = null }) {
5
5
  const streams = [];
6
6
  // live file claims: "project file session" -> { project, file, session, ts }. Ephemeral like
7
7
  // presence — see the /claim handler for the design.
@@ -26,12 +26,9 @@ function applyPeerAuth(p, auth) {
26
26
  function touch(session, status, project, hookVersion, auth) {
27
27
  if (!session || session === "all") return; // "all" is a wildcard, not a real peer
28
28
  const p = state.peers[session] || { lastSeen: 0, status: "", project: "" };
29
- // NOTE: peers deliberately carry NO terminal address (tty/window). A short-lived experiment recorded
30
- // one so an idle session could be woken by typing into its terminal; that was removed as unsafe.
31
- // /send is unauthenticated and `from` is self-asserted, so a routable terminal address turns any local
32
- // process into arbitrary keystrokes in a session that may be running with permissions bypassed. A
33
- // message must reach an agent through a channel the agent's own harness controls (see hooks/
34
- // inbox-deliver.mjs and hooks/stop-inbox.mjs), never by driving a process we do not own.
29
+ // Peers carry no terminal address: /send is unauthenticated and `from` is self-asserted, so one
30
+ // would turn any local process into arbitrary keystrokes. Messages reach agents only through
31
+ // channels their own harness controls (hooks/inbox-deliver.mjs and hooks/stop-inbox.mjs).
35
32
  delete p.tty; delete p.windowId; delete p.host;
36
33
  const wasOnline = p._on === true; // explicit flag, so the log gets ONE event per transition
37
34
  p.lastSeen = now();
@@ -70,11 +67,9 @@ function healthOf(status) {
70
67
  return "ok";
71
68
  }
72
69
  function deliverable(m, session) { return (m.to === session || m.to === "all") && m.from !== session; }
73
- // The delivery ledger: the highest message id this session has actually been handed. Every read path
74
- // (/inbox, /poll) reports here, so ONE record answers "has this session seen message N yet?" regardless
75
- // of whether it was the model polling (relay_inbox) or the PostToolUse hook injecting mid-turn.
76
- // That is what lets the deferred waker give in-session delivery first refusal and only type into
77
- // somebody's terminal when nothing else got there first. Monotonic — a later read never lowers it.
70
+ // The delivery ledger records the highest message id handed to a session across /inbox and /poll,
71
+ // whether read by relay_inbox or a PostToolUse hook. The deferred waker gives in-session delivery
72
+ // first refusal; the high-water mark is monotonic.
78
73
  function markDelivered(session, upTo) {
79
74
  const p = state.peers[session]; const n = Number(upTo || 0);
80
75
  if (!p || !n) return;
@@ -82,7 +77,7 @@ function markDelivered(session, upTo) {
82
77
  }
83
78
  function pushToStreams(msg) {
84
79
  for (const s of streams) {
85
- if (!deliverable(msg, s.session) || s.res.writableEnded || s.res.destroyed) continue;
80
+ if (!deliverable(msg, s.session) || (s.readsMessage && !s.readsMessage(msg)) || s.res.writableEnded || s.res.destroyed) continue;
86
81
  try { s.res.write(`data: ${JSON.stringify(msg)}\n\n`); } catch {}
87
82
  }
88
83
  }
@@ -107,18 +102,13 @@ function inboxResponse(auth, messages, cursor, rewound = false) {
107
102
  // message and nothing else — can never see it. Backwards-safe by construction.
108
103
  function pushEventToStreams(ev) {
109
104
  for (const s of streams) {
110
- if (!s.events || s.res.writableEnded || s.res.destroyed) continue;
105
+ if (!s.events || (s.readsEvent && !s.readsEvent(ev)) || s.res.writableEnded || s.res.destroyed) continue;
111
106
  try { s.res.write(`event: ev\ndata: ${JSON.stringify(ev)}\n\n`); } catch {}
112
107
  }
113
108
  }
114
- // --- the unified event log ---------------------------------------------------------------
115
- // One append-only stream for everything that happens in a project. Two families share it:
116
- // • CARD events keep the LEGACY flat shape and legacy type names ("created"/"moved"/"updated")
117
- // so /history, /card, /project/merge and the TIMELINE view are byte-for-byte unaffected.
118
- // • Everything else uses a DOTTED type ("message", "presence.online", "focus", "handoff.written",
119
- // "lesson", "verify.gate.opened", …) and is filtered OUT of /history — it surfaces via /events.
120
- // Card events carry `source` + `costUsd` so the FEED can render a git commit or a sub-agent's
121
- // spend distinctly from an ordinary card move without a second lookup.
109
+ // The unified log keeps card events in their legacy flat shape for /history, /card and TIMELINE.
110
+ // Dotted non-card types surface only through /events. Card events also carry source and costUsd
111
+ // so the FEED can distinguish commits and sub-agent spend without another lookup.
122
112
  const EVENT_CAP = Number(process.env.RELAY_EVENT_CAP || 20000);
123
113
  const CARD_TYPES = new Set(["created", "moved", "updated"]);
124
114
  const isCardEvent = e => CARD_TYPES.has(e?.type);
@@ -134,6 +124,13 @@ function appendEvent(type, project, by, extra = {}) {
134
124
  state.events.push(ev);
135
125
  if (state.events.length > EVENT_CAP) state.events.splice(0, state.events.length - EVENT_CAP);
136
126
  pushEventToStreams(ev);
127
+ try {
128
+ if (notifier) Promise.resolve(notifier.notify(ev)).catch(error => {
129
+ process.stderr.write(`[trantor] push failed: ${error?.message || error}\n`);
130
+ });
131
+ } catch (error) {
132
+ process.stderr.write(`[trantor] push failed: ${error?.message || error}\n`);
133
+ }
137
134
  markDirty();
138
135
  return ev;
139
136
  }
@@ -159,13 +156,3 @@ function appendCardEvent(type, task, by, from = null, to = null, extra = {}) {
159
156
  appendCardEvent, EVENT_CAP, CARD_TYPES, isCardEvent,
160
157
  };
161
158
  }
162
-
163
- // --- FLOW v2: derive a project's PHASES (the orchestrator-rooted flowchart spine) ---
164
- // Real data is deps-sparse (most cards carry no deps), so we DON'T derive phases from the
165
- // dependency graph. Instead: a card's phase = its title-prefix family (P5a/P5b → "P5",
166
- // CBv2-1/CBfix → "CB", FA-comp1 → "FA", …) when present; otherwise it's clustered with its
167
- // time-neighbours into a "Setup N" round (gap > 8h opens a new round). Phases are ordered by
168
- // first-seen. The orchestrator (host session, "machine:project") vs crew ("brand:project")
169
- // split gives each phase its fan-out actors; the plan/integrate spine nodes are synthetic
170
- // because real per-phase orchestrator cards are rare. `sparse` flags a board that's mostly
171
- // un-prefixed (the UI shows an "inferred phases" notice — never a silent blob).
@@ -0,0 +1,171 @@
1
+ import http2 from "node:http2";
2
+ import { createPrivateKey, sign } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ const APNS_HOSTS = {
6
+ sandbox: "api.sandbox.push.apple.com",
7
+ production: "api.push.apple.com",
8
+ };
9
+ const JWT_REUSE_MS = 50 * 60 * 1000;
10
+ const text = (value, max = 300) => String(value || "").slice(0, max);
11
+
12
+ function apnsTransport({ host, path, headers, body }) {
13
+ return new Promise((resolve, reject) => {
14
+ const session = http2.connect(`https://${host}`);
15
+ let settled = false;
16
+ const fail = error => {
17
+ if (settled) return;
18
+ settled = true;
19
+ session.destroy();
20
+ reject(error);
21
+ };
22
+ session.on("error", fail);
23
+ const req = session.request({ ...headers, ":method": "POST", ":path": path });
24
+ let status = 0;
25
+ let responseBody = "";
26
+ req.setEncoding("utf8");
27
+ req.on("response", headers => { status = Number(headers[":status"] || 0); });
28
+ req.on("data", chunk => { responseBody += chunk; });
29
+ req.on("error", fail);
30
+ req.setTimeout(10000, () => fail(new Error("APNs request timed out")));
31
+ req.on("end", () => {
32
+ if (settled) return;
33
+ settled = true;
34
+ session.close();
35
+ resolve({ status, body: responseBody });
36
+ });
37
+ req.end(body);
38
+ });
39
+ }
40
+
41
+ function clearFor(event) {
42
+ // Whatever ends the item clears the phone's alert: decided, resolved, expired or withdrawn.
43
+ const match = /^(hold|verify\.gate|proposal)\.(?:decided|resolved|closed|expired|withdrawn)$/.exec(event.type || "");
44
+ if (!match) return null;
45
+ const kind = match[1] === "verify.gate" ? "gate" : match[1];
46
+ const id = Number(kind === "hold" ? event.holdId : kind === "gate" ? event.gateId : event.proposalId);
47
+ if (!id) return null;
48
+ return { kind, id, project: text(event.project, 80), silent: true };
49
+ }
50
+
51
+ function alertFor(event, state) {
52
+ const base = { project: text(event.project, 80), silent: false, direct: false, category: "" };
53
+ if (event.type === "hold.opened" && Number(event.holdId)) {
54
+ return { ...base, kind: "hold", id: Number(event.holdId), category: "HOLD",
55
+ title: `File hold #${Number(event.holdId)}`, body: text(event.file || event.reason) };
56
+ }
57
+ if (event.type === "verify.gate.opened" && Number(event.gateId)) {
58
+ return { ...base, kind: "gate", id: Number(event.gateId), category: "GATE",
59
+ title: `Verify gate #${Number(event.gateId)}`, body: text(event.claim) };
60
+ }
61
+ if (event.type === "proposal.filed" && Number(event.proposalId)) {
62
+ return { ...base, kind: "proposal", id: Number(event.proposalId), category: "PROPOSAL",
63
+ title: `Proposal #${Number(event.proposalId)}`, body: text(event.scope) };
64
+ }
65
+ if (event.type === "message" && Number(event.msgId)) {
66
+ return { ...base, kind: "message", id: Number(event.msgId), direct: true,
67
+ title: `Message from ${text(event.by, 120)}`, body: text(event.text) };
68
+ }
69
+ if (event.type === "presence.offline" && state.peers?.[event.by]?.kind === "agent" && Number(event.id)) {
70
+ return { ...base, kind: "presence", id: Number(event.id),
71
+ title: `${text(event.by, 120)} offline`, body: text(event.project ? `Crew seat offline in ${event.project}` : "Crew seat offline") };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export function createNotifier({ state, markDirty, canRead, transport = apnsTransport,
77
+ env = process.env, now = Date.now, log = line => process.stderr.write(`[trantor] ${line}\n`) }) {
78
+ const keyId = String(env.APNS_KEY_ID || "");
79
+ const teamId = String(env.APNS_TEAM_ID || "");
80
+ let keyText = String(env.APNS_PRIVATE_KEY || env.APNS_KEY || "").replace(/\\n/g, "\n");
81
+ if (!keyText && env.APNS_KEY_PATH) {
82
+ try { keyText = readFileSync(String(env.APNS_KEY_PATH), "utf8"); }
83
+ catch { log("push disabled: APNS_KEY_PATH unreadable"); }
84
+ }
85
+ const defaultBundleId = String(env.APNS_BUNDLE_ID || "ai.trantor.companion");
86
+ let privateKey = null;
87
+ if (keyId && teamId && keyText) {
88
+ try { privateKey = createPrivateKey(keyText); }
89
+ catch { log("push disabled: invalid APNs key"); }
90
+ } else {
91
+ log("push disabled: no APNs key");
92
+ }
93
+ let cachedJwt = "";
94
+ let cachedJwtAt = 0;
95
+
96
+ function jwt() {
97
+ const time = now();
98
+ if (cachedJwt && time >= cachedJwtAt && time - cachedJwtAt < JWT_REUSE_MS) return cachedJwt;
99
+ const header = Buffer.from(JSON.stringify({ alg: "ES256", kid: keyId })).toString("base64url");
100
+ const claims = Buffer.from(JSON.stringify({ iss: teamId, iat: Math.floor(time / 1000) })).toString("base64url");
101
+ const signature = sign("sha256", Buffer.from(`${header}.${claims}`), { key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64url");
102
+ cachedJwt = `${header}.${claims}.${signature}`;
103
+ cachedJwtAt = time;
104
+ return cachedJwt;
105
+ }
106
+
107
+ function dropDevice(device) {
108
+ const index = state.devices.findIndex(d => d.pubkey === device.pubkey && d.apns_token === device.apns_token);
109
+ if (index < 0) return;
110
+ state.devices.splice(index, 1);
111
+ markDirty();
112
+ }
113
+
114
+ async function send(device, delivery) {
115
+ const aps = delivery.silent
116
+ ? { "content-available": 1 }
117
+ : { alert: { title: delivery.title, body: delivery.body }, sound: "default" };
118
+ if (delivery.category) aps.category = delivery.category;
119
+ const payload = JSON.stringify({ aps, kind: delivery.kind, id: delivery.id, project: delivery.project });
120
+ // kind in the id: hold #5 and gate #5 are different alerts
121
+ const collapseId = `${delivery.kind}-${delivery.id}`;
122
+ try {
123
+ const result = await transport({
124
+ host: APNS_HOSTS[device.env] || APNS_HOSTS.production,
125
+ path: `/3/device/${device.apns_token}`,
126
+ headers: {
127
+ authorization: `bearer ${jwt()}`,
128
+ "apns-topic": device.bundle_id || defaultBundleId,
129
+ "apns-push-type": delivery.silent ? "background" : "alert",
130
+ "apns-priority": delivery.silent ? "5" : "10",
131
+ "apns-collapse-id": collapseId,
132
+ },
133
+ body: payload,
134
+ });
135
+ let reason = "";
136
+ try { reason = JSON.parse(result.body || "{}").reason || ""; } catch {}
137
+ if (result.status === 410 || reason === "BadDeviceToken") {
138
+ dropDevice(device);
139
+ return;
140
+ }
141
+ if (result.status !== 200) {
142
+ log(`push failed (${Number(result.status || 0)}): ${reason || "APNs error"}`);
143
+ return;
144
+ }
145
+ const current = state.devices.find(d => d.pubkey === device.pubkey && d.apns_token === device.apns_token);
146
+ if (current) {
147
+ current.last_ok = now();
148
+ markDirty();
149
+ }
150
+ } catch (error) {
151
+ log(`push failed: ${text(error?.message || error)}`);
152
+ }
153
+ }
154
+
155
+ async function notify(event) {
156
+ if (!privateKey) return;
157
+ const delivery = clearFor(event) || alertFor(event, state);
158
+ if (!delivery) return;
159
+ const deliveries = [];
160
+ for (const device of [...(state.devices || [])]) {
161
+ const identity = state.identities?.[device.pubkey];
162
+ if (!identity || identity.revoked || !canRead({ identity }, delivery.project)) continue;
163
+ if ((device.muted_projects || []).includes(delivery.project)) continue;
164
+ if (delivery.direct && String(identity.name || "") !== String(event.toSession || "")) continue;
165
+ deliveries.push(send(device, delivery));
166
+ }
167
+ await Promise.all(deliveries);
168
+ }
169
+
170
+ return { notify };
171
+ }
@@ -305,7 +305,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
305
305
  const b = await body(req);
306
306
  const hold = overseer.listHolds().find(h => h.id === Number(b.id));
307
307
  if (!hold) return json(res, 404, { error: "hold expired or not found" });
308
- if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "owner")) return json(res, 403, { error: "operator permission required" });
308
+ if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, hold.project, "approver")) return json(res, 403, { error: "operator permission required" });
309
309
  if (b.status !== "go" && b.status !== "nogo") return json(res, 400, { error: "status must be go or nogo" });
310
310
  const decided = overseer.decideHold(hold.id, b.status, auth?.identity?.name || "operator");
311
311
  return decided ? json(res, 200, { ok: true, hold: decided }) : json(res, 409, { error: "hold already decided" });
@@ -38,7 +38,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
38
38
  appendTaskLog, appendTaskNote, cleanChecklist, cleanDrill, hasDrillLine, linkCommitToFocus,
39
39
  derivePhases, PROPOSAL_CAP, propFp, healthOf, REAP_GRACE_MS, subFp,
40
40
  prunePeers, canRead, HUB_VERSION, cmpSemver, isCardEvent, fmtAge, hubSend,
41
- ONLINE_MS, PEER_TTL_MS, AUTH_MODE,
41
+ ONLINE_MS, PEER_TTL_MS, AUTH_MODE, scopeAllows,
42
42
  } = ctx;
43
43
  if (req.method === "POST" && P === "/task") { // create a card
44
44
  const b = await body(req);
@@ -647,7 +647,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
647
647
  // --- agent-proposed permissions (governance): the autonomy ladder made two-directional ---
648
648
  // An agent needing more rope FILES A PROPOSAL here instead of assuming or working around.
649
649
  // Enforced HERE: unbounded proposal = 400; queue caps at 3 pending/session; near-duplicate of
650
- // a DENIED proposal = refused. Deciding is the HUMAN's alone — /proposal/decide is owner-gated.
650
+ // a DENIED proposal = refused. Deciding is the HUMAN's alone — /proposal/decide requires approver or owner.
651
651
  if (req.method === "POST" && P === "/propose") {
652
652
  const b = await body(req);
653
653
  const session = String(b.session || b.by || "").slice(0, 120);
@@ -689,6 +689,7 @@ export async function routeCards({ req, res, q, P, auth, ctx }) {
689
689
  const b = await body(req);
690
690
  const pr = state.proposals.find(p => p.id === Number(b.id));
691
691
  if (!pr) return json(res, 404, { error: "no such proposal" });
692
+ if (AUTH_MODE !== "off" && !scopeAllows(auth?.identity, pr.project, "approver")) return json(res, 403, { error: "operator permission required" });
692
693
  // A grant that GATES tool behavior needs an off-switch: the operator may REVOKE an
693
694
  // approved proposal. Revocation is not a denial — it leaves no denial memory, so the
694
695
  // agent may re-propose a refined bound later.
@@ -0,0 +1,55 @@
1
+ export async function routeDevices({ req, res, q, P, auth, ctx }) {
2
+ if (!((req.method === "POST" && (P === "/device/register" || P === "/device/unregister")) ||
3
+ (req.method === "GET" && P === "/devices"))) return false;
4
+ const { state, body, json, markDirty, canon, AUTH_MODE } = ctx;
5
+ if (AUTH_MODE === "off" || !auth?.identity || !auth?.pubkey) {
6
+ return json(res, 401, { error: "signed identity required" });
7
+ }
8
+ const pubkey = auth.pubkey;
9
+ state.devices ||= [];
10
+
11
+ if (req.method === "GET") {
12
+ if (q.pubkey && q.pubkey !== pubkey) return json(res, 403, { error: "cannot read another signer's devices" });
13
+ return json(res, 200, { devices: state.devices.filter(d => d.pubkey === pubkey) });
14
+ }
15
+
16
+ const b = await body(req);
17
+ if (b.pubkey && b.pubkey !== pubkey) return json(res, 403, { error: "cannot manage another signer's devices" });
18
+ const token = String(b.apnsToken || b.apns_token || "").trim().toLowerCase();
19
+ if (!/^[a-f0-9]{32,128}$/.test(token)) return json(res, 400, { error: "a hexadecimal apnsToken is required" });
20
+
21
+ if (P === "/device/unregister") {
22
+ const index = state.devices.findIndex(d => d.apns_token === token && d.pubkey === pubkey);
23
+ if (index < 0) return json(res, 404, { error: "device not found" });
24
+ const [device] = state.devices.splice(index, 1);
25
+ markDirty();
26
+ return json(res, 200, { ok: true, device });
27
+ }
28
+
29
+ const requestedEnv = String(b.env || "production").toLowerCase();
30
+ const env = requestedEnv === "development" ? "sandbox" : requestedEnv;
31
+ if (env !== "sandbox" && env !== "production") return json(res, 400, { error: "env must be sandbox or production" });
32
+ // A token is one physical device. Re-pairing makes a new key for the same phone, so the newest
33
+ // registration wins and the old identity's row goes; otherwise a re-paired phone loses push.
34
+ const before = state.devices.length;
35
+ state.devices = state.devices.filter(d => d.apns_token !== token || d.pubkey === pubkey);
36
+ if (state.devices.length !== before) markDirty();
37
+ const existing = state.devices.find(d => d.apns_token === token && d.pubkey === pubkey);
38
+ const muted = Array.isArray(b.muted_projects) ? b.muted_projects : b.mutedProjects;
39
+ const device = {
40
+ pubkey,
41
+ apns_token: token,
42
+ env,
43
+ bundle_id: String(b.bundleId || b.bundle_id || process.env.APNS_BUNDLE_ID || "ai.trantor.companion").slice(0, 200),
44
+ name: String(b.name || auth.identity.name || "").slice(0, 120),
45
+ created: existing?.created || Date.now(),
46
+ last_ok: existing?.last_ok || 0,
47
+ muted_projects: Array.isArray(muted)
48
+ ? [...new Set(muted.map(p => canon(String(p).slice(0, 80))).filter(Boolean))].slice(0, 100)
49
+ : (existing?.muted_projects || []),
50
+ };
51
+ if (existing) Object.assign(existing, device);
52
+ else state.devices.push(device);
53
+ markDirty();
54
+ return json(res, 200, { ok: true, device: existing || device });
55
+ }
@@ -7,7 +7,7 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
7
7
  const {
8
8
  state, body, json, stripNulText, crossProjectGuard, touch, pushToStreams,
9
9
  appendEvent, appendCardEvent, appendTaskLog, markDelivered, contractsFor, canUseInboxSession, inboxWindow,
10
- deliverable, inboxReadable, inboxResponse, filterReadable, streams, UI,
10
+ deliverable, inboxReadable, inboxResponse, filterReadable, canRead, streams, UI,
11
11
  AUTH_MODE, persistHealth, duty, now, markDirty, assertNoSecrets, PEER_TTL_MS,
12
12
  CONTRACT_WINDOW_MS,
13
13
  } = ctx;
@@ -143,7 +143,13 @@ export async function routeMessages({ req, res, q, P, auth, ctx }) {
143
143
  touch(session, q.status, undefined, undefined, auth);
144
144
  // events=1 opts this stream into the unified log as NAMED "ev" frames (see pushEventToStreams).
145
145
  // Existing consumers omit it and keep receiving bus messages on the default channel only.
146
- const entry = { session, res, events: q.events === "1" };
146
+ // #12112: a stream carries only what its signer may read, by the same checks /inbox and /events
147
+ // apply, so a key scoped to one project never sees another project's messages or events live.
148
+ const entry = {
149
+ session, res, events: q.events === "1",
150
+ readsMessage: m => inboxReadable(auth, m, session),
151
+ readsEvent: ev => canRead(auth, ev.project || ""),
152
+ };
147
153
  streams.push(entry);
148
154
  const ka = setInterval(() => {
149
155
  if (res.writableEnded || res.destroyed) return;
package/hub/store.mjs CHANGED
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
6
6
 
7
7
  export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
8
8
  function emptyState() {
9
- return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
9
+ return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, devices: [], inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
10
10
  }
11
11
 
12
12
  const CARD_LOG_MAX = 40;
@@ -107,6 +107,7 @@ function normalizeState(loaded = {}) {
107
107
  s.subagentCostReset = !!loaded.subagentCostReset;
108
108
  s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
109
109
  s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
110
+ s.devices = Array.isArray(loaded.devices) ? loaded.devices : [];
110
111
  s.inviteTokens = loaded.inviteTokens && typeof loaded.inviteTokens === "object" ? loaded.inviteTokens : {};
111
112
  s.instances = loaded.instances && typeof loaded.instances === "object" ? loaded.instances : {};
112
113
  s.focus = loaded.focus && typeof loaded.focus === "object" ? loaded.focus : {};
package/hub.mjs CHANGED
@@ -17,6 +17,8 @@ import { routeAdmin } from "./hub/routes/admin.mjs";
17
17
  import { routeCards } from "./hub/routes/cards.mjs";
18
18
  import { routeInsights } from "./hub/routes/insights.mjs";
19
19
  import { routeMessages } from "./hub/routes/messages.mjs";
20
+ import { routeDevices } from "./hub/routes/devices.mjs";
21
+ import { createNotifier } from "./hub/notifier.mjs";
20
22
 
21
23
  const PORT = Number(process.env.RELAY_PORT || 4477);
22
24
  const HOST = process.env.RELAY_HOST || "127.0.0.1";
@@ -63,7 +65,8 @@ const authRuntime = createAuthRuntime({
63
65
  state: store.state, markDirty: store.markDirty, AUTH_MODE, SUPERSEDE_GRACE_MS,
64
66
  LOOPBACK_BIND, ENROLL_MODE,
65
67
  });
66
- const events = createEventRuntime({ state: store.state, markDirty: store.markDirty, AUTH_MODE, ONLINE_MS, canon: authRuntime.canon });
68
+ const notifier = createNotifier({ state: store.state, markDirty: store.markDirty, canRead: authRuntime.canRead });
69
+ const events = createEventRuntime({ state: store.state, markDirty: store.markDirty, AUTH_MODE, ONLINE_MS, canon: authRuntime.canon, notifier });
67
70
  runStoreMigrations({ ...store, subFp: authRuntime.subFp });
68
71
  if (process.argv.includes("--smoke")) {
69
72
  // The smoke exists to refuse a restart that would boot into a broken store: a configured pg
@@ -122,7 +125,7 @@ const context = {
122
125
  hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
123
126
  REAP_GRACE_MS, CONTRACT_WINDOW_MS,
124
127
  };
125
- const routes = [routeAdmin, routeCards, routeInsights, routeMessages];
128
+ const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages];
126
129
 
127
130
  const server = http.createServer(async (req, res) => {
128
131
  const u = new URL(req.url, "http://x");
@@ -42,6 +42,19 @@ CREATE TABLE IF NOT EXISTS identities (
42
42
  ALTER TABLE identities DROP CONSTRAINT IF EXISTS identities_kind_check;
43
43
  ALTER TABLE identities ADD CONSTRAINT identities_kind_check CHECK (kind IN (${IDENTITY_KINDS_SQL}));
44
44
 
45
+ CREATE TABLE IF NOT EXISTS devices (
46
+ org_id TEXT NOT NULL,
47
+ pubkey TEXT NOT NULL,
48
+ apns_token TEXT NOT NULL,
49
+ env TEXT NOT NULL CHECK (env IN ('sandbox','production')),
50
+ bundle_id TEXT NOT NULL,
51
+ name TEXT NOT NULL DEFAULT '',
52
+ created BIGINT NOT NULL,
53
+ last_ok BIGINT NOT NULL DEFAULT 0,
54
+ muted_projects JSONB NOT NULL DEFAULT '[]'::jsonb,
55
+ PRIMARY KEY (org_id, apns_token)
56
+ );
57
+
45
58
 
46
59
  -- THE LOG. Append-only, never updated, never deleted except by retention. Everything else derives.
47
60
  CREATE TABLE IF NOT EXISTS events (
package/lib/store-pg.mjs CHANGED
@@ -139,6 +139,19 @@ function identityFromRow(row) {
139
139
  };
140
140
  }
141
141
 
142
+ function deviceFromRow(row) {
143
+ return {
144
+ pubkey: row.pubkey,
145
+ apns_token: row.apns_token,
146
+ env: row.env,
147
+ bundle_id: row.bundle_id,
148
+ name: row.name || "",
149
+ created: Number(row.created || 0),
150
+ last_ok: Number(row.last_ok || 0),
151
+ muted_projects: Array.isArray(row.muted_projects) ? row.muted_projects : [],
152
+ };
153
+ }
154
+
142
155
  // The kv projection of hub state — shared by saveSnapshot and saveDelta so the two can never drift.
143
156
  function kvFromState(state) {
144
157
  return {
@@ -501,6 +514,14 @@ export class PgStore {
501
514
  "DELETE FROM peers WHERE org_id=$1 AND NOT (session = ANY($2::text[]))",
502
515
  [orgId, Object.keys(state.peers || {})],
503
516
  );
517
+ await c.query("DELETE FROM devices WHERE org_id=$1", [orgId]);
518
+ for (const d of state.devices || []) {
519
+ await c.query(
520
+ `INSERT INTO devices(org_id, pubkey, apns_token, env, bundle_id, name, created, last_ok, muted_projects)
521
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)`,
522
+ [orgId, d.pubkey, d.apns_token, d.env, d.bundle_id, d.name || "", ms(d.created, 0), Number(d.last_ok || 0), JSON.stringify(d.muted_projects || [])],
523
+ );
524
+ }
504
525
  for (const id of Object.values(state.identities || {})) {
505
526
  await c.query(
506
527
  `INSERT INTO identities(pubkey, org_id, name, kind, scopes, enrolled_by, created_at, revoked_at)
@@ -548,12 +569,13 @@ export class PgStore {
548
569
  p => p.session,
549
570
  );
550
571
  const identities = diffById(Object.values(prev.identities || {}), Object.values(next.identities || {}), i => i.pubkey);
572
+ const devices = diffById(prev.devices, next.devices, d => d.apns_token);
551
573
  const prevKv = kvFromState(prev), nextKv = kvFromState(next);
552
574
  const kvChanged = KV_KEYS.filter(k => JSON.stringify(prevKv[k] ?? null) !== JSON.stringify(nextKv[k] ?? null));
553
575
 
554
576
  const changed = tasks.upserts.length + tasks.deletes.length + events.upserts.length + events.deletes.length
555
577
  + messages.upserts.length + messages.deletes.length + peers.upserts.length + peers.deletes.length
556
- + identities.upserts.length + kvChanged.length;
578
+ + identities.upserts.length + devices.upserts.length + devices.deletes.length + kvChanged.length;
557
579
  if (!changed) return 0;
558
580
 
559
581
  const c = await this.pool.connect();
@@ -618,6 +640,17 @@ export class PgStore {
618
640
  );
619
641
  }
620
642
  if (peers.deletes.length) await c.query("DELETE FROM peers WHERE org_id=$1 AND session = ANY($2::text[])", [orgId, peers.deletes]);
643
+ for (const d of devices.upserts) {
644
+ await c.query(
645
+ `INSERT INTO devices(org_id, pubkey, apns_token, env, bundle_id, name, created, last_ok, muted_projects)
646
+ VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb)
647
+ ON CONFLICT(org_id, apns_token) DO UPDATE SET pubkey=EXCLUDED.pubkey, env=EXCLUDED.env,
648
+ bundle_id=EXCLUDED.bundle_id, name=EXCLUDED.name, created=EXCLUDED.created,
649
+ last_ok=EXCLUDED.last_ok, muted_projects=EXCLUDED.muted_projects`,
650
+ [orgId, d.pubkey, d.apns_token, d.env, d.bundle_id, d.name || "", ms(d.created, 0), Number(d.last_ok || 0), JSON.stringify(d.muted_projects || [])],
651
+ );
652
+ }
653
+ if (devices.deletes.length) await c.query("DELETE FROM devices WHERE org_id=$1 AND apns_token = ANY($2::text[])", [orgId, devices.deletes]);
621
654
  for (const id of identities.upserts) {
622
655
  await c.query(
623
656
  `INSERT INTO identities(pubkey, org_id, name, kind, scopes, enrolled_by, created_at, revoked_at)
@@ -681,12 +714,13 @@ export class PgStore {
681
714
  }
682
715
 
683
716
  async loadSnapshot(orgId) {
684
- const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows, eventMaxRow] = await Promise.all([
717
+ const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, devicesRows, kvRows, eventMaxRow] = await Promise.all([
685
718
  this.pool.query("SELECT * FROM tasks WHERE org_id=$1 ORDER BY id ASC", [orgId]),
686
719
  this.pool.query("SELECT * FROM peers WHERE org_id=$1 ORDER BY session ASC", [orgId]),
687
720
  this.pool.query("SELECT * FROM events WHERE org_id=$1 ORDER BY id ASC", [orgId]),
688
721
  this.pool.query("SELECT * FROM messages WHERE org_id=$1 ORDER BY id ASC", [orgId]),
689
722
  this.pool.query("SELECT * FROM identities WHERE org_id=$1 ORDER BY created_at ASC", [orgId]),
723
+ this.pool.query("SELECT * FROM devices WHERE org_id=$1 ORDER BY created ASC", [orgId]),
690
724
  this.pool.query("SELECT key, value FROM kv WHERE org_id=$1", [orgId]),
691
725
  // The authoritative high-water mark for event ids. Deriving it from the loaded array is what
692
726
  // broke: a clobbered tail reported a lower id than the table actually held. Ask the table.
@@ -730,6 +764,7 @@ export class PgStore {
730
764
  subagentCostReset: !!kv.subagentCostReset,
731
765
  handoffLog: Array.isArray(kv.handoffLog) ? kv.handoffLog : [],
732
766
  identities,
767
+ devices: devicesRows.rows.map(deviceFromRow),
733
768
  inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
734
769
  instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
735
770
  balancesByMachine: meta.balancesByMachine && typeof meta.balancesByMachine === "object" ? meta.balancesByMachine : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.109",
3
+ "version": "0.18.110",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"