trantor 0.17.99 → 0.18.1
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/README.md +9 -1
- package/bin/doctor.mjs +52 -0
- package/hub.mjs +38 -9
- package/lib/store-pg.mjs +27 -4
- package/mcp.mjs +3 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Trantor \u2014 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
|
@@ -151,7 +151,15 @@ Fix the `→` lines (each CLI's own sign-in happens once, in that CLI) and re-ru
|
|
|
151
151
|
until it's clean.
|
|
152
152
|
|
|
153
153
|
Provider API keys (e.g. `DEEPSEEK_API_KEY`) live in one file: **`~/.agent-bus/.env`** — the
|
|
154
|
-
crew runners source it automatically.
|
|
154
|
+
crew runners source it automatically, and it wins over anything Scrooge has.
|
|
155
|
+
|
|
156
|
+
That precedence is the point. Scrooge (the cheap-model router) keeps its own keys in
|
|
157
|
+
`~/.token-scrooge/.env`, and if the crew has no key of its own it falls through to Scrooge's. That
|
|
158
|
+
still works, but then one key authenticates both and your provider bill cannot tell them apart —
|
|
159
|
+
a crew seat and a batch of grunt summaries land on the same line item. Give the crew **separate
|
|
160
|
+
keys**, minted in the provider console rather than copied, and each shows up on its own line and
|
|
161
|
+
can be capped independently. `trantor doctor` reports which key each surface resolves to, masked,
|
|
162
|
+
under "provider keys".
|
|
155
163
|
|
|
156
164
|
## Your first build
|
|
157
165
|
|
package/bin/doctor.mjs
CHANGED
|
@@ -139,6 +139,58 @@ for (const c of CLIS) {
|
|
|
139
139
|
}
|
|
140
140
|
if (!installed) warn("no crew CLIs found", "install at least one of: codex, gemini, kimi, opencode — Trantor orchestrates whatever you have");
|
|
141
141
|
|
|
142
|
+
// ---- key attribution: WHICH key does each surface actually spend on? ------------------------
|
|
143
|
+
// Provider keys resolve through a LAYERED lookup and nothing ever showed which layer won. On
|
|
144
|
+
// 2026-08-25 a $14 DeepSeek day could not be explained: ~/.token-scrooge/.env held the only
|
|
145
|
+
// DEEPSEEK_API_KEY, so Scrooge's `dev-infra` key was ALSO authenticating every crew seat (the
|
|
146
|
+
// runner sources that file). Scrooge turned out to be 0.15% of the tokens on that key and the
|
|
147
|
+
// crew was the other 99.85%, but the bill could not say so — one key, two jobs, one line item.
|
|
148
|
+
//
|
|
149
|
+
// The layers, highest priority first — this MIRRORS bin/crew-runner.mjs, which sources
|
|
150
|
+
// ~/.agent-bus/.env last so it wins:
|
|
151
|
+
// 1. the process environment
|
|
152
|
+
// 2. ~/.agent-bus/.env — the CREW layer (seats: opencode/deepseek/openrouter/dsh)
|
|
153
|
+
// 3. ~/.token-scrooge/.env — the SCROOGE layer (cheap-model grunt routing)
|
|
154
|
+
section("provider keys (who spends on what)");
|
|
155
|
+
const KEY_VARS = ["DEEPSEEK_API_KEY", "OPENROUTER_API_KEY", "MOONSHOT_API_KEY", "ZAI_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY"];
|
|
156
|
+
const CREW_ENV = join(H, ".agent-bus", ".env");
|
|
157
|
+
const SCROOGE_ENV = join(H, ".token-scrooge", ".env");
|
|
158
|
+
const readEnvFile = (f) => {
|
|
159
|
+
const out = {};
|
|
160
|
+
try {
|
|
161
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
162
|
+
const m = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/);
|
|
163
|
+
if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
// Never print a key. The suffix is enough to match a line item in a provider console.
|
|
169
|
+
const mask = (v) => (!v ? "" : v.length <= 12 ? "****" : `${v.slice(0, 5)}…${v.slice(-4)}`);
|
|
170
|
+
const crewEnv = readEnvFile(CREW_ENV), scroogeEnv = readEnvFile(SCROOGE_ENV);
|
|
171
|
+
let anyKey = false, shared = 0; const sharedVars = [];
|
|
172
|
+
for (const v of KEY_VARS) {
|
|
173
|
+
const crew = process.env[v] || crewEnv[v] || "";
|
|
174
|
+
const scrooge = process.env[v] || scroogeEnv[v] || "";
|
|
175
|
+
if (!crew && !scrooge) continue;
|
|
176
|
+
anyKey = true;
|
|
177
|
+
const crewSrc = process.env[v] ? "process env" : crewEnv[v] ? "~/.agent-bus/.env (crew)" : scroogeEnv[v] ? "~/.token-scrooge/.env (FALLBACK)" : "none";
|
|
178
|
+
const crewKey = crew || scrooge;
|
|
179
|
+
const scroogeFileKey = scroogeEnv[v] || "";
|
|
180
|
+
if (crewKey && scroogeFileKey && crewKey === scroogeFileKey) {
|
|
181
|
+
shared++; sharedVars.push(v);
|
|
182
|
+
note(`${v}: crew + Scrooge share ONE key ${mask(crewKey)} — spend is indistinguishable on the bill`);
|
|
183
|
+
} else {
|
|
184
|
+
ok(`${v}: crew ${mask(crewKey)} via ${crewSrc}${scroogeFileKey && scroogeFileKey !== crewKey ? ` · scrooge ${mask(scroogeFileKey)} via ~/.token-scrooge/.env` : ""}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!anyKey) note("no provider API keys found in env, ~/.agent-bus/.env or ~/.token-scrooge/.env");
|
|
188
|
+
else if (!shared) ok("crew and Scrooge spend on separate keys — each shows up as its own line item");
|
|
189
|
+
else {
|
|
190
|
+
warn(`${shared} provider key(s) do double duty (${sharedVars.join(", ")}) — a spike on the bill cannot be attributed to the crew or to Scrooge`,
|
|
191
|
+
`mint a second key per provider and give the CREW its own, e.g.: echo 'DEEPSEEK_API_KEY=<new-crew-key>' >> ~/.agent-bus/.env (Scrooge keeps ~/.token-scrooge/.env; the runner sources ~/.agent-bus/.env last, so it wins)`);
|
|
192
|
+
}
|
|
193
|
+
|
|
142
194
|
// brain
|
|
143
195
|
section("the brain");
|
|
144
196
|
has("scrooge") || existsSync(join(H, ".local", "bin", "scrooge"))
|
package/hub.mjs
CHANGED
|
@@ -111,7 +111,7 @@ function scanTelemetry() {
|
|
|
111
111
|
// TIMELINE view are untouched; every NEW type is dotted ("message", "presence.online", …) and is
|
|
112
112
|
// filtered OUT of /history. Loads from the old `cardEvents` key when `events` is absent.
|
|
113
113
|
function emptyState() {
|
|
114
|
-
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: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {} };
|
|
114
|
+
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: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
const CARD_LOG_MAX = 40;
|
|
@@ -188,6 +188,14 @@ function normalizeState(loaded = {}) {
|
|
|
188
188
|
s.orgPolicy = loaded.orgPolicy && typeof loaded.orgPolicy === "object" ? loaded.orgPolicy : {};
|
|
189
189
|
s.dutySession = String(loaded.dutySession || "");
|
|
190
190
|
s.contractReap = loaded.contractReap && typeof loaded.contractReap === "object" ? loaded.contractReap : {};
|
|
191
|
+
// The event id high-water mark. Seeded from the store's own MAX(id) where it supplied one, and
|
|
192
|
+
// otherwise from the largest id in the array — never from the array TAIL, which is exactly the
|
|
193
|
+
// assumption that let a clobbered id mint colliding event ids and kill the log.
|
|
194
|
+
s.eventSeq = Math.max(
|
|
195
|
+
Number(loaded.eventSeq || 0),
|
|
196
|
+
...s.events.map(e => Number(e?.id) || 0),
|
|
197
|
+
0,
|
|
198
|
+
);
|
|
191
199
|
for (const [session, v] of Object.entries(loaded.peers || {})) {
|
|
192
200
|
// migrate old numeric form
|
|
193
201
|
s.peers[session] = typeof v === "number"
|
|
@@ -203,6 +211,11 @@ if (STORE_KIND === "pg" || STORE_KIND === "postgres") {
|
|
|
203
211
|
try {
|
|
204
212
|
const { createPgStore } = await import("./lib/store-pg.mjs");
|
|
205
213
|
durableStore = createPgStore({ url: PG_URL });
|
|
214
|
+
// An event insert that hits ON CONFLICT DO NOTHING is a LOST append, not a no-op. Silence here
|
|
215
|
+
// hid a dead log for 18 days. Never let it be quiet again.
|
|
216
|
+
durableStore.onDroppedEvent = ({ id, type }) => {
|
|
217
|
+
process.stderr.write(`[trantor] EVENT DROPPED: id ${id} (${type}) already exists — the append-only log is not appending. This is a bug, not routine.\n`);
|
|
218
|
+
};
|
|
206
219
|
await durableStore.init();
|
|
207
220
|
if (ORG_ID !== DEFAULT_ORG) await durableStore.createOrg({ id: ORG_ID, name: ORG_ID, ownerPubkey: "local-owner" });
|
|
208
221
|
state = normalizeState(await durableStore.loadSnapshot(ORG_ID));
|
|
@@ -1039,8 +1052,13 @@ const CARD_TYPES = new Set(["created", "moved", "updated"]);
|
|
|
1039
1052
|
const isCardEvent = e => CARD_TYPES.has(e?.type);
|
|
1040
1053
|
|
|
1041
1054
|
function appendEvent(type, project, by, extra = {}) {
|
|
1055
|
+
// The id comes from a monotonic high-water mark, NOT from the tail of the array. Trusting the tail
|
|
1056
|
+
// meant one bad id anywhere in the log made every future append collide, and ON CONFLICT DO NOTHING
|
|
1057
|
+
// then dropped them all without a word. `extra` is spread FIRST so a stray id in a payload can
|
|
1058
|
+
// never take over the event's own identity.
|
|
1042
1059
|
const last = state.events[state.events.length - 1];
|
|
1043
|
-
|
|
1060
|
+
state.eventSeq = Math.max(Number(state.eventSeq || 0), Number(last?.id) || 0) + 1;
|
|
1061
|
+
const ev = { ...extra, id: state.eventSeq, ts: now(), type, project: project || "", by: by || "" };
|
|
1044
1062
|
state.events.push(ev);
|
|
1045
1063
|
if (state.events.length > EVENT_CAP) state.events.splice(0, state.events.length - EVENT_CAP);
|
|
1046
1064
|
pushEventToStreams(ev);
|
|
@@ -1307,7 +1325,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1307
1325
|
}
|
|
1308
1326
|
for (const e of (Array.isArray(b.events) ? b.events : [])) {
|
|
1309
1327
|
const last = state.events[state.events.length - 1];
|
|
1310
|
-
|
|
1328
|
+
// Spread the incoming event FIRST, then stamp OUR id and project over it. The incoming `id`
|
|
1329
|
+
// belongs to the other hub's log and must not survive in any form: carried into the payload
|
|
1330
|
+
// it comes back on load and overwrites the real row id.
|
|
1331
|
+
state.eventSeq = Math.max(Number(state.eventSeq || 0), Number(last?.id) || 0) + 1;
|
|
1332
|
+
const ev = { ...e, id: state.eventSeq, project: proj };
|
|
1311
1333
|
if (ev.taskId != null && remap.has(Number(ev.taskId))) ev.taskId = remap.get(Number(ev.taskId));
|
|
1312
1334
|
state.events.push(ev); added.events++;
|
|
1313
1335
|
}
|
|
@@ -2426,13 +2448,20 @@ const server = http.createServer(async (req, res) => {
|
|
|
2426
2448
|
const windowMs = Math.max(60000, Number(q.windowMs || CONTRACT_WINDOW_MS));
|
|
2427
2449
|
const rawOverdue = q.overdueMs === undefined || q.overdueMs === "" ? null : Number(q.overdueMs);
|
|
2428
2450
|
const overdueMs = Number.isFinite(rawOverdue) ? Math.max(0, rawOverdue) : null;
|
|
2429
|
-
const
|
|
2430
|
-
const by = (d) =>
|
|
2431
|
-
//
|
|
2432
|
-
//
|
|
2451
|
+
const all = contractsFor(session, { project: String(q.project || ""), windowMs, overdueMs });
|
|
2452
|
+
const by = (d) => all.filter(c => c.disposition === d).length;
|
|
2453
|
+
// Abandoned contracts leave `contracts` entirely and ride in their own key.
|
|
2454
|
+
//
|
|
2455
|
+
// Not cosmetic. A session's hooks are PINNED at session start, so an older stop hook iterates
|
|
2456
|
+
// `contracts` with its own predicate and knows nothing about `disposition` — it kept blocking on
|
|
2457
|
+
// ghosts no matter what the hub called them. Keeping them in the array meant the fix only
|
|
2458
|
+
// reached sessions that restarted, and a live one nagged its operator every single turn.
|
|
2459
|
+
// Splitting them out fixes every running session the moment the hub redeploys, and the ledger
|
|
2460
|
+
// still shows what died via `abandonedContracts`.
|
|
2461
|
+
const out = all.filter(c => c.disposition !== "abandoned");
|
|
2433
2462
|
return json(res, 200, {
|
|
2434
|
-
session, contracts: out,
|
|
2435
|
-
open: out.filter(c => !c.answered
|
|
2463
|
+
session, contracts: out, abandonedContracts: all.filter(c => c.disposition === "abandoned"),
|
|
2464
|
+
open: out.filter(c => !c.answered).length,
|
|
2436
2465
|
waiting: by("waiting"), stalled: by("stalled"), abandoned: by("abandoned"), answered: by("answered"),
|
|
2437
2466
|
});
|
|
2438
2467
|
}
|
package/lib/store-pg.mjs
CHANGED
|
@@ -10,24 +10,34 @@ const ms = (v, fallback = Date.now()) => {
|
|
|
10
10
|
};
|
|
11
11
|
const num = (v) => (v == null ? v : Number(v));
|
|
12
12
|
|
|
13
|
+
const EVENT_COLUMN_KEYS = ["id", "ts", "type", "project", "by", "by_session", "taskId", "task_id", "payload"];
|
|
13
14
|
function stripEventPayload(evt = {}) {
|
|
14
15
|
const payload = { ...(evt.payload && typeof evt.payload === "object" ? evt.payload : {}) };
|
|
16
|
+
// The nested payload gets the same treatment as the top level. It used to be copied in wholesale,
|
|
17
|
+
// so a column key riding inside it (an `id` from an imported event) was stored and then clobbered
|
|
18
|
+
// the real column on the way back out.
|
|
19
|
+
for (const k of EVENT_COLUMN_KEYS) delete payload[k];
|
|
15
20
|
for (const [k, v] of Object.entries(evt)) {
|
|
16
|
-
if (
|
|
21
|
+
if (EVENT_COLUMN_KEYS.includes(k)) continue;
|
|
17
22
|
payload[k] = v;
|
|
18
23
|
}
|
|
19
24
|
return payload;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
// The COLUMNS are the truth; the payload is only the fields that have no column. Spreading payload
|
|
28
|
+
// LAST let a stored `id` overwrite the row id, and that silently killed the append-only log for 18
|
|
29
|
+
// days: 1198 rows (8706..9903 on the production hub) carried a shadow payload id, so on load the
|
|
30
|
+
// array tail reported id 4655 instead of 9903, appendEvent then minted 4656 and every insert after
|
|
31
|
+
// that hit ON CONFLICT (id) DO NOTHING. Payload first, columns after: a column can never be clobbered.
|
|
22
32
|
function eventFromRow(row) {
|
|
23
33
|
const payload = row.payload && typeof row.payload === "object" ? row.payload : {};
|
|
24
34
|
const ev = {
|
|
35
|
+
...payload,
|
|
25
36
|
id: Number(row.id),
|
|
26
37
|
ts: Number(row.ts),
|
|
27
38
|
type: row.type,
|
|
28
39
|
project: row.project || "",
|
|
29
40
|
by: row.by_session || "",
|
|
30
|
-
...payload,
|
|
31
41
|
};
|
|
32
42
|
if (row.task_id != null) ev.taskId = Number(row.task_id);
|
|
33
43
|
return ev;
|
|
@@ -129,6 +139,7 @@ function kvFromState(state) {
|
|
|
129
139
|
verifyGateSeq: Number(state.verifyGateSeq || 0),
|
|
130
140
|
proposalSeq: Number(state.proposalSeq || 0),
|
|
131
141
|
cardEventsBackfilled: !!state.cardEventsBackfilled,
|
|
142
|
+
eventSeq: Number(state.eventSeq || 0),
|
|
132
143
|
inviteTokens: state.inviteTokens || {},
|
|
133
144
|
instances: state.instances || {},
|
|
134
145
|
},
|
|
@@ -542,12 +553,18 @@ export class PgStore {
|
|
|
542
553
|
}
|
|
543
554
|
if (tasks.deletes.length) await c.query("DELETE FROM tasks WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, tasks.deletes]);
|
|
544
555
|
for (const e of events.upserts) {
|
|
545
|
-
await c.query(
|
|
556
|
+
const r = await c.query(
|
|
546
557
|
`INSERT INTO events(id, org_id, ts, type, project, by_session, task_id, payload)
|
|
547
558
|
VALUES($1,$2,$3,$4,$5,$6,$7,$8::jsonb)
|
|
548
559
|
ON CONFLICT (id) DO NOTHING`,
|
|
549
560
|
[Number(e.id), orgId, ms(e.ts), e.type || "", e.project || "", e.by || "", e.taskId ?? null, JSON.stringify(stripEventPayload(e))],
|
|
550
561
|
);
|
|
562
|
+
// DO NOTHING protects a foreign row, but it also swallows a REAL append when the id collides.
|
|
563
|
+
// That is what hid the dead log for 18 days: every event was dropped and nothing said a word.
|
|
564
|
+
// A conflict here is never routine, so say so.
|
|
565
|
+
if (r?.rowCount === 0) {
|
|
566
|
+
this.onDroppedEvent?.({ id: Number(e.id), type: e.type || "", orgId });
|
|
567
|
+
}
|
|
551
568
|
}
|
|
552
569
|
if (events.deletes.length) await c.query("DELETE FROM events WHERE org_id=$1 AND id = ANY($2::bigint[])", [orgId, events.deletes]);
|
|
553
570
|
if (events.upserts.length) await c.query("SELECT setval(pg_get_serial_sequence('events','id'), GREATEST((SELECT COALESCE(MAX(id),0) FROM events), 1), true)");
|
|
@@ -634,13 +651,18 @@ export class PgStore {
|
|
|
634
651
|
}
|
|
635
652
|
|
|
636
653
|
async loadSnapshot(orgId) {
|
|
637
|
-
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows] = await Promise.all([
|
|
654
|
+
const [tasks, peersRows, eventsRows, messagesRows, identitiesRows, kvRows, eventMaxRow] = await Promise.all([
|
|
638
655
|
this.pool.query("SELECT * FROM tasks WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
639
656
|
this.pool.query("SELECT * FROM peers WHERE org_id=$1 ORDER BY session ASC", [orgId]),
|
|
640
657
|
this.pool.query("SELECT * FROM events WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
641
658
|
this.pool.query("SELECT * FROM messages WHERE org_id=$1 ORDER BY id ASC", [orgId]),
|
|
642
659
|
this.pool.query("SELECT * FROM identities WHERE org_id=$1 ORDER BY created_at ASC", [orgId]),
|
|
643
660
|
this.pool.query("SELECT key, value FROM kv WHERE org_id=$1", [orgId]),
|
|
661
|
+
// The authoritative high-water mark for event ids. Deriving it from the loaded array is what
|
|
662
|
+
// broke: a clobbered tail reported a lower id than the table actually held. Ask the table.
|
|
663
|
+
// Deliberately NOT scoped to org: the events primary key is (id) alone, so the id space is
|
|
664
|
+
// global and a per-org max would still collide across orgs.
|
|
665
|
+
this.pool.query("SELECT COALESCE(MAX(id),0) AS max_id FROM events"),
|
|
644
666
|
]);
|
|
645
667
|
const kv = Object.fromEntries(kvRows.rows.map(r => [r.key, r.value]));
|
|
646
668
|
const peers = {};
|
|
@@ -683,6 +705,7 @@ export class PgStore {
|
|
|
683
705
|
focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
|
|
684
706
|
orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
|
|
685
707
|
contractReap: kv.contractReap && typeof kv.contractReap === "object" ? kv.contractReap : {},
|
|
708
|
+
eventSeq: Math.max(Number(eventMaxRow?.rows?.[0]?.max_id || 0), Number(meta.eventSeq || 0)),
|
|
686
709
|
};
|
|
687
710
|
}
|
|
688
711
|
}
|
package/mcp.mjs
CHANGED
|
@@ -141,7 +141,9 @@ server.tool("relay_contracts", "What you dispatched and are still owed. Lists ev
|
|
|
141
141
|
let r;
|
|
142
142
|
try { r = await api("GET", `/contracts?session=${encodeURIComponent(SESSION)}&project=${encodeURIComponent(PROJECT)}`); }
|
|
143
143
|
catch (e) { return { content: [{ type: "text", text: `could not reach the hub: ${e?.message || e}` }] }; }
|
|
144
|
-
|
|
144
|
+
// The hub keeps abandoned contracts in their own key so older stop hooks stop blocking on them.
|
|
145
|
+
// The ledger still wants to SHOW them, so put the two halves back together here.
|
|
146
|
+
const all = [...(r?.contracts || []), ...(r?.abandonedContracts || [])].sort((a, b) => a.ts - b.ts);
|
|
145
147
|
if (!all.length) return { content: [{ type: "text", text: "You have not dispatched any contracts in the last 24h." }] };
|
|
146
148
|
// Fall back to the pre-disposition shape when talking to an older hub.
|
|
147
149
|
const disp = (c) => c.disposition || (c.answered ? "answered" : (c.assigneeOnline ? "waiting" : "stalled"));
|