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.
- package/bin/lib/claude.mjs +19 -1
- package/bin/lib/fleet.mjs +75 -0
- package/bin/lib/localSessions.mjs +398 -0
- package/bin/lib/prompts.mjs +43 -0
- package/bin/lib/runtimes.mjs +51 -10
- package/bin/lib/work.mjs +538 -41
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
+
}
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -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
|
*
|