trantor 0.18.119 → 0.18.120

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.119",
3
+ "version": "0.18.120",
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": {
@@ -85,7 +85,7 @@ export function loadPendingHandoff(project, { claim = true, freshSession = null,
85
85
  if (!claim) return record;
86
86
  const next = claimHandoffFile(path, freshSession, now);
87
87
  if (!next) return null;
88
- const readFirst = readFirstPaths(next.summary || "");
88
+ const readFirst = readFirstPaths(next.summary || "", { projectDir: next.project || process.cwd() });
89
89
  writeFileSync(recapStampPath(dir, freshSession.session_id), JSON.stringify({
90
90
  handoffId: next.id, file, token: next.claim.token, ts: now,
91
91
  mode: next.mode === "unattended" ? "unattended" : "attended", readFirst,
@@ -135,3 +135,11 @@ export function completeHandoffRecap({ sessionId, transcriptPath, dir = handoffD
135
135
  else traceRecapAttempt(stampPath, { ts: now, sessionId, outcome: "no-record-update" });
136
136
  return { recapped: !!next, missed };
137
137
  }
138
+
139
+ // Persist before emitting the reminder so subsequent turns cannot repeat it (#13061).
140
+ export function markHandoffReadReminder({ sessionId, dir = handoffDir() }) {
141
+ try {
142
+ return !!updateRecord(recapStampPath(dir, sessionId), stamp =>
143
+ stamp.readReminderShown ? null : { ...stamp, readReminderShown: true });
144
+ } catch { return false; }
145
+ }
@@ -3,10 +3,11 @@
3
3
  // spawns a fresh same-agent session. PreCompact cannot stop compaction, so continuing with a full
4
4
  // window means a NEW session; both paths share a per-session guard so nothing is written or spawned twice.
5
5
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, openSync, readSync, fstatSync, closeSync, rmSync } from "node:fs";
6
- import { join, basename, dirname, sep } from "node:path";
6
+ import { join, basename, dirname, sep, resolve } from "node:path";
7
7
  import { homedir, hostname } from "node:os";
8
8
  import { execSync, spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { asString, asRecord } from "../../lib/decode.mjs";
10
11
  import { deriveSubagentManifest } from "../../lib/subagent-manifest.mjs";
11
12
  import { signedPost, loadIdentity } from "./api.mjs";
12
13
  // #7037: signedHeaders is SYNCHRONOUS, which is why this path can sign at all — see hubCallSync.
@@ -692,7 +693,7 @@ export function writeHandoff({ projectDir, sessionId, transcript, trigger, summa
692
693
  // paths disarms the #8162 gate — every check downstream passes vacuously, and the summarizer
693
694
  // produced exactly that before #8232 asked it for the section. The memory index is knowable
694
695
  // without the model; PRD and TDD stay the summarizer's to find (only it knows they exist).
695
- if (!readFirstPaths(narrative).length) {
696
+ if (!readFirstPaths(narrative, { projectDir }).length) {
696
697
  const mem = memoryIndexPath(projectDir);
697
698
  if (mem) narrative += `\n\n## READ FIRST\n${mem}`;
698
699
  }
@@ -869,7 +870,7 @@ export function memoryIndexPath(projectDir) {
869
870
  * can check it. Recognised: a `READ FIRST`/`READ-FIRST`/`Read first:` heading or line, and every
870
871
  * path-looking token until the next blank line or heading — narrow on purpose: a path in passing
871
872
  * is not a reading order. @returns {string[]} paths, deduped, capped */
872
- export function readFirstPaths(summary, { max = 12 } = {}) {
873
+ export function readFirstPaths(summary, { max = 12, projectDir = process.cwd() } = {}) {
873
874
  const text = String(summary || "");
874
875
  const out = [];
875
876
  const lines = text.split(/\r?\n/);
@@ -880,10 +881,13 @@ export function readFirstPaths(summary, { max = 12 } = {}) {
880
881
  if (!collecting && !opens) continue;
881
882
  if (collecting && (line === "" || /^#{1,6}\s/.test(line))) break;
882
883
  collecting = true;
883
- // Paths: a token with a slash or a known doc/memory extension, stripped of markdown furniture.
884
- for (const m of line.matchAll(/[`"'(\[]?([~./\w-]*[\w-]+\.(?:md|mjs|js|ts|tsx|json|sql|rs|py|sh|txt))[`"')\]]?/g)) {
885
- const p = m[1].replace(/^[`"'(\[]+|[`"')\]]+$/g, "");
886
- if (p && !out.includes(p)) out.push(p);
884
+ // Preserve bracketed routes and globs as whole tokens; never turn a suffix into a path.
885
+ for (const m of line.matchAll(/`([^`]+)`|"([^"]+)"|'([^']+)'|[^\s`"'<>]+/g)) {
886
+ const p = (m[1] || m[2] || m[3] || m[0]).replace(/^\(|[),;:]$/g, "");
887
+ if (!/\.(?:md|mjs|js|ts|tsx|json|sql|rs|py|sh|txt)$/.test(p) || /[*?]/.test(p)) continue;
888
+ const full = p.startsWith("~/") ? join(homedir(), p.slice(2)) : resolve(projectDir, p);
889
+ try { if (!statSync(full).isFile()) continue; } catch { continue; }
890
+ if (!out.includes(p)) out.push(p);
887
891
  if (out.length >= max) return out;
888
892
  }
889
893
  }
@@ -901,8 +905,29 @@ export function pathsReadIn(transcriptPath, paths) {
901
905
  // Only the tool CALLS count. A path quoted in the injected handoff summary, or in the model's own
902
906
  // prose, must never read as "opened" — that is precisely the proxy this card exists to kill.
903
907
  const opened = new Set();
904
- for (const m of body.matchAll(/"name"\s*:\s*"(Read|Grep|Glob|NotebookRead)"\s*,\s*"input"\s*:\s*\{([^}]*)\}/g)) {
905
- for (const f of m[2].matchAll(/"(?:file_path|path|pattern)"\s*:\s*"([^"]+)"/g)) opened.add(f[1]);
908
+ for (const line of body.split("\n")) {
909
+ let row;
910
+ try { row = asRecord(JSON.parse(line)); } catch { continue; }
911
+ if (row?.type !== "assistant" || row.isSidechain) continue;
912
+ for (const call of Array.isArray(row.message?.content) ? row.message.content : []) {
913
+ if (call.type !== "tool_use") continue;
914
+ if (["Read", "Grep", "Glob", "NotebookRead"].includes(call.name)) {
915
+ for (const key of ["file_path", "path", "pattern"]) {
916
+ const path = asString(asRecord(call.input)?.[key]);
917
+ if (path !== null) opened.add(path);
918
+ }
919
+ }
920
+ const command = asString(asRecord(call.input)?.command);
921
+ if (call.name !== "Bash" || command === null) continue;
922
+ const tokens = command.match(/"[^"]*"|'[^']*'|[^\s;&|]+|[;\n&|]+/g) || [];
923
+ let reader = false, commandStart = true;
924
+ for (const token of tokens) {
925
+ if (/^[;\n&|]+$/.test(token)) { reader = false; commandStart = true; continue; }
926
+ const word = token.replace(/^["']|["']$/g, "");
927
+ if (commandStart) { commandStart = false; reader = /^(?:\/[^ ]*\/)?(?:cat|sed|head)$/.test(word); continue; }
928
+ if (reader && !word.startsWith("-")) opened.add(word);
929
+ }
930
+ }
906
931
  }
907
932
  const hit = (p) => {
908
933
  const base = p.split("/").pop();
@@ -13,7 +13,7 @@ import { signedGet } from "./lib/api.mjs"; // signed: enforce hubs 401 unsigne
13
13
  import { ledgerPaths, ensureStart, anchorCursor, writeCursor } from "./lib/inbox-ledger.mjs";
14
14
  import { readArm, clearArm, markHandedOff, subagentsActive } from "./lib/handoff.mjs";
15
15
 
16
- import { completeHandoffRecap } from "./lib/handoff-claims.mjs";
16
+ import { completeHandoffRecap, markHandoffReadReminder } from "./lib/handoff-claims.mjs";
17
17
 
18
18
  const HERE = dirname(fileURLToPath(import.meta.url));
19
19
 
@@ -129,7 +129,8 @@ async function main() {
129
129
 
130
130
  if (process.env.RELAY_STOP_INBOX === "0") return allow();
131
131
  const recap = completeHandoffRecap({ sessionId: String(input.session_id || ""), transcriptPath: input.transcript_path || "" });
132
- if (recap?.missed.length && !input.stop_hook_active) {
132
+ if (recap?.missed.length && !input.stop_hook_active
133
+ && markHandoffReadReminder({ sessionId: String(input.session_id || "") })) {
133
134
  process.stdout.write(JSON.stringify({ decision: "block", reason: `Read these handoff files before recapping: ${recap.missed.join(", ")}` }));
134
135
  return;
135
136
  }
@@ -20,6 +20,7 @@ const CLAIMED_EXPIRED_REASON = "The Mac claimed this prompt but never delivered
20
20
  const ACK_OUTCOMES = new Set(["delivered", "blocked", "not_ready", "stalled", "no_agent", "stale"]);
21
21
  const ANSWER_PICKS_MAX = 10;
22
22
  const ANSWER_OTHER_MAX = 2000;
23
+ const ANSWER_ENTRIES_MAX = 4; // CC's question limit per ask (#12912)
23
24
  // #12161: photo attachments ride POST /session/prompt as base64 JPEGs. The phone downscales to
24
25
  // the byte cap; the hub stores the bytes on disk next to its state (never in bus.json) and hands
25
26
  // metadata to the claiming desktop, which downloads them through GET /session/attachment.
@@ -243,14 +244,33 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
243
244
  if (!project) return json(res, 400, { error: "project required" });
244
245
  if (!session) return json(res, 400, { error: "session required" });
245
246
  if (!toolId) return json(res, 400, { error: "toolId required" });
246
- const picks = (Array.isArray(b.picks) ? b.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
247
- const other = String(b.other || "").slice(0, ANSWER_OTHER_MAX);
248
- if (!picks.length && !other.trim()) return json(res, 400, { error: "picks or other required" });
247
+ // #12912: a multi-question ask carries answers[] (1..4 entries, one per question in
248
+ // order); the old single-question { picks, other } body normalizes to one entry.
249
+ const normEntry = (e) => {
250
+ const picks = (Array.isArray(e?.picks) ? e.picks : []).map(Number).filter(Number.isInteger).slice(0, ANSWER_PICKS_MAX);
251
+ const other = String(e?.other || "").slice(0, ANSWER_OTHER_MAX);
252
+ if (!picks.length && !other.trim()) return null;
253
+ const entry = { picks };
254
+ if (other.trim()) entry.other = other;
255
+ return entry;
256
+ };
257
+ let answers;
258
+ if (Array.isArray(b.answers)) {
259
+ if (!b.answers.length || b.answers.length > ANSWER_ENTRIES_MAX) {
260
+ return json(res, 400, { error: `answers needs 1..${ANSWER_ENTRIES_MAX} entries` });
261
+ }
262
+ answers = b.answers.map(normEntry);
263
+ if (answers.some(a => !a)) return json(res, 400, { error: "each answer needs picks or a non-blank other" });
264
+ } else {
265
+ const entry = normEntry(b);
266
+ if (!entry) return json(res, 400, { error: "picks or other required" });
267
+ answers = [entry];
268
+ }
249
269
  expireQueued();
250
270
  if (queueFull()) return json(res, 429, { error: "prompt queue full; try again after pending work finishes" });
251
271
  state.sessionPromptSeq += 1;
252
- const text = other.trim() ? other : `picks ${picks.join(", ")}`;
253
- const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, picks, other,
272
+ const text = answers.map(a => a.other ? a.other : `picks ${a.picks.join(", ")}`).join("; ");
273
+ const p = { id: state.sessionPromptSeq, project, kind: "answer", session, toolId, answers,
254
274
  text, by: String(auth?.identity?.name || "").slice(0, 120), ts: now(), status: "queued" };
255
275
  state.sessionPrompts.push(p);
256
276
  trimPrompts();
@@ -379,15 +399,29 @@ export function createSessionRuntime({ state, markDirty, appendEvent, pushEventT
379
399
  }
380
400
 
381
401
  // The public view of a queue row: plain prompts are kind "prompt"; answers carry their
382
- // session, toolId, picks and other; crew commands carry action, seats, from and to —
402
+ // session, toolId and normalized answers (plus legacy picks/other for a one-entry answer); crew commands carry action, seats, from and to —
383
403
  // so the claiming desktop has everything it needs. Attachments ride as metadata only;
384
404
  // the bytes go to the claimer through GET /session/attachment.
385
405
  const promptView = (p) => {
386
406
  const view = { id: p.id, project: p.project, text: p.text, by: p.by, ts: p.ts, status: p.status, kind: p.kind || "prompt" };
387
407
  if (p.attachments?.length) view.attachments = p.attachments.map(({ name, size }) => ({ name, size }));
388
408
  if (p.kind === "answer") {
389
- view.session = p.session; view.toolId = p.toolId; view.picks = p.picks;
390
- if (p.other && p.other.trim()) view.other = p.other; // absent, never "", when nothing was typed
409
+ view.session = p.session; view.toolId = p.toolId;
410
+ // #12912: the view always carries the normalized answers; the legacy top-level picks and
411
+ // other ride along only for a one-entry answer so a desktop that predates it keeps working.
412
+ // A row persisted before answers[] shipped has picks/other but no answers — derive the one
413
+ // entry from them so new clients read old rows too.
414
+ let answers = Array.isArray(p.answers) && p.answers.length ? p.answers : null;
415
+ if (!answers) {
416
+ const entry = { picks: Array.isArray(p.picks) ? p.picks : [] };
417
+ if (String(p.other || "").trim()) entry.other = p.other;
418
+ answers = [entry];
419
+ }
420
+ view.answers = answers;
421
+ if (answers.length === 1) {
422
+ view.picks = answers[0].picks;
423
+ if (answers[0].other?.trim()) view.other = answers[0].other; // absent, never "", when nothing was typed
424
+ }
391
425
  }
392
426
  if (p.kind === "crew") {
393
427
  view.action = p.action; view.seats = p.seats;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.119",
3
+ "version": "0.18.120",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"