flowviant 0.44.1 → 0.47.0

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.
@@ -233,7 +233,7 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
233
233
  // returned string for sentinel detection, and each activity is handed to
234
234
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
235
235
  // off and keep the raw text passthrough + line sentinels.
236
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort }) {
236
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, onActivity, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
237
237
  return new Promise((resolve) => {
238
238
  const rt = runtimeById(runtime);
239
239
  if (!rt.args) {
@@ -276,6 +276,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
276
276
  resume,
277
277
  streamJson,
278
278
  profile,
279
+ // Adopting a terminal session (work.mjs): Claude turns it into
280
+ // `--resume <id> --fork-session` (a FORK — the original is untouched);
281
+ // agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
282
+ // tab continues the terminal conversation itself). Codex THROWS on it,
283
+ // so a mis-wired adoption fails as a loud turn error rather than a
284
+ // silent fresh conversation wearing an adopted session's name.
285
+ adoptResumeId,
279
286
  // Only the wiki profile uses it, but it is passed unconditionally: a
280
287
  // runtime that can path-scope its writes needs to know WHERE the vault is,
281
288
  // and Claude — which cannot — simply ignores it.
@@ -291,6 +298,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
291
298
  // positional, so a flag after it is a flag in the wrong place.
292
299
  // Wiki-vault turns are pure file work and pass neither — no MCP at all.
293
300
  mcp: mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
301
+ // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
302
+ // sessions). Runtimes without a by-id resume ignore it and keep their
303
+ // `resume` behavior unchanged.
304
+ resumeThreadId,
305
+ // agy's by-id resume (work.mjs, antigravity sessions): the conversation
306
+ // id learned from the adopt hint or the cwd registry after a turn.
307
+ resumeConversationId,
294
308
  });
295
309
  // Whatever this machine is signed in with, we use. We do NOT pick.
296
310
  //
@@ -333,6 +347,10 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
333
347
  if (!rt.parse) return handleStreamLine(line, { cwd, emit, onActivity, appendText });
334
348
  const ev = rt.parse(line, cwd);
335
349
  if (!ev) return;
350
+ // The conversation id, when the runtime announces one (codex's
351
+ // thread.started). Purely additive: callers that pass no onThreadId —
352
+ // every dispatch path — see zero behavior change.
353
+ if (ev.threadId) onThreadId?.(ev.threadId);
336
354
  if (ev.text) appendText(ev.text);
337
355
  if (ev.activity) {
338
356
  emit(`${ev.activity.label}\n`);
package/bin/lib/fleet.mjs CHANGED
@@ -84,6 +84,7 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
84
84
  import { machineSnapshot } from './resources.mjs';
85
85
  import { detectRuntimes, pickRuntimeFor, RUNTIMES } from './runtimes.mjs';
86
86
  import { createWorkManager } from './work.mjs';
87
+ import { scanLocalSessions } from './localSessions.mjs';
87
88
 
88
89
  async function fetchRoster(haveIds) {
89
90
  const url = new URL(FLEET_URL);
@@ -93,6 +94,13 @@ async function fetchRoster(haveIds) {
93
94
  // machine knows its cores, its RAM and whose Claude quota is being spent.
94
95
  // Older servers ignore the param, so sending it is always safe.
95
96
  url.searchParams.set('capacity', String(MAX_CONCURRENT));
97
+ // WHICH DAEMON this machine runs, so the server can gate version-dependent
98
+ // work — codex Workbench tabs are only created for machines whose daemon can
99
+ // actually serve them (dv >= 0.46.0). The same source the self-update check
100
+ // compares against the roster's daemon.latest (config.mjs VERSION, read off
101
+ // our own package.json). Older servers ignore unknown params, so sending it
102
+ // unconditionally is always safe.
103
+ url.searchParams.set('dv', VERSION);
96
104
  // WHICH CLIs this machine actually has, so the app can stop guessing.
97
105
  //
98
106
  // Until now every surface that listed Gemini or Codex said "not wired up yet"
@@ -222,6 +230,69 @@ function sampleDiffstat(cwd, baseRef, intentId, agentId) {
222
230
  };
223
231
  }
224
232
 
233
+ /**
234
+ * Terminal-session presence: tell the server which Claude sessions exist in
235
+ * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
236
+ * the Workbench can offer "adopt this terminal session as a tab". Best-effort
237
+ * in exactly the way the env/runtimes blocks are — a presence report that can
238
+ * fail a poll is worse than no presence at all — with three quiet economies:
239
+ * the scan runs at most once a minute (the reconcile loop ticks far faster), a
240
+ * report identical to the last DELIVERED one is not re-sent, and a 404 means
241
+ * an older server that has never heard of the endpoint, after which this
242
+ * process stops asking (a deploy that adds it also restarts nothing on this
243
+ * machine, so silence-until-restart costs one daemon restart, not a feature).
244
+ */
245
+ const LOCAL_SESSIONS_URL = FLEET_URL.replace(/\/agents\/?$/, '/local-sessions');
246
+ const LOCAL_SESSIONS_SCAN_MS = 60_000;
247
+ // The web hides a report older than 10 minutes (presence must not linger as
248
+ // fact after the machine dies), so an UNCHANGED report is re-sent inside that
249
+ // window anyway — the re-send is the machine's heartbeat on this fact, and
250
+ // suppressing it entirely would blank the strip while everything still holds.
251
+ const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
252
+ let localSessionsUnsupported = false; // the server 404'd — quiet until restart
253
+ let localSessionsScanAt = 0;
254
+ let localSessionsSent = null; // last payload the server ACCEPTED, stringified
255
+ let localSessionsSentAt = 0;
256
+ async function maybeReportLocalSessions({ repoRoot, excludeDirs }) {
257
+ if (localSessionsUnsupported) return;
258
+ if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
259
+ localSessionsScanAt = Date.now();
260
+ let payload;
261
+ try {
262
+ // scanLocalSessions orders deterministically, so this string only changes
263
+ // when the facts on disk do — the dedup below compares whole payloads.
264
+ payload = JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs }) });
265
+ } catch {
266
+ return; // presence must never throw into the poll loop
267
+ }
268
+ if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
269
+ return;
270
+ try {
271
+ const res = await fetch(LOCAL_SESSIONS_URL, {
272
+ method: 'POST',
273
+ headers: {
274
+ Authorization: `Bearer ${FLEET_TOKEN}`,
275
+ 'User-Agent': USER_AGENT,
276
+ 'Content-Type': 'application/json',
277
+ },
278
+ signal: AbortSignal.timeout(15_000),
279
+ body: payload,
280
+ });
281
+ if (res.status === 404) {
282
+ localSessionsUnsupported = true; // older server — it REPLACED nothing here
283
+ return;
284
+ }
285
+ // Only an accepted report counts as sent; anything else forgets the
286
+ // last-sent payload so the next pass retries instead of dedup-suppressing
287
+ // a report the server never received.
288
+ localSessionsSent = res.ok ? payload : null;
289
+ localSessionsSentAt = res.ok ? Date.now() : 0;
290
+ } catch {
291
+ localSessionsSent = null;
292
+ localSessionsSentAt = 0;
293
+ }
294
+ }
295
+
225
296
  // One roster agent's loop: persistent worktree, one intent per turn, reset to
226
297
  // base between tasks (fresh conversation), resume in place while on a blocker.
227
298
  async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWork, getNext, getMcpUrl, isAlive, onChild, onTokenSuspect }) {
@@ -1754,6 +1825,10 @@ export async function runFleetDaemon() {
1754
1825
  // sessions are LIVE, and the guards above (chains, shipping) are populated
1755
1826
  // by the intake this same tick.
1756
1827
  retireWorkSessions(roster.activeWorkSessions);
1828
+ // Terminal-session presence, throttled + dedup'd inside; never awaited —
1829
+ // the daemon's own worktrees are carved out (a session the daemon spawned
1830
+ // is already a tab, not something to offer adopting).
1831
+ void maybeReportLocalSessions({ repoRoot, excludeDirs: [baseDir] });
1757
1832
  processJoinJobs(roster.joinJobs);
1758
1833
  processCleanupJobs(roster.cleanupJobs);
1759
1834
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Terminal-session presence — which Claude Code sessions exist in THIS repo,
3
+ * read off Claude's own on-disk state. Nothing here is inference: the liveness
4
+ * registry (~/.claude/sessions/<pid>.json) says what is open right now, and the
5
+ * transcript store (~/.claude/projects/<munged-cwd>/<id>.jsonl) says what was.
6
+ * The daemon RELAYS both to the server so the Workbench can offer "adopt this
7
+ * terminal session as a tab" — activity, never capacity, and only ever facts
8
+ * the user could see by looking at their own machine.
9
+ *
10
+ * The one contract that matters to callers: NOTHING in this file throws. A
11
+ * presence scan runs inside the poll loop's best-effort tail, and a torn
12
+ * registry file or a vanished cwd is a session to skip, not an error to raise.
13
+ */
14
+
15
+ import {
16
+ readdirSync,
17
+ readFileSync,
18
+ realpathSync,
19
+ statSync,
20
+ openSync,
21
+ readSync,
22
+ closeSync,
23
+ } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { join } from 'node:path';
26
+
27
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
28
+ const REPORT_CAP = 30;
29
+
30
+ /** Path-prefix containment on already-realpath'd absolute paths. */
31
+ const inside = (p, root) => p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
32
+
33
+ /**
34
+ * Is this pid the SAME process the registry entry recorded?
35
+ *
36
+ * Registry entries go stale — Claude exits, the pid is recycled by something
37
+ * else, the file stays. `/proc/<pid>` existing only proves A process; the
38
+ * starttime (field 22 of /proc/<pid>/stat) proves it is THAT process. The comm
39
+ * field (parenthesised, may itself contain spaces and parens) makes naive
40
+ * whitespace-splitting wrong, so fields are counted from after the LAST ')':
41
+ * the first post-comm field is field 3, which puts starttime at index 19.
42
+ */
43
+ function pidAlive(pid, procStart) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ let stat;
46
+ try {
47
+ stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
48
+ } catch {
49
+ return false; // no /proc entry — the process is gone
50
+ }
51
+ if (procStart == null) return true; // nothing recorded to compare against
52
+ const close = stat.lastIndexOf(')');
53
+ if (close === -1) return false;
54
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
55
+ return fields[19] === String(procStart);
56
+ }
57
+
58
+ /**
59
+ * Is a terminal Claude session with this id open on the machine RIGHT NOW?
60
+ *
61
+ * The adoption path asks this at the moment of adopting: forking a session
62
+ * while its terminal is still typing into it would put two Claudes on one
63
+ * conversation, which is the exact incoherence the Workbench's own locks
64
+ * exist to prevent.
65
+ */
66
+ export function isTerminalSessionLive(sessionId) {
67
+ try {
68
+ const dir = join(homedir(), '.claude', 'sessions');
69
+ for (const name of readdirSync(dir)) {
70
+ if (!name.endsWith('.json')) continue;
71
+ let rec;
72
+ try {
73
+ rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
74
+ } catch {
75
+ continue; // torn write / not JSON — not evidence of anything
76
+ }
77
+ if (rec?.sessionId !== sessionId) continue;
78
+ if (pidAlive(rec.pid, rec.procStart)) return true;
79
+ }
80
+ } catch {
81
+ /* registry unreadable — no proof of life is "not live" */
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * First transcript record that carries a cwd, from the file's head only.
88
+ *
89
+ * A transcript can be megabytes; the cwd/gitBranch identity rides on every
90
+ * record, so ~16KB from the front is enough to verify WHOSE session this is
91
+ * without paying to read the conversation. A file whose first cwd-bearing
92
+ * line does not parse (truncated at the window edge) is skipped, not retried
93
+ * deeper — this is presence, not forensics.
94
+ */
95
+ function firstCwdRecord(file) {
96
+ let fd;
97
+ try {
98
+ fd = openSync(file, 'r');
99
+ const buf = Buffer.alloc(16384);
100
+ const n = readSync(fd, buf, 0, buf.length, 0);
101
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
102
+ if (!line.includes('"cwd":"')) continue;
103
+ try {
104
+ const rec = JSON.parse(line);
105
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) return rec;
106
+ } catch {
107
+ /* an incomplete line at the window edge — try the next candidate */
108
+ }
109
+ }
110
+ return null;
111
+ } catch {
112
+ return null;
113
+ } finally {
114
+ if (fd !== undefined) {
115
+ try {
116
+ closeSync(fd);
117
+ } catch {
118
+ /* best-effort */
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every Claude terminal session belonging to this repo: LIVE ones from the
126
+ * liveness registry, ENDED ones from the transcript store. Returns
127
+ * [{ id, cwd, live, lastActiveAt, branch? }], live first, then newest ended,
128
+ * capped at 30, deterministically ordered (so a stringified report only
129
+ * changes when the facts do).
130
+ *
131
+ * `excludeDirs` carves out the daemon's own worktrees: sessions the daemon
132
+ * itself spawned are tabs already, and offering to adopt one would be the
133
+ * product offering the user their own reflection.
134
+ */
135
+ export function scanLocalSessions({ repoRoot, excludeDirs = [] }) {
136
+ const live = [];
137
+ const ended = [];
138
+ try {
139
+ let realRoot;
140
+ try {
141
+ realRoot = realpathSync(repoRoot);
142
+ } catch {
143
+ realRoot = String(repoRoot ?? '');
144
+ }
145
+ if (!realRoot) return [];
146
+ const excludes = [];
147
+ for (const d of excludeDirs) {
148
+ if (!d) continue;
149
+ try {
150
+ excludes.push(realpathSync(d));
151
+ } catch {
152
+ excludes.push(String(d)); // not on disk yet — keep the literal fence
153
+ }
154
+ }
155
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
156
+
157
+ // ── LIVE: the registry, validated pid by pid ─────────────────────────
158
+ const nowIso = new Date().toISOString();
159
+ const liveIds = new Set();
160
+ let regNames = [];
161
+ try {
162
+ regNames = readdirSync(join(homedir(), '.claude', 'sessions'));
163
+ } catch {
164
+ /* no registry — no live sessions */
165
+ }
166
+ for (const name of regNames) {
167
+ if (!name.endsWith('.json')) continue; // .key files ride alongside
168
+ let rec;
169
+ try {
170
+ rec = JSON.parse(readFileSync(join(homedir(), '.claude', 'sessions', name), 'utf8'));
171
+ } catch {
172
+ continue;
173
+ }
174
+ if (!rec || typeof rec.sessionId !== 'string' || typeof rec.cwd !== 'string') continue;
175
+ if (liveIds.has(rec.sessionId)) continue;
176
+ if (!pidAlive(rec.pid, rec.procStart)) continue;
177
+ let cwd;
178
+ try {
179
+ cwd = realpathSync(rec.cwd);
180
+ } catch {
181
+ continue; // the directory is gone — nothing to point a tab at
182
+ }
183
+ if (!ours(cwd)) continue;
184
+ liveIds.add(rec.sessionId);
185
+ live.push({
186
+ id: rec.sessionId,
187
+ cwd,
188
+ live: true,
189
+ lastActiveAt: nowIso,
190
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
191
+ });
192
+ }
193
+ live.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
194
+
195
+ // ── ENDED: the transcript store, verified file by file ───────────────
196
+ //
197
+ // The munged directory name is a PREFIX match on purpose: a session run in
198
+ // a SUBDIRECTORY of the repo munges to a longer name sharing the root's.
199
+ // But so does a sibling repo ('flowviant-two' shares 'flowviant' + '-'),
200
+ // which is why every candidate is verified against the cwd its own records
201
+ // embed rather than trusted on its directory name.
202
+ const munged = realRoot.replace(/[/.]/g, '-');
203
+ const projectsDir = join(homedir(), '.claude', 'projects');
204
+ let projDirs = [];
205
+ try {
206
+ projDirs = readdirSync(projectsDir);
207
+ } catch {
208
+ /* no transcript store — live sessions still report */
209
+ }
210
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
211
+ const candidates = [];
212
+ for (const dirName of projDirs) {
213
+ if (dirName !== munged && !dirName.startsWith(`${munged}-`)) continue;
214
+ let entries = [];
215
+ try {
216
+ entries = readdirSync(join(projectsDir, dirName), { withFileTypes: true });
217
+ } catch {
218
+ continue;
219
+ }
220
+ for (const ent of entries) {
221
+ if (!ent.isFile() || !ent.name.endsWith('.jsonl')) continue; // top-level only
222
+ const id = ent.name.slice(0, -'.jsonl'.length);
223
+ if (!id || liveIds.has(id)) continue; // a live session outranks its own transcript
224
+ const file = join(projectsDir, dirName, ent.name);
225
+ let mtimeMs;
226
+ try {
227
+ mtimeMs = statSync(file).mtimeMs;
228
+ } catch {
229
+ continue;
230
+ }
231
+ if (mtimeMs < cutoff) continue; // week-old sessions are history, not presence
232
+ candidates.push({ id, file, mtimeMs });
233
+ }
234
+ }
235
+ // Newest first, then verify only as many as the cap still has room for —
236
+ // the verification read is the expensive step, so it is not spent on
237
+ // sessions the report would drop anyway.
238
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.id < b.id ? -1 : 1));
239
+ const room = Math.max(0, REPORT_CAP - Math.min(live.length, REPORT_CAP));
240
+ const endedIds = new Set();
241
+ for (const cand of candidates) {
242
+ if (ended.length >= room) break;
243
+ if (endedIds.has(cand.id)) continue; // one row per session, whatever dir names it
244
+ endedIds.add(cand.id);
245
+ const rec = firstCwdRecord(cand.file);
246
+ if (!rec) continue;
247
+ let cwd;
248
+ try {
249
+ cwd = realpathSync(rec.cwd);
250
+ } catch {
251
+ continue;
252
+ }
253
+ if (!ours(cwd)) continue;
254
+ ended.push({
255
+ id: cand.id,
256
+ cwd,
257
+ live: false,
258
+ lastActiveAt: new Date(cand.mtimeMs).toISOString(),
259
+ ...(typeof rec.gitBranch === 'string' && rec.gitBranch ? { branch: rec.gitBranch } : {}),
260
+ });
261
+ }
262
+ } catch {
263
+ /* presence must never throw into the poll loop — report what was gathered */
264
+ }
265
+ const claude = [...live.slice(0, REPORT_CAP), ...ended];
266
+ // agy rides in whatever room the cap leaves — Claude sessions first, they
267
+ // are the ones adoption serves best (fork, never move).
268
+ const agy = scanAgyConversations({ repoRoot, excludeDirs }).slice(
269
+ 0,
270
+ Math.max(0, REPORT_CAP - claude.length)
271
+ );
272
+ return [...claude, ...agy];
273
+ }
274
+
275
+ // ── Antigravity (agy) ──────────────────────────────────────────────────────
276
+ //
277
+ // agy's store is nothing like Claude's: one SQLite db per conversation at
278
+ // ~/.gemini/antigravity-cli/conversations/<uuid>.db (global, not cwd-keyed),
279
+ // no per-pid liveness registry that survives contact (the presence/*.lock
280
+ // files sit untouched by real runs — measured), and the only cwd mapping is
281
+ // cache/last_conversations.json: {cwd → the LAST conversation run there}.
282
+ // So the honest agy report is a SUBSET — the last conversation per directory
283
+ // inside this repo — and that is exactly the one `agy --continue` would give
284
+ // the person at that keyboard, i.e. the one worth offering to adopt.
285
+
286
+ const AGY_DIR = () => join(homedir(), '.gemini', 'antigravity-cli');
287
+ const AGY_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
288
+
289
+ /** Newest write to the conversation's store — the wal carries recent turns,
290
+ * so its mtime (not the db's) is the real "last active" (measured: a resume
291
+ * touched db+wal, and never the presence lock). 0 = no such conversation. */
292
+ function agyLastWriteMs(id) {
293
+ if (!AGY_UUID_RE.test(id)) return 0;
294
+ let newest = 0;
295
+ for (const suffix of ['.db', '.db-wal']) {
296
+ try {
297
+ const t = statSync(join(AGY_DIR(), 'conversations', `${id}${suffix}`)).mtimeMs;
298
+ if (t > newest) newest = t;
299
+ } catch {
300
+ /* absent half is fine — the db alone still answers */
301
+ }
302
+ }
303
+ return newest;
304
+ }
305
+
306
+ /** Any agy process on the machine right now? /proc comm scan — cheap at the
307
+ * 60s cadence, and the only liveness signal agy leaves (locks are inert). */
308
+ function agyProcessAlive() {
309
+ try {
310
+ for (const name of readdirSync('/proc')) {
311
+ if (!/^\d+$/.test(name)) continue;
312
+ try {
313
+ if (readFileSync(`/proc/${name}/comm`, 'utf8').trim() === 'agy') return true;
314
+ } catch {
315
+ /* raced exit — keep scanning */
316
+ }
317
+ }
318
+ } catch {
319
+ /* no /proc — call nothing live rather than everything */
320
+ }
321
+ return false;
322
+ }
323
+
324
+ /**
325
+ * Is this agy conversation being driven RIGHT NOW? agy cannot answer
326
+ * per-conversation, so this is the conservative composite: an agy process
327
+ * exists AND this conversation's store was written in the last 10 minutes.
328
+ * Adoption is a MOVE for agy (no fork exists — measured, "trajectory not
329
+ * found" on a renamed copy), so refusing a maybe-live conversation for a few
330
+ * minutes costs a retry; adopting an actually-live one puts two drivers on
331
+ * one store.
332
+ */
333
+ const AGY_LIVE_WINDOW_MS = 10 * 60 * 1000;
334
+ export function isAgyConversationLive(id) {
335
+ try {
336
+ if (!agyProcessAlive()) return false;
337
+ const t = agyLastWriteMs(id);
338
+ return t > 0 && Date.now() - t < AGY_LIVE_WINDOW_MS;
339
+ } catch {
340
+ return false;
341
+ }
342
+ }
343
+
344
+ /** The repo's agy conversations, via the cwd registry — see the section
345
+ * comment for why this is deliberately a subset. */
346
+ function scanAgyConversations({ repoRoot, excludeDirs = [] }) {
347
+ const out = [];
348
+ try {
349
+ let realRoot;
350
+ try {
351
+ realRoot = realpathSync(repoRoot);
352
+ } catch {
353
+ return out;
354
+ }
355
+ const excludes = [];
356
+ for (const d of excludeDirs) {
357
+ if (!d) continue;
358
+ try {
359
+ excludes.push(realpathSync(d));
360
+ } catch {
361
+ excludes.push(String(d));
362
+ }
363
+ }
364
+ const ours = (p) => inside(p, realRoot) && !excludes.some((e) => inside(p, e));
365
+ const raw = readFileSync(join(AGY_DIR(), 'cache', 'last_conversations.json'), 'utf8');
366
+ const map = JSON.parse(raw);
367
+ if (!map || typeof map !== 'object') return out;
368
+ const cutoff = Date.now() - SEVEN_DAYS_MS;
369
+ const processUp = agyProcessAlive();
370
+ for (const [cwd, id] of Object.entries(map)) {
371
+ if (typeof id !== 'string' || !AGY_UUID_RE.test(id)) continue;
372
+ let real;
373
+ try {
374
+ real = realpathSync(cwd);
375
+ } catch {
376
+ continue; // the directory is gone — nothing to point a tab at
377
+ }
378
+ if (!ours(real)) continue;
379
+ const lastMs = agyLastWriteMs(id);
380
+ if (!lastMs || lastMs < cutoff) continue;
381
+ out.push({
382
+ id,
383
+ cwd: real,
384
+ live: processUp && Date.now() - lastMs < AGY_LIVE_WINDOW_MS,
385
+ lastActiveAt: new Date(lastMs).toISOString(),
386
+ runtime: 'antigravity',
387
+ });
388
+ }
389
+ out.sort(
390
+ (a, b) =>
391
+ (b.lastActiveAt < a.lastActiveAt ? -1 : b.lastActiveAt > a.lastActiveAt ? 1 : 0) ||
392
+ (a.id < b.id ? -1 : 1)
393
+ );
394
+ } catch {
395
+ /* no agy on this machine, or an unreadable registry — nothing to report */
396
+ }
397
+ return out;
398
+ }
@@ -486,6 +486,41 @@ the way, say so — fixing it is allowed if it's small and obviously wanted.
486
486
 
487
487
  Write plain Markdown for a person watching a live session.`;
488
488
 
489
+ /**
490
+ * The PLAIN tab — a work session on a runtime that cannot mount MCP
491
+ * (Antigravity: its server list is machine-wide, measured). No Flowviant
492
+ * tools means no streaming, no cards, no purpose line — and the product
493
+ * stays honest anyway: the final answer is delivered by the daemon's own
494
+ * report, an uncarded session's rail says "no card yet" (a readout, not a
495
+ * failure), and ship-time reconciliation turns every branch commit into the
496
+ * ledger's record. What this prompt must NOT do is pretend the tools exist,
497
+ * or apologize for their absence every turn.
498
+ */
499
+ export const SYSTEM_WORK_PLAIN = `You are the human's own coding agent, working WITH them in their repository.
500
+ This is a persistent session — a tab they keep open — and it should feel like
501
+ working in a terminal: they talk, you work.
502
+
503
+ MECHANICS OF THIS TAB:
504
+
505
+ 1. THIS WORKTREE IS THE SESSION. You are on this tab's own branch. Edit freely,
506
+ commit as coherent units complete — small, honest commits with real
507
+ messages. Uncommitted state survives between turns; this directory is yours.
508
+ 2. YOUR FINAL MESSAGE IS YOUR REPLY. It is delivered into the tab when the turn
509
+ ends — there is no live streaming from this runtime, so make the final
510
+ message the complete, self-contained report of what you did and found.
511
+ 3. YOU HAVE NO FLOWVIANT TOOLS in this session — no cards, no ledger calls.
512
+ Don't mention or simulate them. Your commits ARE your record: when this
513
+ tab's branch ships, every commit is reconciled onto the project ledger.
514
+ 4. NEVER merge to main, deploy, or force-push unless the human explicitly says
515
+ so in this conversation. Branch pushes are fine when asked. Shipping is
516
+ their word to say, not yours to infer.
517
+
518
+ POSTURE: terminal, not ticket. Don't ask permission to look at things. Ground
519
+ claims in files you opened. When they ask a question, answer it; when they ask
520
+ for work, do it.
521
+
522
+ Write plain Markdown for a person reading your reply in a chat tab.`;
523
+
489
524
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
490
525
  // The speaker is the tab's OWNER — the same person who owns this machine —
491
526
  // so this is the one prompt whose author is fully trusted. The fence stays
@@ -497,6 +532,14 @@ export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName
497
532
  `${fence('WHAT THEY SAID', message)}\n\n` +
498
533
  `Stream your reply with stream_session_turn as you work.`;
499
534
 
535
+ /** The plain tab's kickoff: no session id (there is no tool to pass it to)
536
+ * and no streaming instruction — the final message is the reply. */
537
+ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =>
538
+ `Continue the session${sessionName ? ` "${sessionName}"` : ''}.\n\n` +
539
+ `${fence('WHO IS TALKING', askedByName || 'the tab owner')}\n\n` +
540
+ `${fence('WHAT THEY SAID', message)}\n\n` +
541
+ `Reply with your complete report when the work is done.`;
542
+
500
543
  /**
501
544
  * A quick edit running ALONGSIDE the task's own agent.
502
545
  *