trantor 0.18.82 → 0.18.86

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.
@@ -0,0 +1,193 @@
1
+ // #11311: client-side open-PR overlap evidence. The session's OWN hook polls the repo it is
2
+ // actually editing (`git -C <gitRoot> remote get-url origin`) — never the hub, whose checkout and
3
+ // unauthenticated gh would match trantor's PRs to every project. Cached 5 min per repo in
4
+ // ~/.agent-bus/remote-overlap-<owner>-<name>.json; gh missing/unauthenticated = off, one log line.
5
+ import { execFile, spawnSync } from "node:child_process";
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { homedir } from "node:os";
9
+
10
+ const POLL_TTL_MS = 5 * 60 * 1000;
11
+ const RATE_LIMIT_BACKOFF_MS = 10 * 60 * 1000;
12
+ const BLIP_BACKOFF_MS = 30 * 1000;
13
+ const GH_TIMEOUT_MS = Number(process.env.RELAY_REMOTE_OVERLAP_TIMEOUT_MS || 5000);
14
+ // Cap PRs per poll: each costs a /files call, and a busy repo must not eat the rate limit.
15
+ const PR_CAP = 25;
16
+
17
+ const GH_OFF = /gh auth login|not logged into|gh: auth|authentication/i;
18
+ const RATE_LIMIT = /rate limit/i;
19
+
20
+ // "owner/name" from every remote form in the wild — https, ssh, git://, with credentials or a
21
+ // .git suffix — or a bare owner/name that is already parsed. Anything else is not pollable.
22
+ export function parseRepoUrl(url) {
23
+ const raw = String(url ?? "").trim();
24
+ if (!raw) return null;
25
+ if (/^[\w.-]+\/[\w.-]+$/.test(raw)) return raw;
26
+ const m = raw.match(/^(?:(?:https?:\/\/|ssh:\/\/|git:\/\/)(?:[^@/]+@)?|git@)?[^/:@]+[:/](.+?)(?:\.git)?\/?$/i);
27
+ const parts = m ? m[1].replace(/\.git$/i, "").split("/").filter(Boolean) : [];
28
+ const owner = parts[parts.length - 2];
29
+ const name = parts[parts.length - 1];
30
+ return owner && name && /^[\w.-]+$/.test(owner) && /^[\w.-]+$/.test(name) ? `${owner}/${name}` : null;
31
+ }
32
+
33
+ function defaultGitRun(args, { cwd } = {}) {
34
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 5000 });
35
+ return { status: r.status, stdout: r.stdout || "", stderr: r.stderr || "" };
36
+ }
37
+
38
+ function defaultGhRun(args) {
39
+ const r = spawnSync("gh", args, { encoding: "utf8", timeout: GH_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 });
40
+ if (r.error && !r.error.message?.includes("exited with")) {
41
+ return { code: r.error.code ?? 1, stdout: "", stderr: String(r.stderr || r.error.message || "") };
42
+ }
43
+ return { code: r.status ?? 1, stdout: r.stdout || "", stderr: r.stderr || "" };
44
+ }
45
+
46
+ // Repo detection: `git remote get-url origin` from the session's git root. Null when there is
47
+ // no origin or it is not a GitHub-ish remote — the poller stays quietly off.
48
+ export function detectRepo({ dir = process.cwd(), run = defaultGitRun } = {}) {
49
+ try {
50
+ const r = run(["remote", "get-url", "origin"], { cwd: dir });
51
+ return r.status === 0 ? parseRepoUrl(r.stdout) : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ const cleanPaths = (files) =>
58
+ [...new Set(Array.from(files ?? []).map((f) => String(f ?? "").trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
59
+
60
+ // The poller. overlapsFor({repo, gitRoot, files}) is SYNC: cache file read first (the norm), a
61
+ // stale or cold repo triggers one gh poll (list + per-PR files) whose result rewrites the cache,
62
+ // so sibling processes (other claims, other seats) hit the warm file. rate-limit and gh-off state
63
+ // persist in the cache file too, so backoff survives the hook's short-lived processes.
64
+ export function createRemoteOverlap({
65
+ run = defaultGhRun,
66
+ gitRun = defaultGitRun,
67
+ log = (m) => console.error(`[remote-overlap] ${m}`),
68
+ now = Date.now,
69
+ ttlMs = POLL_TTL_MS,
70
+ backoffMs = RATE_LIMIT_BACKOFF_MS,
71
+ cacheDir,
72
+ } = {}) {
73
+ const dir = cacheDir || process.env.AGENT_BUS_DIR || join(homedir(), ".agent-bus");
74
+ let enabled = true;
75
+ let logged = "";
76
+
77
+ const say = (m) => {
78
+ if (!logged) {
79
+ logged = m;
80
+ log(m);
81
+ }
82
+ };
83
+
84
+ const cachePath = (repo) => join(dir, `remote-overlap-${String(repo).replace(/\//g, "-")}.json`);
85
+ const readCache = (repo) => {
86
+ try {
87
+ return JSON.parse(readFileSync(cachePath(repo), "utf8"));
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+ const writeCache = (repo, data) => {
93
+ try {
94
+ mkdirSync(dir, { recursive: true });
95
+ writeFileSync(cachePath(repo), JSON.stringify({ repo, at: now(), prs: [], off: "", blockedUntil: 0, ...data }));
96
+ } catch {}
97
+ };
98
+
99
+ function fail(repo, res) {
100
+ const stderr = String(res?.stderr || "");
101
+ if (res?.code === "ENOENT" || GH_OFF.test(stderr)) {
102
+ enabled = false;
103
+ say(`gh unusable (${res?.code === "ENOENT" ? "gh not found" : "not authenticated"}) — remote-overlap off.`);
104
+ writeCache(repo, { off: "gh missing or unauthenticated", prs: [] });
105
+ return null;
106
+ }
107
+ const prior = readCache(repo);
108
+ const blockedUntil = RATE_LIMIT.test(stderr) ? (say(`GitHub rate limit hit — polling paused ${Math.round(backoffMs / 60000)}m.`), now() + backoffMs) : now() + BLIP_BACKOFF_MS;
109
+ writeCache(repo, { at: prior?.at ?? 0, prs: prior?.prs ?? [], blockedUntil, off: "" });
110
+ return null;
111
+ }
112
+
113
+ function poll(repo) {
114
+ const list = run(["api", `repos/${repo}/pulls?state=open`]);
115
+ if (list.code !== 0) return fail(repo, list);
116
+ let pulls;
117
+ try {
118
+ pulls = JSON.parse(list.stdout || "[]");
119
+ } catch {
120
+ return fail(repo, { code: list.code, stderr: "unparsable PR list" });
121
+ }
122
+ if (!Array.isArray(pulls)) return fail(repo, { code: list.code, stderr: "unexpected PR list shape" });
123
+ const prs = [];
124
+ for (const p of pulls.slice(0, PR_CAP)) {
125
+ const number = Number(p?.number);
126
+ if (!Number.isFinite(number)) continue;
127
+ const res = run(["api", `repos/${repo}/pulls/${number}/files`]);
128
+ let files = [];
129
+ if (res.code === 0) {
130
+ try {
131
+ files = cleanPaths(JSON.parse(res.stdout || "[]").map((f) => f?.filename));
132
+ } catch {
133
+ files = [];
134
+ }
135
+ }
136
+ prs.push({ number, author: String(p?.user?.login || ""), url: String(p?.html_url || ""), files });
137
+ }
138
+ const data = { repo, at: now(), prs, off: "", blockedUntil: 0 };
139
+ writeCache(repo, data);
140
+ return data;
141
+ }
142
+
143
+ function overlapsFor(input = {}) {
144
+ if (!enabled) return [];
145
+ const want = cleanPaths(input.files);
146
+ if (!want.length) return [];
147
+ const repo = String(input.repo || "").trim() || (input.gitRoot ? detectRepo({ dir: input.gitRoot, run: gitRun }) : null);
148
+ if (!repo) return [];
149
+ const t = now();
150
+ let data = readCache(repo);
151
+ if (!data || data.off) {
152
+ // a fresh off marker stays off without spawning gh; past the TTL the poll retries once
153
+ if (data?.off && t - (data.at ?? 0) <= ttlMs) return [];
154
+ poll(repo);
155
+ data = readCache(repo);
156
+ if (!data || data.off) return [];
157
+ } else if (t - (data.at ?? 0) > ttlMs && t >= (data.blockedUntil ?? 0)) {
158
+ poll(repo);
159
+ data = readCache(repo) ?? data;
160
+ }
161
+ const wantSet = new Set(want);
162
+ const out = [];
163
+ for (const p of data.prs ?? []) {
164
+ const hit = (p.files || []).filter((f) => wantSet.has(f));
165
+ if (hit.length) out.push({ repo, number: p.number, author: p.author, url: p.url, files: cleanPaths(hit) });
166
+ }
167
+ return out.sort((a, b) => a.number - b.number);
168
+ }
169
+
170
+ // Force a poll, cache result, return it (or null on failure) — the test seam and warm-up path.
171
+ function refresh(repo) {
172
+ return poll(repo);
173
+ }
174
+
175
+ return {
176
+ overlapsFor,
177
+ refresh,
178
+ get enabled() {
179
+ return enabled;
180
+ },
181
+ };
182
+ }
183
+
184
+ const defaultApi = createRemoteOverlap({});
185
+
186
+ // The hook-facing seam: never raises, whatever the input.
187
+ export function overlapsFor(input) {
188
+ try {
189
+ return defaultApi.overlapsFor(input);
190
+ } catch {
191
+ return [];
192
+ }
193
+ }
package/lib/seat-why.mjs CHANGED
@@ -134,6 +134,7 @@ export function seatWhy(project, agent, opts = {}) {
134
134
  const pids = opts.pidCheck ? opts.pidCheck(project, agent) : scanPids(project, agent);
135
135
  const last = [...telemetry].reverse().find(r => asNumber(r.turn) !== null) || null;
136
136
  const boots = telemetry.filter(r => r.boot).length;
137
+ const lifecycle = [...telemetry].reverse().find(r => r.boot || r.stopped);
137
138
  const errTail = errText.trim().split("\n").filter(Boolean).slice(-2).join(" | ").slice(0, 240);
138
139
 
139
140
  let state, why, advice;
@@ -184,7 +185,11 @@ export function seatWhy(project, agent, opts = {}) {
184
185
  else if (boots) bits.push(`${boots} boot record(s), no completed turn`);
185
186
  if (pane) bits.push(`pane ${pane.source}/${pane.pane} registered but no runner process`);
186
187
 
187
- if (last?.authFailed || AUTH_RE.test(errText)) {
188
+ if (lifecycle?.stopped) {
189
+ state = "stopped";
190
+ why = `no runner process; operator stopped ${agent}:${project} ${rel(lifecycle.ts)}.`;
191
+ advice = "seat was deliberately stopped — `trantor up <agent>` to start it again.";
192
+ } else if (last?.authFailed || AUTH_RE.test(errText)) {
188
193
  state = "dead-auth";
189
194
  why = `no runner process; ${bits.join("; ") || "auth markers in evidence"}.`;
190
195
  advice = "provider credentials are rejected — fix the key (~/.agent-bus/.env or the seat's token-scrooge .env), then `trantor up <agent>`.";
@@ -0,0 +1,83 @@
1
+ // CLI-owned records let read-only turns and their sub-agents prove liveness (#10497).
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+
7
+ const canonical = p => { try { return realpathSync(p); } catch { return p; } };
8
+ const sql = s => `'${String(s).replaceAll("'", "''")}'`;
9
+ const entries = p => { try { return readdirSync(p, { withFileTypes: true }); } catch { return []; } };
10
+ const mtime = p => { try { return statSync(p).mtimeMs; } catch { return 0; } };
11
+ function logTime(dir, budget = { left: 20000 }) {
12
+ let best = 0;
13
+ for (const e of entries(dir)) {
14
+ if (--budget.left < 0) break;
15
+ const path = join(dir, e.name);
16
+ if (e.isDirectory()) best = Math.max(best, logTime(path, budget));
17
+ else if (e.name.endsWith(".jsonl")) best = Math.max(best, mtime(path));
18
+ }
19
+ return best;
20
+ }
21
+ function query(db, statement) {
22
+ try {
23
+ const r = spawnSync("sqlite3", ["-readonly", db, statement], {
24
+ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000,
25
+ });
26
+ return r.status === 0 ? r.stdout.trim() : null;
27
+ } catch { return null; }
28
+ }
29
+
30
+ // Pin the root once discovered. A fresh root must have been created during this turn;
31
+ // a resumed root is supplied by the runner. Never use global DB/WAL mtimes.
32
+ export function sessionActivityReader({ kind, sid = "", db, workDir, home, startedAt, transcriptDir }) {
33
+ let root = sid, logDir = "", lastDb = 0;
34
+ const cwd = canonical(workDir);
35
+ return () => {
36
+ if (kind === "opencode") {
37
+ if (!root) root = query(db, `SELECT id FROM session WHERE directory IN (${sql(workDir)},${sql(cwd)})`
38
+ + ` AND parent_id IS NULL AND time_created>=${Math.floor(startedAt)} ORDER BY time_created DESC LIMIT 1;`);
39
+ if (!/^ses_[A-Za-z0-9]+$/.test(root)) return 0;
40
+ const value = query(db, `WITH RECURSIVE own(id) AS (SELECT id FROM session WHERE id=${sql(root)}`
41
+ + ` UNION SELECT s.id FROM session s JOIN own ON s.parent_id=own.id)`
42
+ + ` SELECT MAX(t) FROM (SELECT MAX(time_created,time_updated) t FROM message WHERE session_id IN (SELECT id FROM own)`
43
+ + ` UNION ALL SELECT MAX(time_created,time_updated) t FROM part WHERE session_id IN (SELECT id FROM own));`);
44
+ // #9832: a locked or timed-out read is unknown, not silence; keep the last seen activity.
45
+ if (value === null) return lastDb;
46
+ return (lastDb = Math.max(lastDb, Number(value) || 0));
47
+ }
48
+ if (kind === "claude") {
49
+ if (!root) {
50
+ const newest = entries(transcriptDir).filter(e => e.isFile() && e.name.endsWith(".jsonl"))
51
+ .map(e => ({ name: e.name, ts: mtime(join(transcriptDir, e.name)) }))
52
+ .filter(e => e.ts >= startedAt).sort((a, b) => b.ts - a.ts)[0];
53
+ if (newest) root = newest.name.slice(0, -6);
54
+ }
55
+ if (!root || root.includes("/")) return 0;
56
+ return Math.max(mtime(join(transcriptDir, `${root}.jsonl`)), logTime(join(transcriptDir, root)));
57
+ }
58
+ if (kind !== "kimi") return 0;
59
+ if (!logDir) {
60
+ // kimi-code indexes the actual workdir and session directory; avoid guessing its hash.
61
+ try {
62
+ const rows = readFileSync(join(home, ".kimi-code", "session_index.jsonl"), "utf8").trim().split("\n");
63
+ for (const line of rows.reverse()) {
64
+ let row; try { row = JSON.parse(line); } catch { continue; }
65
+ if (canonical(row.workDir || "") !== cwd || !row.sessionDir || (root && row.sessionId !== root)) continue;
66
+ if (!root && mtime(row.sessionDir) < startedAt) continue;
67
+ root = row.sessionId; logDir = row.sessionDir; break;
68
+ }
69
+ } catch {}
70
+ // Python kimi-cli uses md5(canonical cwd)/session-id.
71
+ if (!logDir) {
72
+ const parent = join(home, ".kimi", "sessions", createHash("md5").update(cwd).digest("hex"));
73
+ if (root && !root.includes("/")) logDir = join(parent, root);
74
+ else {
75
+ const newest = entries(parent).filter(e => e.isDirectory()).map(e => ({ name: e.name, ts: mtime(join(parent, e.name)) }))
76
+ .filter(e => e.ts >= startedAt).sort((a, b) => b.ts - a.ts)[0];
77
+ if (newest) { root = newest.name; logDir = join(parent, root); }
78
+ }
79
+ }
80
+ }
81
+ return logDir ? logTime(logDir) : 0;
82
+ };
83
+ }
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, readFileSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { join, resolve } from "node:path";
6
+ import { dirname, join, resolve } from "node:path";
7
7
  import { CAPS } from "./schema.mjs";
8
8
 
9
9
  const DEFAULT_MAX_MS = 300_000;
package/lib/store-pg.mjs CHANGED
@@ -154,6 +154,7 @@ function kvFromState(state) {
154
154
  proposals: state.proposals || [],
155
155
  contractReap: state.contractReap || {},
156
156
  meta: {
157
+ overseerState: state.overseerState || {},
157
158
  taskSeq: Number(state.taskSeq || 0),
158
159
  verifyGateSeq: Number(state.verifyGateSeq || 0),
159
160
  proposalSeq: Number(state.proposalSeq || 0),
@@ -731,6 +732,7 @@ export class PgStore {
731
732
  inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
732
733
  instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
733
734
  focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
735
+ overseerState: meta.overseerState || {},
734
736
  orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
735
737
  contractReap: kv.contractReap && typeof kv.contractReap === "object" ? kv.contractReap : {},
736
738
  eventSeq: Math.max(Number(eventMaxRow?.rows?.[0]?.max_id || 0), Number(meta.eventSeq || 0)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.82",
3
+ "version": "0.18.86",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"