trantor 0.18.110 → 0.18.112
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/crew/codex-relay.mjs +8 -0
- package/bin/crew-runner.mjs +5 -3
- package/bin/keys.mjs +10 -3
- package/bin/pair.mjs +3 -1
- package/hub/auth.mjs +9 -2
- package/hub/notifier.mjs +22 -3
- package/hub/routes/session.mjs +268 -0
- package/hub/store.mjs +5 -1
- package/hub.mjs +6 -1
- package/lib/identity.mjs +25 -17
- package/lib/key-prune.mjs +30 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.112",
|
|
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": {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Codex's MCP env table overrides ambient runner variables and may pin another seat.
|
|
2
|
+
export function codexRelayFlags({ persona, session, project, hub }) {
|
|
3
|
+
const env = { RELAY_AGENT: persona, RELAY_SESSION: session, RELAY_PROJECT: project, RELAY_URL: hub };
|
|
4
|
+
return Object.entries(env).map(([key, value]) => {
|
|
5
|
+
const setting = `mcp_servers.relay.env.${key}=${JSON.stringify(value)}`;
|
|
6
|
+
return ` -c '${setting.replaceAll("'", "'\"'\"'")}'`;
|
|
7
|
+
}).join("");
|
|
8
|
+
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -48,6 +48,7 @@ import { costLine } from "../lib/state/cost.mjs";
|
|
|
48
48
|
import { resolveStateFlags, stateFeatureOn } from "../lib/state/flags.mjs";
|
|
49
49
|
|
|
50
50
|
import { awaitSwapRelease } from "./crew/stage.mjs";
|
|
51
|
+
import { codexRelayFlags } from "./crew/codex-relay.mjs";
|
|
51
52
|
|
|
52
53
|
const releaseState = process.env.CREW_RELEASE_STATE ? JSON.parse(process.env.CREW_RELEASE_STATE) : null;
|
|
53
54
|
delete process.env.CREW_RELEASE_STATE;
|
|
@@ -243,8 +244,8 @@ let MODEL = process.env.CREW_MODEL || "";
|
|
|
243
244
|
// ZAI-coding-plan models into deepseek/…). `scrooge route` returns qualified ids.
|
|
244
245
|
if (MODEL && !MODEL.includes("/") && AGENT === "deepseek") MODEL = `deepseek/${MODEL}`;
|
|
245
246
|
const CLI = {
|
|
246
|
-
codex: { first: `codex exec{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
247
|
-
next: `codex exec resume --last{M} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
247
|
+
codex: { first: `codex exec{M}{R} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`,
|
|
248
|
+
next: `codex exec resume --last{M}{R} --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox "$(cat {P})" < /dev/null`, mflag: " -m " },
|
|
248
249
|
gemini: { first: `gemini --yolo{M} -p "$(cat {P})"`,
|
|
249
250
|
next: `gemini --yolo{M} -r latest -p "$(cat {P})"`, mflag: " -m " },
|
|
250
251
|
// kimi-code (successor to kimi-cli) has no --print (-p alone is non-interactive), REJECTS
|
|
@@ -863,7 +864,8 @@ async function runTurn(prompt, isFirst, trigger = "kickoff", opts = {}) {
|
|
|
863
864
|
const turnEffort = effortForTurn({ agent: DRIVER, model: MODEL, launchEffort: EFFORT, cardDifficulty });
|
|
864
865
|
const mfrag = (MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "") + turnEffort.flag;
|
|
865
866
|
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid).replaceAll("{DIR}", TURN_DIR)
|
|
866
|
-
.replaceAll("{S}", STATE_SCHEMA_FILE)
|
|
867
|
+
.replaceAll("{S}", STATE_SCHEMA_FILE)
|
|
868
|
+
.replaceAll("{R}", () => codexRelayFlags({ persona: PERSONA, session: SESSION, project: PROJ, hub: HUB }));
|
|
867
869
|
// PRECEDENCE: each file is PREPENDED, so the list is iterated in written order, highest priority
|
|
868
870
|
// first, and the CREW layer (~/.agent-bus/.env) wins. test-crew-env.mjs runs the real shell.
|
|
869
871
|
const envs = [join(homedir(), ".agent-bus", ".env"), cli.env].filter(f => f && existsSync(f));
|
package/bin/keys.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { load, migrateKeysToKeychain, keyLocationReport, IDENTITY_SERVICE } from "../lib/identity.mjs";
|
|
10
10
|
import { sfetch } from "../lib/signed-fetch.mjs";
|
|
11
|
-
import { pruneTestKeys } from "../lib/key-prune.mjs";
|
|
11
|
+
import { pruneTestKeys, pruneStaleTmp } from "../lib/key-prune.mjs";
|
|
12
12
|
|
|
13
13
|
const args = process.argv.slice(2);
|
|
14
14
|
const verb = args[0] || "status";
|
|
@@ -65,6 +65,10 @@ async function prune() {
|
|
|
65
65
|
const idleDays = at < 0 ? 7 : Number(args[at + 1]);
|
|
66
66
|
if (!Number.isFinite(idleDays) || idleDays <= 0) throw new Error("--idle-days must be a positive number");
|
|
67
67
|
if (args.includes("--apply") && args.includes("--dry-run")) throw new Error("choose --apply or --dry-run");
|
|
68
|
+
const apply = args.includes("--apply");
|
|
69
|
+
// The tmp sweep is local (a stranded atomic-write temp is not a key and needs no hub) and runs
|
|
70
|
+
// first, so it works even when the hub is unreachable; --apply gates it like the rest of prune.
|
|
71
|
+
const tmp = pruneStaleTmp({ apply });
|
|
68
72
|
const bus = process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
69
73
|
let config = {};
|
|
70
74
|
try { config = JSON.parse(readFileSync(join(bus, "config.json"), "utf8")); }
|
|
@@ -79,8 +83,11 @@ async function prune() {
|
|
|
79
83
|
snapshots.push(await response.json());
|
|
80
84
|
} catch (error) { throw new Error(`cannot check hub ${hub}: ${error.message}; no keys pruned`); }
|
|
81
85
|
}
|
|
82
|
-
const result = pruneTestKeys({ snapshots, idleDays, apply
|
|
83
|
-
if (JSON_MODE) { console.log(JSON.stringify(result, null, 2)); return; }
|
|
86
|
+
const result = pruneTestKeys({ snapshots, idleDays, apply });
|
|
87
|
+
if (JSON_MODE) { console.log(JSON.stringify({ ...result, tmp }, null, 2)); return; }
|
|
88
|
+
const tmpDid = tmp.dryRun ? "dry run — would remove" : "removed";
|
|
89
|
+
console.log(`${tmpDid} ${tmp.removed.length} stale key-write tmp file(s) older than 1h${tmp.dryRun && tmp.removed.length ? "; use --apply to remove them" : ""}`);
|
|
90
|
+
for (const f of tmp.removed) console.log(` ${f}`);
|
|
84
91
|
console.log(`${result.dryRun ? "dry run — would remove" : "removed"} ${result.removed.length} test key file(s), idle over ${idleDays} days`);
|
|
85
92
|
for (const name of result.removed) console.log(` ${name}`);
|
|
86
93
|
console.log(`kept ${result.kept.length} key file(s)${result.dryRun ? "; use --apply to remove the listed files" : ""}`);
|
package/bin/pair.mjs
CHANGED
|
@@ -9,12 +9,14 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
|
|
10
10
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
11
|
const args = process.argv.slice(2);
|
|
12
|
+
const USAGE = "usage: trantor pair [--role owner] [--sim] [--json]";
|
|
13
|
+
if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
|
|
12
14
|
const asJson = args.includes("--json");
|
|
13
15
|
const openSim = args.includes("--sim");
|
|
14
16
|
const roleIdx = args.indexOf("--role");
|
|
15
17
|
const role = roleIdx >= 0 ? (args[roleIdx + 1] || "") : "approver";
|
|
16
18
|
if (!role || role.startsWith("--")) {
|
|
17
|
-
console.error(
|
|
19
|
+
console.error(USAGE);
|
|
18
20
|
process.exit(1);
|
|
19
21
|
}
|
|
20
22
|
|
package/hub/auth.mjs
CHANGED
|
@@ -53,9 +53,9 @@ 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"]);
|
|
56
|
+
const OWNER_ENDPOINTS = new Set(["/project/delete", "/sweep", "/reconcile", "/invite", "/import", "/policy", "/session/prompts", "/session/prompt/claim", "/session/prompt/ack"]);
|
|
57
57
|
const DEVICE_ENDPOINTS = new Set(["/device/register", "/device/unregister", "/devices"]);
|
|
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 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", "/session/chat", "/session/prompt"]);
|
|
59
59
|
const roleRank = { read: 1, write: 2, approver: 3, owner: 4 };
|
|
60
60
|
const hasAuthHeaders = (req) => AUTH_HEADERS.some(h => !!req.headers[h]);
|
|
61
61
|
const authPath = (u) => `${u.pathname}${u.search || ""}`;
|
|
@@ -116,6 +116,13 @@ function projectFromRequest(P, q, b) {
|
|
|
116
116
|
const p = state.proposals.find(x => x.id === Number(b?.id ?? q?.id));
|
|
117
117
|
return p?.project || "";
|
|
118
118
|
}
|
|
119
|
+
// claim/ack and the single-prompt GET carry only an id — authorization runs against the
|
|
120
|
+
// PROMPT's project, or a project-scoped key could claim any project's queued prompt (#12129).
|
|
121
|
+
if (P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack") {
|
|
122
|
+
const pr = (state.sessionPrompts || []).find(x => x.id === Number(b?.id ?? q?.id));
|
|
123
|
+
if (pr) return pr.project || "";
|
|
124
|
+
if (P !== "/session/prompt") return "";
|
|
125
|
+
}
|
|
119
126
|
return canon(String(b?.project || q?.project || "").slice(0, 80));
|
|
120
127
|
}
|
|
121
128
|
// Cross-project actions require a declared link or the operator identity, even with wildcard scopes (#6228).
|
package/hub/notifier.mjs
CHANGED
|
@@ -9,6 +9,14 @@ const APNS_HOSTS = {
|
|
|
9
9
|
const JWT_REUSE_MS = 50 * 60 * 1000;
|
|
10
10
|
const text = (value, max = 300) => String(value || "").slice(0, max);
|
|
11
11
|
|
|
12
|
+
// #12137: ask alerts are keyed by a tool id string, but the APNs payload's id field is numeric —
|
|
13
|
+
// a stable FNV-1a hash bridges the two, and the toolId string rides along so the phone can match.
|
|
14
|
+
function hashId(s) {
|
|
15
|
+
let h = 0x811c9dc5;
|
|
16
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
17
|
+
return h >>> 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function apnsTransport({ host, path, headers, body }) {
|
|
13
21
|
return new Promise((resolve, reject) => {
|
|
14
22
|
const session = http2.connect(`https://${host}`);
|
|
@@ -40,6 +48,10 @@ function apnsTransport({ host, path, headers, body }) {
|
|
|
40
48
|
|
|
41
49
|
function clearFor(event) {
|
|
42
50
|
// Whatever ends the item clears the phone's alert: decided, resolved, expired or withdrawn.
|
|
51
|
+
if (event.type === "ask.answered" && event.toolId) {
|
|
52
|
+
const toolId = text(event.toolId, 200);
|
|
53
|
+
return { kind: "ask", id: hashId(toolId), toolId, collapse: `ask-${toolId}`, project: text(event.project, 80), silent: true };
|
|
54
|
+
}
|
|
43
55
|
const match = /^(hold|verify\.gate|proposal)\.(?:decided|resolved|closed|expired|withdrawn)$/.exec(event.type || "");
|
|
44
56
|
if (!match) return null;
|
|
45
57
|
const kind = match[1] === "verify.gate" ? "gate" : match[1];
|
|
@@ -62,6 +74,11 @@ function alertFor(event, state) {
|
|
|
62
74
|
return { ...base, kind: "proposal", id: Number(event.proposalId), category: "PROPOSAL",
|
|
63
75
|
title: `Proposal #${Number(event.proposalId)}`, body: text(event.scope) };
|
|
64
76
|
}
|
|
77
|
+
if (event.type === "ask.opened" && event.toolId) {
|
|
78
|
+
const toolId = text(event.toolId, 200);
|
|
79
|
+
return { ...base, kind: "ask", id: hashId(toolId), toolId, collapse: `ask-${toolId}`,
|
|
80
|
+
title: `${base.project} asks`, body: text(event.question) };
|
|
81
|
+
}
|
|
65
82
|
if (event.type === "message" && Number(event.msgId)) {
|
|
66
83
|
return { ...base, kind: "message", id: Number(event.msgId), direct: true,
|
|
67
84
|
title: `Message from ${text(event.by, 120)}`, body: text(event.text) };
|
|
@@ -116,9 +133,11 @@ export function createNotifier({ state, markDirty, canRead, transport = apnsTran
|
|
|
116
133
|
? { "content-available": 1 }
|
|
117
134
|
: { alert: { title: delivery.title, body: delivery.body }, sound: "default" };
|
|
118
135
|
if (delivery.category) aps.category = delivery.category;
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
const
|
|
136
|
+
const body = { aps, kind: delivery.kind, id: delivery.id, project: delivery.project };
|
|
137
|
+
if (delivery.toolId) body.toolId = delivery.toolId;
|
|
138
|
+
const payload = JSON.stringify(body);
|
|
139
|
+
// kind in the id: hold #5 and gate #5 are different alerts; an ask collapses on its tool id
|
|
140
|
+
const collapseId = delivery.collapse || `${delivery.kind}-${delivery.id}`;
|
|
122
141
|
try {
|
|
123
142
|
const result = await transport({
|
|
124
143
|
host: APNS_HOSTS[device.env] || APNS_HOSTS.production,
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// #12129: the hub is the meeting point between the desktop relay and the phone. The chat mirror
|
|
2
|
+
// is MEMORY-ONLY (a restart answers have:0, the desktop resends its window); the prompt queue
|
|
3
|
+
// lives in state so a restart never strands a queued prompt. Live frames ride pushEventToStreams
|
|
4
|
+
// (scope-filtered, never logged); only chat.prompt.queued/.delivered/.failed are audit-appended.
|
|
5
|
+
import { setInterval } from "node:timers";
|
|
6
|
+
|
|
7
|
+
const CHAT_CAP = 400; // turns kept per project, highest idx wins
|
|
8
|
+
const CHAT_PAGE = 200; // max turns per GET
|
|
9
|
+
const PROMPT_CAP = 200;
|
|
10
|
+
const PROMPT_TEXT_MAX = 8000;
|
|
11
|
+
const EVENT_TEXT_MAX = 500;
|
|
12
|
+
const PROMPT_TTL_MS = Number(process.env.RELAY_PROMPT_TTL_MS || 120000);
|
|
13
|
+
const PROMPT_SWEEP_MS = Number(process.env.RELAY_PROMPT_SWEEP_MS || 30000);
|
|
14
|
+
const EXPIRED_REASON = "No Mac is relaying this project. Is the Trantor app open?";
|
|
15
|
+
const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
|
|
16
|
+
const ANSWER_PICKS_MAX = 10;
|
|
17
|
+
const ANSWER_OTHER_MAX = 2000;
|
|
18
|
+
|
|
19
|
+
export function createSessionRuntime({ state, markDirty, appendEvent, pushEventToStreams, notifier }) {
|
|
20
|
+
const now = () => Date.now();
|
|
21
|
+
const mirrors = new Map(); // project -> { session, total, turns: Map(idx->turn), have, meta, updatedAt, host }
|
|
22
|
+
state.sessionPrompts ||= [];
|
|
23
|
+
state.sessionPromptSeq = Number(state.sessionPromptSeq || 0);
|
|
24
|
+
|
|
25
|
+
const findPrompt = (id) => state.sessionPrompts.find(p => p.id === Number(id));
|
|
26
|
+
|
|
27
|
+
// A prompt still queued past the TTL means no desktop relay claimed it. Expiry is lazy (on
|
|
28
|
+
// read) plus a sweep that is a no-op when nothing is queued, so an idle hub does no work.
|
|
29
|
+
function expireQueued() {
|
|
30
|
+
let changed = false;
|
|
31
|
+
for (const p of state.sessionPrompts) {
|
|
32
|
+
if (p.status !== "queued" || now() - p.ts <= PROMPT_TTL_MS) continue;
|
|
33
|
+
p.status = "expired"; p.outcome = "expired"; p.error = EXPIRED_REASON;
|
|
34
|
+
appendEvent("chat.prompt.failed", p.project, p.by, { promptId: p.id, outcome: "expired", error: EXPIRED_REASON });
|
|
35
|
+
changed = true;
|
|
36
|
+
}
|
|
37
|
+
if (changed) markDirty();
|
|
38
|
+
return changed;
|
|
39
|
+
}
|
|
40
|
+
const sweep = setInterval(() => {
|
|
41
|
+
if (state.sessionPrompts.some(p => p.status === "queued")) expireQueued();
|
|
42
|
+
}, PROMPT_SWEEP_MS);
|
|
43
|
+
sweep.unref?.();
|
|
44
|
+
|
|
45
|
+
function mergeChat(b, auth) {
|
|
46
|
+
const project = String(b.project || "").slice(0, 80);
|
|
47
|
+
const session = String(b.session || "").slice(0, 200);
|
|
48
|
+
if (!project) return { code: 400, body: { error: "project required" } };
|
|
49
|
+
if (!session) return { code: 400, body: { error: "session required" } };
|
|
50
|
+
let m = mirrors.get(project);
|
|
51
|
+
// A different session resets the mirror (handoff/compaction): the desktop then sends its
|
|
52
|
+
// whole window, and stale turns from the previous session must not survive the switch.
|
|
53
|
+
if (!m || m.session !== session) {
|
|
54
|
+
m = { session, total: 0, turns: new Map(), have: 0, meta: {}, updatedAt: 0, host: "" };
|
|
55
|
+
mirrors.set(project, m);
|
|
56
|
+
asked.delete(project); // a session switch orphans the old asks; its alerts go stale on the phone
|
|
57
|
+
}
|
|
58
|
+
// The last turn grows while the assistant writes, so the desktop resends from the lowest
|
|
59
|
+
// changed idx and the hub REPLACES by idx — re-sends are idempotent by construction.
|
|
60
|
+
for (const t of Array.isArray(b.turns) ? b.turns : []) {
|
|
61
|
+
const idx = Number(t?.idx);
|
|
62
|
+
if (!Number.isInteger(idx) || idx < 0) continue;
|
|
63
|
+
const clean = {
|
|
64
|
+
idx,
|
|
65
|
+
role: String(t.role || "").slice(0, 40),
|
|
66
|
+
blocks: Array.isArray(t.blocks) ? t.blocks : [],
|
|
67
|
+
};
|
|
68
|
+
if (t.queued) clean.queued = true;
|
|
69
|
+
m.turns.set(idx, clean);
|
|
70
|
+
if (idx + 1 > m.have) m.have = idx + 1;
|
|
71
|
+
}
|
|
72
|
+
if (m.turns.size > CHAT_CAP) {
|
|
73
|
+
const drop = [...m.turns.keys()].sort((a, b) => a - b).slice(0, m.turns.size - CHAT_CAP);
|
|
74
|
+
for (const k of drop) m.turns.delete(k);
|
|
75
|
+
}
|
|
76
|
+
m.total = Math.max(0, Number(b.total) || 0);
|
|
77
|
+
// Property access on a primitive is safe (undefined); only null/undefined would throw.
|
|
78
|
+
const meta = b.meta || {};
|
|
79
|
+
m.meta = {
|
|
80
|
+
working: !!meta.working,
|
|
81
|
+
model: String(meta.model || "").slice(0, 120),
|
|
82
|
+
context: meta.context ?? null,
|
|
83
|
+
};
|
|
84
|
+
m.updatedAt = now();
|
|
85
|
+
m.host = String(b.host || auth?.identity?.name || "").slice(0, 120);
|
|
86
|
+
pushEventToStreams({ ts: m.updatedAt, type: "chat.updated", project, total: m.total, working: m.meta.working });
|
|
87
|
+
scanAsks(m, project);
|
|
88
|
+
return { code: 200, body: { ok: true, have: m.have } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// #12137: an unanswered AskUserQuestion block in the mirror raises ONE push per tool id
|
|
92
|
+
// ("<project> asks: <question>"); when the desktop republishes the block with its answer, the
|
|
93
|
+
// phone's alert is silently cleared. Dedupe is in-memory like the mirror itself, and ask events
|
|
94
|
+
// never touch the audit log — the prompt queue already carries the durable record.
|
|
95
|
+
const asked = new Map(); // project -> Set(toolId) alerted and still unanswered
|
|
96
|
+
function askNotify(event) {
|
|
97
|
+
try { notifier?.notify(event)?.catch?.(() => {}); } catch {}
|
|
98
|
+
}
|
|
99
|
+
function firstQuestion(blk) {
|
|
100
|
+
const a = blk?.ask;
|
|
101
|
+
if (Array.isArray(a) && a.length) return String(a[0]?.question ?? a[0] ?? "").slice(0, 300);
|
|
102
|
+
return String(blk?.text || "").slice(0, 300);
|
|
103
|
+
}
|
|
104
|
+
function scanAsks(m, project) {
|
|
105
|
+
if (!notifier) return;
|
|
106
|
+
for (const t of m.turns.values()) {
|
|
107
|
+
for (const blk of t.blocks) {
|
|
108
|
+
if (!blk || (blk.kind !== "ask" && !blk.ask)) continue;
|
|
109
|
+
const toolId = String(blk.toolId || blk.tool_id || "").slice(0, 200);
|
|
110
|
+
if (!toolId) continue;
|
|
111
|
+
let seen = asked.get(project);
|
|
112
|
+
if (blk.answer) {
|
|
113
|
+
if (seen?.has(toolId)) {
|
|
114
|
+
seen.delete(toolId);
|
|
115
|
+
askNotify({ type: "ask.answered", project, by: m.host, toolId });
|
|
116
|
+
}
|
|
117
|
+
} else if (!seen?.has(toolId)) {
|
|
118
|
+
if (!seen) { seen = new Set(); asked.set(project, seen); }
|
|
119
|
+
seen.add(toolId);
|
|
120
|
+
askNotify({ type: "ask.opened", project, by: m.host, toolId, question: firstQuestion(blk) });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function route({ req, res, q, P, auth, ctx }) {
|
|
127
|
+
const isChat = P === "/session/chat";
|
|
128
|
+
const isPrompts = P === "/session/prompts";
|
|
129
|
+
const isPrompt = P === "/session/prompt" || P === "/session/prompt/claim" || P === "/session/prompt/ack";
|
|
130
|
+
const isAnswer = P === "/session/answer";
|
|
131
|
+
if (!isChat && !isPrompts && !isPrompt && !isAnswer) return false;
|
|
132
|
+
const { body, json, canon } = ctx;
|
|
133
|
+
|
|
134
|
+
// #12137: the phone's answer to an AskUserQuestion joins the SAME queue as a plain prompt,
|
|
135
|
+
// as kind "answer" — same claim, ack, expiry and audit trail; the desktop relay maps the
|
|
136
|
+
// picks to keystrokes and types them, or acks "stale" when the ask is gone.
|
|
137
|
+
if (req.method === "POST" && isAnswer) {
|
|
138
|
+
const b = await body(req);
|
|
139
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
140
|
+
const session = String(b.session || "").slice(0, 200);
|
|
141
|
+
const toolId = String(b.toolId || "").slice(0, 200);
|
|
142
|
+
if (!project) return json(res, 400, { error: "project required" });
|
|
143
|
+
if (!session) return json(res, 400, { error: "session required" });
|
|
144
|
+
if (!toolId) return json(res, 400, { error: "toolId required" });
|
|
145
|
+
const picks = (Array.isArray(b.picks) ? b.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
|
|
146
|
+
const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
|
|
147
|
+
if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
|
|
148
|
+
expireQueued();
|
|
149
|
+
state.sessionPromptSeq += 1;
|
|
150
|
+
const text = other.trim() ? other : `picks ${picks.join(", ")}`;
|
|
151
|
+
const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
|
|
152
|
+
text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
153
|
+
state.sessionPrompts.push(p);
|
|
154
|
+
while (state.sessionPrompts.length > PROMPT_CAP) {
|
|
155
|
+
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
156
|
+
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
157
|
+
}
|
|
158
|
+
markDirty();
|
|
159
|
+
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, kind: "answer", toolId, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
160
|
+
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
161
|
+
return json(res, 200, { id: p.id, status: "queued" });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === "POST" && isChat) {
|
|
165
|
+
const b = await body(req);
|
|
166
|
+
// the same canon the GET reads with, or a publish and a read could name two mirrors
|
|
167
|
+
b.project = canon(String(b.project || "").slice(0, 80));
|
|
168
|
+
const r = mergeChat(b, auth);
|
|
169
|
+
return json(res, r.code, r.body);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (req.method === "GET" && isChat) {
|
|
173
|
+
const project = canon(String(q.project || "").slice(0, 80));
|
|
174
|
+
const m = mirrors.get(project);
|
|
175
|
+
if (!m) return json(res, 200, { session: "", total: 0, turns: [], meta: {}, updatedAt: 0, host: "" });
|
|
176
|
+
const afterRaw = Number(q.after);
|
|
177
|
+
const after = Number.isFinite(afterRaw) ? afterRaw : -1;
|
|
178
|
+
const turns = [...m.turns.values()].filter(t => t.idx > after).sort((a, b) => a.idx - b.idx).slice(0, CHAT_PAGE);
|
|
179
|
+
return json(res, 200, { session: m.session, total: m.total, turns, meta: m.meta, updatedAt: m.updatedAt, host: m.host });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (req.method === "POST" && P === "/session/prompt") {
|
|
183
|
+
const b = await body(req);
|
|
184
|
+
const project = canon(String(b.project || "").slice(0, 80));
|
|
185
|
+
const text = String(b.text || "");
|
|
186
|
+
if (!project || !text.trim()) return json(res, 400, { error: "project and non-empty text required" });
|
|
187
|
+
if (text.length > PROMPT_TEXT_MAX) return json(res, 400, { error: `text must be <= ${PROMPT_TEXT_MAX} chars` });
|
|
188
|
+
expireQueued();
|
|
189
|
+
state.sessionPromptSeq += 1;
|
|
190
|
+
const p = { id: state.sessionPromptSeq, project, text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
|
|
191
|
+
state.sessionPrompts.push(p);
|
|
192
|
+
// Cap: evict oldest terminal rows first so live work is never the first casualty.
|
|
193
|
+
while (state.sessionPrompts.length > PROMPT_CAP) {
|
|
194
|
+
const ti = state.sessionPrompts.findIndex(x => x.status !== "queued" && x.status !== "claimed");
|
|
195
|
+
state.sessionPrompts.splice(ti >= 0 ? ti : 0, 1);
|
|
196
|
+
}
|
|
197
|
+
markDirty();
|
|
198
|
+
appendEvent("chat.prompt.queued", project, p.by, { promptId: p.id, text: text.slice(0, EVENT_TEXT_MAX) });
|
|
199
|
+
pushEventToStreams({ ts: p.ts, type: "chat.prompt", id: p.id, project });
|
|
200
|
+
return json(res, 200, { id: p.id, status: "queued" });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// The public view of a queue row: plain prompts are kind "prompt"; answers carry their
|
|
204
|
+
// session, toolId, picks and other so the claiming desktop has everything it needs.
|
|
205
|
+
const promptView = (p) => {
|
|
206
|
+
const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
|
|
207
|
+
if (p.kind === "answer") {
|
|
208
|
+
view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
|
|
209
|
+
if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
|
|
210
|
+
}
|
|
211
|
+
return view;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (req.method === "GET" && isPrompts) {
|
|
215
|
+
expireQueued();
|
|
216
|
+
const project = canon(String(q.project || "").slice(0, 80));
|
|
217
|
+
const status = String(q.status || "").slice(0, 40);
|
|
218
|
+
const prompts = state.sessionPrompts
|
|
219
|
+
.filter(p => (!project || p.project === project) && (!status || p.status === status))
|
|
220
|
+
.map(promptView);
|
|
221
|
+
return json(res, 200, { prompts });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (req.method === "POST" && P === "/session/prompt/claim") {
|
|
225
|
+
const b = await body(req);
|
|
226
|
+
expireQueued();
|
|
227
|
+
const p = findPrompt(b.id);
|
|
228
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
229
|
+
// The first claim wins; a second claim — even from the same host — gets 409.
|
|
230
|
+
if (p.status !== "queued") return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
231
|
+
p.status = "claimed"; p.host = String(b.host || "").slice(0, 120); p.claimedAt = now();
|
|
232
|
+
markDirty();
|
|
233
|
+
// The full text goes only to the claimer.
|
|
234
|
+
return json(res, 200, { ok: true, prompt: promptView(p) });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (req.method === "POST" && P === "/session/prompt/ack") {
|
|
238
|
+
const b = await body(req);
|
|
239
|
+
const p = findPrompt(b.id);
|
|
240
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
241
|
+
const outcome = String(b.outcome || "");
|
|
242
|
+
if (!ACK_OUTCOMES.has(outcome)) return json(res, 400, { error: `outcome must be one of: ${[...ACK_OUTCOMES].join(", ")}` });
|
|
243
|
+
if (p.status === "delivered" || p.status === "failed" || p.status === "expired") {
|
|
244
|
+
return json(res, 409, { error: `prompt already ${p.status}`, status: p.status });
|
|
245
|
+
}
|
|
246
|
+
const error = String(b.error || "").slice(0, EVENT_TEXT_MAX);
|
|
247
|
+
p.outcome = outcome; p.error = error; p.ackedAt = now();
|
|
248
|
+
p.status = outcome === "delivered" ? "delivered" : "failed";
|
|
249
|
+
markDirty();
|
|
250
|
+
const extra = { promptId: p.id, outcome };
|
|
251
|
+
if (error) extra.error = error;
|
|
252
|
+
appendEvent(outcome === "delivered" ? "chat.prompt.delivered" : "chat.prompt.failed", p.project,
|
|
253
|
+
String(auth?.identity?.name || ""), extra);
|
|
254
|
+
return json(res, 200, { ok: true, status: p.status });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (req.method === "GET" && P === "/session/prompt") {
|
|
258
|
+
expireQueued();
|
|
259
|
+
const p = findPrompt(q.id);
|
|
260
|
+
if (!p) return json(res, 404, { error: "prompt not found" });
|
|
261
|
+
return json(res, 200, { id: p.id, status: p.status, outcome: p.outcome || "", error: p.error || "" });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { route, expireQueued };
|
|
268
|
+
}
|
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: {}, devices: [], 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, sessionPrompts: [], sessionPromptSeq: 0 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const CARD_LOG_MAX = 40;
|
|
@@ -115,6 +115,10 @@ function normalizeState(loaded = {}) {
|
|
|
115
115
|
s.overseerState = loaded.overseerState || {};
|
|
116
116
|
s.dutySession = String(loaded.dutySession || "");
|
|
117
117
|
s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
|
|
118
|
+
// #12129: the phone's prompt queue survives a hub restart (JSON store); the id high-water mark
|
|
119
|
+
// is rebuilt from the rows so a reloaded queue never mints a colliding id.
|
|
120
|
+
s.sessionPrompts = Array.isArray(loaded.sessionPrompts) ? loaded.sessionPrompts : [];
|
|
121
|
+
s.sessionPromptSeq = Number(loaded.sessionPromptSeq || Math.max(0, ...s.sessionPrompts.map(p => Number(p.id) || 0))) || 0;
|
|
118
122
|
// The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
|
|
119
123
|
// otherwise from the largest id in the array — never from the array TAIL, which is exactly the
|
|
120
124
|
// assumption that let a clobbered id mint colliding event ids and kill the log.
|
package/hub.mjs
CHANGED
|
@@ -18,6 +18,7 @@ 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
20
|
import { routeDevices } from "./hub/routes/devices.mjs";
|
|
21
|
+
import { createSessionRuntime } from "./hub/routes/session.mjs";
|
|
21
22
|
import { createNotifier } from "./hub/notifier.mjs";
|
|
22
23
|
|
|
23
24
|
const PORT = Number(process.env.RELAY_PORT || 4477);
|
|
@@ -125,7 +126,11 @@ const context = {
|
|
|
125
126
|
hubSend: duty.hubSend, assertNoSecrets, UI, AUTH_MODE, ONLINE_MS, PEER_TTL_MS,
|
|
126
127
|
REAP_GRACE_MS, CONTRACT_WINDOW_MS,
|
|
127
128
|
};
|
|
128
|
-
const
|
|
129
|
+
const sessionChat = createSessionRuntime({
|
|
130
|
+
state: store.state, markDirty: store.markDirty,
|
|
131
|
+
appendEvent: events.appendEvent, pushEventToStreams: events.pushEventToStreams, notifier,
|
|
132
|
+
});
|
|
133
|
+
const routes = [routeDevices, routeAdmin, routeCards, routeInsights, routeMessages, sessionChat.route];
|
|
129
134
|
|
|
130
135
|
const server = http.createServer(async (req, res) => {
|
|
131
136
|
const u = new URL(req.url, "http://x");
|
package/lib/identity.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const HDR_INST = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const busDir = () => process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
|
|
31
|
-
const keysDir = () => join(busDir(), "keys");
|
|
31
|
+
export const keysDir = () => join(busDir(), "keys");
|
|
32
32
|
// A name is cosmetic, but it becomes a filename, so it gets sanitised twice over: dot-runs collapse
|
|
33
33
|
// FIRST (so no `..` survives even in principle), then anything outside the safe charset goes. Relying
|
|
34
34
|
// on "we stripped the separators" alone would make traversal one regex edit away.
|
|
@@ -187,17 +187,17 @@ export function loadOrCreate(name, kind = "agent") {
|
|
|
187
187
|
const f = keyPath(name);
|
|
188
188
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
189
189
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
190
|
+
try {
|
|
191
|
+
writeFileSync(tmp, JSON.stringify(id), { mode: 0o600 });
|
|
192
|
+
if (existsSync(f)) return load(name) || id; // someone beat us between check and write
|
|
193
|
+
renameSync(tmp, f);
|
|
194
|
+
chmodSync(f, 0o600);
|
|
195
|
+
processCache.set(cacheKey(String(name)), id);
|
|
196
|
+
return id;
|
|
197
|
+
} finally {
|
|
198
|
+
try { unlinkSync(tmp); } catch {} // unconditional: no early exit leaves a tmp holding a private key
|
|
194
199
|
}
|
|
195
|
-
renameSync(tmp, f);
|
|
196
|
-
chmodSync(f, 0o600);
|
|
197
|
-
processCache.set(cacheKey(String(name)), id);
|
|
198
|
-
return id;
|
|
199
200
|
} catch {
|
|
200
|
-
try { unlinkSync(tmp); } catch {}
|
|
201
201
|
return load(name) || id; // lost the race, or unwritable — never throw
|
|
202
202
|
}
|
|
203
203
|
}
|
|
@@ -220,8 +220,12 @@ export function listKeyNames() {
|
|
|
220
220
|
function writeKeyStub(f, id) {
|
|
221
221
|
const stub = { name: id.name, kind: id.kind, pubkey: id.pubkey, createdAt: id.createdAt, keychain: IDENTITY_SERVICE };
|
|
222
222
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
try {
|
|
224
|
+
writeFileSync(tmp, JSON.stringify(stub), { mode: 0o600 });
|
|
225
|
+
renameSync(tmp, f);
|
|
226
|
+
} finally {
|
|
227
|
+
try { unlinkSync(tmp); } catch {} // a failed rename must not strand its tmp
|
|
228
|
+
}
|
|
225
229
|
try { chmodSync(f, 0o600); } catch {}
|
|
226
230
|
}
|
|
227
231
|
|
|
@@ -353,11 +357,15 @@ export function loadOrCreateInstance(durableIdentity, instanceId) {
|
|
|
353
357
|
durablePubkey: durableIdentity.pubkey,
|
|
354
358
|
};
|
|
355
359
|
const tmp = `${f}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
360
|
+
try {
|
|
361
|
+
writeFileSync(tmp, JSON.stringify(inst), { mode: 0o600 });
|
|
362
|
+
if (existsSync(f)) { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return inst; } }
|
|
363
|
+
renameSync(tmp, f);
|
|
364
|
+
chmodSync(f, 0o600);
|
|
365
|
+
return inst;
|
|
366
|
+
} finally {
|
|
367
|
+
try { unlinkSync(tmp); } catch {} // the race loser's tmp still holds a private key — never strand it
|
|
368
|
+
}
|
|
361
369
|
} catch { return null; } // unwritable — caller falls back to durable
|
|
362
370
|
}
|
|
363
371
|
|
package/lib/key-prune.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { lstatSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { lstatSync, readFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { keyPath, listKeyNames, keysDir } from "./identity.mjs";
|
|
3
4
|
|
|
4
5
|
import { isTestIdentity } from "./test-identity-names.mjs";
|
|
5
6
|
export { isTestIdentity } from "./test-identity-names.mjs";
|
|
@@ -47,3 +48,30 @@ export function pruneTestKeys({ snapshots, idleDays = 7, apply = false, now = Da
|
|
|
47
48
|
}
|
|
48
49
|
return { dryRun: !apply, idleDays, removed, kept };
|
|
49
50
|
}
|
|
51
|
+
|
|
52
|
+
// An interrupted atomic key write strands `<name>.json.<pid>.<rand>.tmp` — and a tmp from a lost
|
|
53
|
+
// race holds a full PRIVATE key. Real keys always end in `.json`, so anything ending in `.tmp` in
|
|
54
|
+
// the keys tree is by construction an incomplete write; an hour past its mtime no live writer is
|
|
55
|
+
// still between write and rename, so it is safe to remove (--apply only, like the rest of prune).
|
|
56
|
+
export function pruneStaleTmp({ maxAgeMs = 3_600_000, apply = false, now = Date.now() } = {}) {
|
|
57
|
+
if (!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) throw new Error("tmp age must be a positive number");
|
|
58
|
+
const cutoff = now - maxAgeMs;
|
|
59
|
+
const removed = [], kept = [];
|
|
60
|
+
for (const dir of [keysDir(), join(keysDir(), "instances")]) {
|
|
61
|
+
let entries = [];
|
|
62
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.name.endsWith(".tmp")) continue; // a real key file never matches
|
|
65
|
+
const file = join(dir, entry.name);
|
|
66
|
+
let stat;
|
|
67
|
+
try { stat = lstatSync(file); } catch { continue; } // vanished mid-sweep
|
|
68
|
+
if (!stat.isFile() || stat.isSymbolicLink()) continue; // never follow a link out of the keys tree
|
|
69
|
+
if (stat.mtimeMs >= cutoff) { kept.push(file); continue; }
|
|
70
|
+
if (apply) {
|
|
71
|
+
try { unlinkSync(file); } catch { kept.push(file); continue; }
|
|
72
|
+
}
|
|
73
|
+
removed.push(file);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { dryRun: !apply, maxAgeMs, removed, kept };
|
|
77
|
+
}
|