@quantiya/codevibe-antigravity-plugin 2.0.60 → 2.0.62
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/README.md +1 -1
- package/dist/server.js +938 -333
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -49,7 +49,7 @@ var fs5 = __toESM(require("fs"));
|
|
|
49
49
|
var os5 = __toESM(require("os"));
|
|
50
50
|
var import_child_process3 = require("child_process");
|
|
51
51
|
var import_util3 = require("util");
|
|
52
|
-
var
|
|
52
|
+
var import_codevibe_core6 = require("@quantiya/codevibe-core");
|
|
53
53
|
|
|
54
54
|
// src/logger.ts
|
|
55
55
|
var import_os = __toESM(require("os"));
|
|
@@ -95,14 +95,14 @@ var HttpApi = class {
|
|
|
95
95
|
if (this.server) return this.assignedPort;
|
|
96
96
|
this.server = http.createServer((req, res) => this.dispatch(req, res));
|
|
97
97
|
try {
|
|
98
|
-
await new Promise((
|
|
98
|
+
await new Promise((resolve2, reject) => {
|
|
99
99
|
const onError = (err) => {
|
|
100
100
|
this.server?.off("listening", onListening);
|
|
101
101
|
reject(err);
|
|
102
102
|
};
|
|
103
103
|
const onListening = () => {
|
|
104
104
|
this.server?.off("error", onError);
|
|
105
|
-
|
|
105
|
+
resolve2();
|
|
106
106
|
};
|
|
107
107
|
this.server.once("error", onError);
|
|
108
108
|
this.server.once("listening", onListening);
|
|
@@ -135,7 +135,7 @@ var HttpApi = class {
|
|
|
135
135
|
const srv = this.server;
|
|
136
136
|
if (!srv) return;
|
|
137
137
|
this.server = null;
|
|
138
|
-
await new Promise((
|
|
138
|
+
await new Promise((resolve2) => srv.close(() => resolve2()));
|
|
139
139
|
if (this.portFilePath) {
|
|
140
140
|
try {
|
|
141
141
|
await fs.promises.unlink(this.portFilePath);
|
|
@@ -205,23 +205,20 @@ var HttpApi = class {
|
|
|
205
205
|
this.sendJson(res, 400, { success: false, error: "invalid JSON" });
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
|
-
if (!payload
|
|
209
|
-
this.sendJson(res, 400, {
|
|
210
|
-
success: false,
|
|
211
|
-
error: "missing required fields: sessionId, type, source, content"
|
|
212
|
-
});
|
|
213
|
-
return;
|
|
214
|
-
}
|
|
215
|
-
if (!isValidEventType(payload.type) || !isValidEventSource(payload.source)) {
|
|
208
|
+
if (!isHttpEventPayload(payload)) {
|
|
216
209
|
this.sendJson(res, 400, {
|
|
217
210
|
success: false,
|
|
218
|
-
error: "invalid
|
|
211
|
+
error: "invalid ordinary desktop event payload"
|
|
219
212
|
});
|
|
220
213
|
return;
|
|
221
214
|
}
|
|
222
215
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
216
|
+
const result = await this.handlers.pushEvent(payload);
|
|
217
|
+
if (result.status === "DROPPED") {
|
|
218
|
+
this.sendJson(res, 503, { success: false, error: "event was not admitted", data: result });
|
|
219
|
+
} else {
|
|
220
|
+
this.sendJson(res, result.status === "PENDING" ? 202 : 200, { success: true, data: result });
|
|
221
|
+
}
|
|
225
222
|
} catch (err) {
|
|
226
223
|
logger.error("pushEvent failed in /event handler", { error: String(err) });
|
|
227
224
|
const code = err?.code;
|
|
@@ -229,6 +226,10 @@ var HttpApi = class {
|
|
|
229
226
|
this.sendJson(res, 404, { success: false, error: "session not found" });
|
|
230
227
|
return;
|
|
231
228
|
}
|
|
229
|
+
if (code === "INVALID_SYNC_INPUT") {
|
|
230
|
+
this.sendJson(res, 400, { success: false, error: "unsupported event payload; use its dedicated producer" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
232
233
|
this.sendJson(res, 500, { success: false, error: "event push failed" });
|
|
233
234
|
}
|
|
234
235
|
}
|
|
@@ -263,12 +264,13 @@ function isValidEventType(value) {
|
|
|
263
264
|
if (typeof value !== "string") return false;
|
|
264
265
|
return Object.values(import_codevibe_core2.EventType).includes(value);
|
|
265
266
|
}
|
|
266
|
-
function
|
|
267
|
-
if (typeof value !== "
|
|
268
|
-
|
|
267
|
+
function isHttpEventPayload(value) {
|
|
268
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
269
|
+
const row = value;
|
|
270
|
+
return !Object.keys(row).some((key) => !["sessionId", "type", "source", "content", "metadata"].includes(key)) && typeof row.sessionId === "string" && /^[A-Za-z0-9_-]{1,256}$/.test(row.sessionId) && isValidEventType(row.type) && row.source === import_codevibe_core2.EventSource.DESKTOP && typeof row.content === "string";
|
|
269
271
|
}
|
|
270
272
|
async function readBody(req, maxBytes) {
|
|
271
|
-
return new Promise((
|
|
273
|
+
return new Promise((resolve2, reject) => {
|
|
272
274
|
let received = 0;
|
|
273
275
|
const chunks = [];
|
|
274
276
|
req.on("data", (chunk) => {
|
|
@@ -280,7 +282,7 @@ async function readBody(req, maxBytes) {
|
|
|
280
282
|
}
|
|
281
283
|
chunks.push(chunk);
|
|
282
284
|
});
|
|
283
|
-
req.on("end", () =>
|
|
285
|
+
req.on("end", () => resolve2(Buffer.concat(chunks).toString("utf8")));
|
|
284
286
|
req.on("error", reject);
|
|
285
287
|
});
|
|
286
288
|
}
|
|
@@ -306,8 +308,8 @@ async function boundedStep(work, ms, onTimeout) {
|
|
|
306
308
|
return Promise.reject(err);
|
|
307
309
|
}
|
|
308
310
|
})();
|
|
309
|
-
const timeout = new Promise((
|
|
310
|
-
timer = setTimeout(() =>
|
|
311
|
+
const timeout = new Promise((resolve2) => {
|
|
312
|
+
timer = setTimeout(() => resolve2("timeout"), ms);
|
|
311
313
|
if (timer.unref) timer.unref();
|
|
312
314
|
});
|
|
313
315
|
const outcome = await Promise.race([
|
|
@@ -346,6 +348,29 @@ function armShutdownWatchdog(exit, onFire, ms = SHUTDOWN_WATCHDOG_MS, durableObl
|
|
|
346
348
|
return () => clearTimeout(timer);
|
|
347
349
|
}
|
|
348
350
|
|
|
351
|
+
// src/durable-transcript-reader.ts
|
|
352
|
+
var import_codevibe_core3 = require("@quantiya/codevibe-core");
|
|
353
|
+
function readAdmittedTranscript(options) {
|
|
354
|
+
const stream = (value) => Object.freeze({ ...value, conversationId: options.conversationId });
|
|
355
|
+
return (0, import_codevibe_core3.readDesktopSyncFile)({
|
|
356
|
+
...options,
|
|
357
|
+
parse: (line) => {
|
|
358
|
+
const parsed = JSON.parse(line);
|
|
359
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) && Number.isFinite(parsed.step_index) && typeof parsed.type === "string" ? parsed : null;
|
|
360
|
+
},
|
|
361
|
+
admission: {
|
|
362
|
+
restore: (source, initial) => options.admission.restore(stream(source), initial),
|
|
363
|
+
skip: (gap, source) => options.admission.skip(gap, stream(source)),
|
|
364
|
+
admit: (event, source) => options.admission.admit({
|
|
365
|
+
conversationId: options.conversationId,
|
|
366
|
+
filePath: source.filePath,
|
|
367
|
+
byteOffset: source.startOffset,
|
|
368
|
+
event
|
|
369
|
+
}, stream(source))
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
349
374
|
// src/transcript-tailer.ts
|
|
350
375
|
var OFFSET_FLUSH_INTERVAL_MS = 3e4;
|
|
351
376
|
var ERROR_LOG_MIN_INTERVAL_MS = 1e4;
|
|
@@ -355,6 +380,30 @@ var CONVERSATION_DIR_REGEX = /^[0-9a-fA-F-]{36}$/;
|
|
|
355
380
|
var SYSTEM_GENERATED_DIR = ".system_generated";
|
|
356
381
|
var LOGS_DIR = "logs";
|
|
357
382
|
var TRANSCRIPT_BASENAME = "transcript.jsonl";
|
|
383
|
+
var DISCOVERY_TICK_MS = 250;
|
|
384
|
+
var RESUME_SWEEP_EVERY_TICKS = 4;
|
|
385
|
+
var TRANSCRIPT_POLL_INTERVAL_MS = 100;
|
|
386
|
+
var MAX_FOLLOWED_TRANSCRIPTS = 4;
|
|
387
|
+
var FOLLOW_CATCH_UP_READ_DELAY_MS = 250;
|
|
388
|
+
var CHOKIDAR_ENV_OVERRIDES = ["CHOKIDAR_USEPOLLING", "CHOKIDAR_INTERVAL"];
|
|
389
|
+
function watchOneFilePolled(target, options) {
|
|
390
|
+
const saved = CHOKIDAR_ENV_OVERRIDES.map((name) => [name, process.env[name]]);
|
|
391
|
+
for (const [name] of saved) delete process.env[name];
|
|
392
|
+
let watcher;
|
|
393
|
+
try {
|
|
394
|
+
watcher = (0, import_chokidar.watch)(target, { ...options, usePolling: true });
|
|
395
|
+
} finally {
|
|
396
|
+
for (const [name, value] of saved) {
|
|
397
|
+
if (value === void 0) delete process.env[name];
|
|
398
|
+
else process.env[name] = value;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (watcher.options?.usePolling !== true) {
|
|
402
|
+
void watcher.close().catch(() => void 0);
|
|
403
|
+
throw new Error("chokidar did not honour usePolling; refusing a native fs.watch");
|
|
404
|
+
}
|
|
405
|
+
return watcher;
|
|
406
|
+
}
|
|
358
407
|
function defaultPaths() {
|
|
359
408
|
const home = os2.homedir();
|
|
360
409
|
return {
|
|
@@ -365,9 +414,41 @@ function defaultPaths() {
|
|
|
365
414
|
var TranscriptTailer = class extends import_events.EventEmitter {
|
|
366
415
|
constructor(options = {}) {
|
|
367
416
|
super();
|
|
368
|
-
this.watcher = null;
|
|
369
417
|
this.isWatching = false;
|
|
370
418
|
this.isShuttingDown = false;
|
|
419
|
+
this.ownedReads = /* @__PURE__ */ new Set();
|
|
420
|
+
this.retryTimers = /* @__PURE__ */ new Map();
|
|
421
|
+
/** Transcripts currently followed (stat-polled), keyed by conversation. */
|
|
422
|
+
this.followed = /* @__PURE__ */ new Map();
|
|
423
|
+
/** Conversations released by the follow cap during this lifetime. Their
|
|
424
|
+
* offsets stay protected from LRU eviction (as activeConversations are), so
|
|
425
|
+
* a later re-follow resumes exactly where it left off instead of replaying
|
|
426
|
+
* history. Cleared when its transcript goes away (unlink, confirmed
|
|
427
|
+
* absence, directory removal), when the sweep resets a never-read cursor
|
|
428
|
+
* for a replaced file, and on start(). A replacement found by the read
|
|
429
|
+
* chain keeps it: that read consumes the new file (Stage 2 R1-F6). */
|
|
430
|
+
this.releasedConversations = /* @__PURE__ */ new Set();
|
|
431
|
+
/** Last inode seen per transcript path by readNewLines: a different inode
|
|
432
|
+
* is a replacement (delete-then-recreate or atomic rename) and resets the
|
|
433
|
+
* cursor, as the former tree watch's `unlink` did. */
|
|
434
|
+
this.fileInodes = /* @__PURE__ */ new Map();
|
|
435
|
+
/** Per-path count of cursor resets (replacement, deletion, directory
|
|
436
|
+
* removal, unlink). A read in flight that sees the count change discards
|
|
437
|
+
* its stale cursor instead of writing it back, and re-queues itself so the
|
|
438
|
+
* new file is read from byte 0 (Stage 1 r7 S1-F14 / NB-K). */
|
|
439
|
+
this.cursorResets = /* @__PURE__ */ new Map();
|
|
440
|
+
/** Conversation directories seen in the brain root (snapshot at start, then
|
|
441
|
+
* maintained by the discovery tick). */
|
|
442
|
+
this.knownConversationDirs = /* @__PURE__ */ new Set();
|
|
443
|
+
/** Conversations created after start whose transcript does not exist yet;
|
|
444
|
+
* checked on every discovery tick. */
|
|
445
|
+
this.pendingTranscripts = /* @__PURE__ */ new Map();
|
|
446
|
+
/** Last swept stat of each unfollowed conversation's transcript (null when
|
|
447
|
+
* absent), for the resume sweep. */
|
|
448
|
+
this.transcriptSnapshots = /* @__PURE__ */ new Map();
|
|
449
|
+
this.rootMtimeMs = 0;
|
|
450
|
+
this.discoveryTimer = null;
|
|
451
|
+
this.discoveryTickCount = 0;
|
|
371
452
|
/** Error-log rate limiting (incident 2026-09-22 — a wedged watcher wrote
|
|
372
453
|
* 356,298 identical EMFILE errors / 96 MB before the daemon was killed). */
|
|
373
454
|
this.lastErrorSignature = null;
|
|
@@ -394,11 +475,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
394
475
|
const defaults = defaultPaths();
|
|
395
476
|
this.brainRoot = options.brainRoot ?? defaults.brainRoot;
|
|
396
477
|
this.offsetFilePath = options.offsetFilePath ?? defaults.offsetFilePath;
|
|
397
|
-
this.
|
|
478
|
+
this.admission = options.admission;
|
|
479
|
+
if (this.admission && (typeof this.admission.restore !== "function" || typeof this.admission.admit !== "function" || typeof this.admission.skip !== "function")) throw new TypeError("Durable transcript admission requires restore, admit and skip");
|
|
398
480
|
}
|
|
399
481
|
/**
|
|
400
|
-
* Start
|
|
401
|
-
* then
|
|
482
|
+
* Start following transcripts. Loads persisted offsets, prunes orphans,
|
|
483
|
+
* snapshots the brain, then runs the discovery tick. Opens no directory
|
|
484
|
+
* watch (see the file header).
|
|
402
485
|
*/
|
|
403
486
|
start() {
|
|
404
487
|
if (this.isWatching) {
|
|
@@ -407,6 +490,8 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
407
490
|
}
|
|
408
491
|
this.startTime = Date.now();
|
|
409
492
|
this.readGeneration++;
|
|
493
|
+
for (const timer of this.retryTimers.values()) clearTimeout(timer);
|
|
494
|
+
this.retryTimers.clear();
|
|
410
495
|
this.loadPersistedOffsets();
|
|
411
496
|
this.pruneOrphanedOffsets();
|
|
412
497
|
if (!fs2.existsSync(this.brainRoot)) {
|
|
@@ -420,97 +505,35 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
420
505
|
});
|
|
421
506
|
}
|
|
422
507
|
}
|
|
508
|
+
this.knownConversationDirs.clear();
|
|
509
|
+
this.pendingTranscripts.clear();
|
|
510
|
+
this.transcriptSnapshots.clear();
|
|
511
|
+
this.releasedConversations.clear();
|
|
512
|
+
this.discoveryTickCount = 0;
|
|
513
|
+
this.rootMtimeMs = this.statMtimeMs(this.brainRoot) ?? 0;
|
|
514
|
+
for (const conversationId of this.listConversationDirs() ?? []) {
|
|
515
|
+
this.knownConversationDirs.add(conversationId);
|
|
516
|
+
this.transcriptSnapshots.set(
|
|
517
|
+
conversationId,
|
|
518
|
+
this.snapshotTranscript(this.transcriptPathFor(conversationId))
|
|
519
|
+
);
|
|
520
|
+
}
|
|
423
521
|
logger.info("Starting transcript tailer", {
|
|
424
522
|
brainRoot: this.brainRoot,
|
|
425
523
|
offsetFile: this.offsetFilePath,
|
|
426
|
-
loadedOffsets: this.offsets.size
|
|
427
|
-
|
|
428
|
-
this.watcher = (0, import_chokidar.watch)(this.brainRoot, {
|
|
429
|
-
persistent: true,
|
|
430
|
-
ignoreInitial: true,
|
|
431
|
-
depth: 4,
|
|
432
|
-
awaitWriteFinish: false,
|
|
433
|
-
// partial-line carryover handles partial writes
|
|
434
|
-
usePolling: this.usePolling,
|
|
435
|
-
interval: this.usePolling ? 50 : void 0,
|
|
436
|
-
// R24 — admit ONLY the exact path shape that can lead to a transcript.
|
|
437
|
-
//
|
|
438
|
-
// THE DEFECT THIS REPLACES: the previous predicate returned `false` for
|
|
439
|
-
// EVERY directory ("allow directories so chokidar can descend into
|
|
440
|
-
// them"), so chokidar descended into the whole brain. Measured on a real
|
|
441
|
-
// machine 2026-09-22: **40,345 directories across 234 conversations**
|
|
442
|
-
// (7.8 GB), on chokidar's kqueue backend which needs roughly one file
|
|
443
|
-
// descriptor per watched directory. The watcher exhausted its descriptors
|
|
444
|
-
// and threw EMFILE continuously — 77,359 errors in a single 28-minute
|
|
445
|
-
// daemon start, 89% of the whole log file across the day.
|
|
446
|
-
//
|
|
447
|
-
// That was not merely wasteful, it was a CORRECTNESS failure, and the
|
|
448
|
-
// exact shape matters because it is worse than a slow start:
|
|
449
|
-
//
|
|
450
|
-
// 1. The initial scan storms for tens of minutes.
|
|
451
|
-
// 2. When it finally completes, `ready` fires and
|
|
452
|
-
// backfillActiveTranscripts() does a ONE-SHOT catch-up read. That
|
|
453
|
-
// uses its own readdirSync, NOT the watcher, so it succeeds — mobile
|
|
454
|
-
// shows a sudden burst of backlog.
|
|
455
|
-
// 3. But the per-file WATCH was never established, because the
|
|
456
|
-
// descriptors were spent on old conversations during the scan. So no
|
|
457
|
-
// `change` event ever arrives again and the tailer STALLS PERMANENTLY
|
|
458
|
-
// at whatever offset the backfill reached.
|
|
459
|
-
//
|
|
460
|
-
// Measured live on the owner's machine while this fix was being written,
|
|
461
|
-
// and RETAINED — every figure here comes from that capture, not from
|
|
462
|
-
// recollection:
|
|
463
|
-
// .hard-gate/r24-watch-scope/evidence/prefix-descriptor-accounting.log
|
|
464
|
-
// The daemon held file descriptors for 231 OLD conversations and **zero**
|
|
465
|
-
// for the active one, while its offset sat frozen at 987,484 bytes
|
|
466
|
-
// against a transcript of 1,656,028 on disk — 668 KB behind and not
|
|
467
|
-
// advancing, with the EMFILE rate back to 0/sec and the daemon otherwise
|
|
468
|
-
// idle at 0.3% CPU. The gap WIDENED between two samplings twenty minutes
|
|
469
|
-
// apart (231 KB, then 668 KB), which is what distinguishes a permanent
|
|
470
|
-
// stall from slow catch-up.
|
|
471
|
-
//
|
|
472
|
-
// User-visible result: a burst of backlog, then silence. Interactive
|
|
473
|
-
// prompts degrade to "Prompt candidate fired but no active conversation
|
|
474
|
-
// known {observedConvCount:0}", which renders on mobile as "a prompt is
|
|
475
|
-
// waiting in your desktop terminal" with NO selectable options — the user
|
|
476
|
-
// can see that a prompt exists but cannot answer it, which is the entire
|
|
477
|
-
// purpose of the mobile companion.
|
|
478
|
-
//
|
|
479
|
-
// NOTE this description was corrected twice. It was first written as
|
|
480
|
-
// "permanent", then weakened to "a bounded dead window" after observing a
|
|
481
|
-
// recovery, then corrected again once the descriptor accounting above was
|
|
482
|
-
// actually measured. The recovery was the one-shot backfill of step 2, not
|
|
483
|
-
// the watcher healing. Stage 1 NB-4 predicted this sub-case before it was
|
|
484
|
-
// measured.
|
|
485
|
-
//
|
|
486
|
-
// THE FIX: the transcript lives at exactly one shape, four levels deep:
|
|
487
|
-
// brain/<uuid>/.system_generated/logs/transcript.jsonl
|
|
488
|
-
// so decide purely LEXICALLY on that shape and refuse everything else.
|
|
489
|
-
// Same machine, after: **699** directories (1 root + 234 conversations +
|
|
490
|
-
// 232 .system_generated + 232 logs) — a 57x reduction that also excludes
|
|
491
|
-
// the actual bulk: scratch/ (230 dirs), .user_uploaded/ (232) and the
|
|
492
|
-
// thousands of .system_generated/tasks/ dirs, none of which can ever hold
|
|
493
|
-
// a transcript.
|
|
494
|
-
//
|
|
495
|
-
// Deciding lexically also removes the per-path `fs.statSync` the old
|
|
496
|
-
// predicate ran during traversal, which was its own CPU cost on a tree
|
|
497
|
-
// this size.
|
|
498
|
-
ignored: (filePath) => this.isOutsideTranscriptPath(filePath)
|
|
499
|
-
});
|
|
500
|
-
this.watcher.on("add", (filePath) => this.onFileAdded(filePath));
|
|
501
|
-
this.watcher.on("addDir", (dirPath) => this.onDirAdded(dirPath));
|
|
502
|
-
this.watcher.on("change", (filePath) => this.onFileChanged(filePath));
|
|
503
|
-
this.watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
|
|
504
|
-
this.watcher.on("error", (error) => {
|
|
505
|
-
this.emitError(error, "Chokidar error");
|
|
506
|
-
});
|
|
507
|
-
this.watcher.on("ready", () => {
|
|
508
|
-
logger.info("Transcript tailer ready (chokidar initial scan complete)");
|
|
509
|
-
this.backfillActiveTranscripts();
|
|
524
|
+
loadedOffsets: this.offsets.size,
|
|
525
|
+
knownConversations: this.knownConversationDirs.size
|
|
510
526
|
});
|
|
527
|
+
this.discoveryTimer = setInterval(() => this.discoveryTick(), DISCOVERY_TICK_MS);
|
|
528
|
+
if (this.discoveryTimer.unref) this.discoveryTimer.unref();
|
|
511
529
|
this.flushTimer = setInterval(() => this.flushOffsets(), OFFSET_FLUSH_INTERVAL_MS);
|
|
512
530
|
if (this.flushTimer.unref) this.flushTimer.unref();
|
|
513
531
|
this.isWatching = true;
|
|
532
|
+
const generation = this.readGeneration;
|
|
533
|
+
setImmediate(() => {
|
|
534
|
+
if (!this.isWatching || this.isShuttingDown || generation !== this.readGeneration) return;
|
|
535
|
+
this.backfillActiveTranscripts();
|
|
536
|
+
});
|
|
514
537
|
}
|
|
515
538
|
/**
|
|
516
539
|
* (#638 G4) SAFE 'error' emission. `EventEmitter.emit('error', …)` with NO
|
|
@@ -550,6 +573,8 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
550
573
|
if (!this.isWatching) return;
|
|
551
574
|
this.isShuttingDown = true;
|
|
552
575
|
this.readGeneration++;
|
|
576
|
+
for (const timer of this.retryTimers.values()) clearTimeout(timer);
|
|
577
|
+
this.retryTimers.clear();
|
|
553
578
|
if (this.flushTimer) {
|
|
554
579
|
clearInterval(this.flushTimer);
|
|
555
580
|
this.flushTimer = null;
|
|
@@ -559,10 +584,18 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
559
584
|
} catch (error) {
|
|
560
585
|
logger.warn("Final offset flush failed", { error });
|
|
561
586
|
}
|
|
562
|
-
if (this.
|
|
563
|
-
|
|
587
|
+
if (this.discoveryTimer) {
|
|
588
|
+
clearInterval(this.discoveryTimer);
|
|
589
|
+
this.discoveryTimer = null;
|
|
590
|
+
}
|
|
591
|
+
const watchers = Array.from(this.followed.values(), (f) => f.watcher);
|
|
592
|
+
this.followed.clear();
|
|
593
|
+
this.pendingTranscripts.clear();
|
|
594
|
+
this.knownConversationDirs.clear();
|
|
595
|
+
this.transcriptSnapshots.clear();
|
|
596
|
+
if (watchers.length > 0) {
|
|
564
597
|
const outcome = await boundedStep(
|
|
565
|
-
() =>
|
|
598
|
+
() => Promise.all(watchers.map((w) => w.close())),
|
|
566
599
|
WATCHER_CLOSE_TIMEOUT_MS,
|
|
567
600
|
() => logger.warn("chokidar watcher.close() timed out \u2014 abandoning it", {
|
|
568
601
|
timeoutMs: WATCHER_CLOSE_TIMEOUT_MS
|
|
@@ -571,10 +604,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
571
604
|
if (outcome === "timeout") {
|
|
572
605
|
logger.warn("Transcript tailer watcher abandoned; relying on shutdown watchdog");
|
|
573
606
|
}
|
|
574
|
-
this.watcher = null;
|
|
575
607
|
}
|
|
576
608
|
this.activeReads.clear();
|
|
577
609
|
this.pendingReads.clear();
|
|
610
|
+
this.cursorResets.clear();
|
|
578
611
|
this.isWatching = false;
|
|
579
612
|
this.isShuttingDown = false;
|
|
580
613
|
logger.info(
|
|
@@ -584,6 +617,10 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
584
617
|
this.lastErrorSignature = null;
|
|
585
618
|
this.suppressedErrorCount = 0;
|
|
586
619
|
}
|
|
620
|
+
/** Server shutdown joins reads it admitted before retiring the session. */
|
|
621
|
+
async waitForReads() {
|
|
622
|
+
await Promise.allSettled([...this.ownedReads]);
|
|
623
|
+
}
|
|
587
624
|
/**
|
|
588
625
|
* Best-effort sync flush. Safe to call from a process EXIT trap.
|
|
589
626
|
* (The async stop() above is preferred when a regular shutdown is feasible.)
|
|
@@ -603,7 +640,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
603
640
|
getActiveConversations() {
|
|
604
641
|
return Array.from(this.activeConversations);
|
|
605
642
|
}
|
|
606
|
-
// ───
|
|
643
|
+
// ─── follower event handlers ─────────────────────────────────────────────
|
|
607
644
|
onFileAdded(filePath) {
|
|
608
645
|
if (this.isShuttingDown) return;
|
|
609
646
|
const conversationId = this.extractConversationId(filePath);
|
|
@@ -625,57 +662,292 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
625
662
|
this.emit("transcript-add", filePath, conversationId);
|
|
626
663
|
void this.scheduleReadNewLines(filePath, conversationId);
|
|
627
664
|
}
|
|
665
|
+
// ─── Discovery (no directory watch) ─────────────────────────────────────
|
|
628
666
|
/**
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
* Purely lexical — no filesystem call — so it is cheap enough to run on every
|
|
632
|
-
* traversed path and cannot be fooled by a stat race. The only paths admitted
|
|
633
|
-
* are the ones that can lead to a transcript:
|
|
634
|
-
*
|
|
635
|
-
* <brainRoot> depth 0
|
|
636
|
-
* <brainRoot>/<uuid> depth 1
|
|
637
|
-
* <brainRoot>/<uuid>/.system_generated depth 2
|
|
638
|
-
* <brainRoot>/<uuid>/.system_generated/logs depth 3
|
|
639
|
-
* <brainRoot>/<uuid>/.system_generated/logs/transcript.jsonl depth 4
|
|
640
|
-
*
|
|
641
|
-
* Anything else — scratch/, evidence/, .user_uploaded/, the task logs, and
|
|
642
|
-
* every file that is not the transcript — is refused, which stops chokidar
|
|
643
|
-
* descending into it at all.
|
|
644
|
-
*
|
|
645
|
-
* NOTE the depth-1 UUID check is what keeps this bounded as the brain grows:
|
|
646
|
-
* a non-conversation entry at the top level can never be a conversation, so
|
|
647
|
-
* it is never traversed. `depth: 4` on the watcher remains as a second,
|
|
648
|
-
* independent bound.
|
|
667
|
+
* One discovery tick. Never throws: a throw from a timer callback is an
|
|
668
|
+
* uncaughtException, which the daemon's process guard may treat as fatal.
|
|
649
669
|
*/
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
670
|
+
discoveryTick() {
|
|
671
|
+
if (!this.isWatching || this.isShuttingDown) return;
|
|
672
|
+
try {
|
|
673
|
+
this.discoveryTickCount++;
|
|
674
|
+
const rootMtimeMs = this.statMtimeMs(this.brainRoot);
|
|
675
|
+
if (rootMtimeMs !== null && rootMtimeMs !== this.rootMtimeMs) {
|
|
676
|
+
this.rootMtimeMs = rootMtimeMs;
|
|
677
|
+
this.reconcileConversationDirs();
|
|
678
|
+
}
|
|
679
|
+
for (const [conversationId, transcriptPath] of Array.from(this.pendingTranscripts)) {
|
|
680
|
+
if (fs2.existsSync(transcriptPath)) {
|
|
681
|
+
this.pendingTranscripts.delete(conversationId);
|
|
682
|
+
this.followTranscript(conversationId, transcriptPath, "added");
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (this.discoveryTickCount % RESUME_SWEEP_EVERY_TICKS === 0) {
|
|
686
|
+
this.reconcileConversationDirs();
|
|
687
|
+
this.sweepForResumedTranscripts();
|
|
688
|
+
}
|
|
689
|
+
} catch (error) {
|
|
690
|
+
this.emitError(error, "Transcript discovery tick failed (non-fatal)");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Bring knownConversationDirs in line with the brain root's listing. */
|
|
694
|
+
reconcileConversationDirs() {
|
|
695
|
+
const present = this.listConversationDirs();
|
|
696
|
+
if (present === null) return;
|
|
697
|
+
const presentSet = new Set(present);
|
|
698
|
+
for (const conversationId of present) {
|
|
699
|
+
if (this.knownConversationDirs.has(conversationId)) continue;
|
|
700
|
+
this.knownConversationDirs.add(conversationId);
|
|
701
|
+
this.onConversationDirAdded(conversationId);
|
|
702
|
+
}
|
|
703
|
+
const tracked = /* @__PURE__ */ new Set([
|
|
704
|
+
...this.knownConversationDirs,
|
|
705
|
+
...this.pendingTranscripts.keys(),
|
|
706
|
+
...this.followed.keys()
|
|
707
|
+
]);
|
|
708
|
+
for (const conversationId of tracked) {
|
|
709
|
+
if (presentSet.has(conversationId)) continue;
|
|
710
|
+
this.knownConversationDirs.delete(conversationId);
|
|
711
|
+
this.pendingTranscripts.delete(conversationId);
|
|
712
|
+
this.transcriptSnapshots.delete(conversationId);
|
|
713
|
+
this.unfollow(conversationId, "conversation directory removed");
|
|
714
|
+
this.resetCursorForReplacement(
|
|
715
|
+
this.transcriptPathFor(conversationId),
|
|
716
|
+
conversationId,
|
|
717
|
+
"conversation directory removed"
|
|
718
|
+
);
|
|
719
|
+
this.activeConversations.delete(conversationId);
|
|
668
720
|
}
|
|
669
721
|
}
|
|
670
|
-
|
|
722
|
+
onConversationDirAdded(conversationId) {
|
|
671
723
|
if (this.isShuttingDown) return;
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (path2.resolve(expectedParent) !== path2.resolve(this.brainRoot)) return;
|
|
677
|
-
logger.info("New brain directory discovered (pre-prompt)", { dirPath, conversationId });
|
|
724
|
+
logger.info("New brain directory discovered (pre-prompt)", {
|
|
725
|
+
dirPath: path2.join(this.brainRoot, conversationId),
|
|
726
|
+
conversationId
|
|
727
|
+
});
|
|
678
728
|
this.emit("conversation-discovered", conversationId);
|
|
729
|
+
const transcriptPath = this.transcriptPathFor(conversationId);
|
|
730
|
+
this.transcriptSnapshots.set(conversationId, null);
|
|
731
|
+
if (fs2.existsSync(transcriptPath)) {
|
|
732
|
+
this.followTranscript(conversationId, transcriptPath, "added");
|
|
733
|
+
} else {
|
|
734
|
+
this.pendingTranscripts.set(conversationId, transcriptPath);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
sweepForResumedTranscripts() {
|
|
738
|
+
for (const conversationId of this.knownConversationDirs) {
|
|
739
|
+
if (this.followed.has(conversationId)) continue;
|
|
740
|
+
if (this.pendingTranscripts.has(conversationId)) continue;
|
|
741
|
+
const transcriptPath = this.transcriptPathFor(conversationId);
|
|
742
|
+
const current = this.snapshotTranscript(transcriptPath);
|
|
743
|
+
const previous = this.transcriptSnapshots.get(conversationId) ?? null;
|
|
744
|
+
if (current === null) {
|
|
745
|
+
if (previous !== null && this.isAbsent(transcriptPath)) {
|
|
746
|
+
this.transcriptSnapshots.set(conversationId, null);
|
|
747
|
+
this.resetCursorForReplacement(transcriptPath, conversationId, "deleted while unfollowed");
|
|
748
|
+
}
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
this.transcriptSnapshots.set(conversationId, current);
|
|
752
|
+
if (previous !== null && current.ino !== previous.ino && !this.fileInodes.has(transcriptPath)) {
|
|
753
|
+
this.resetCursorForReplacement(transcriptPath, conversationId, "inode changed while unfollowed");
|
|
754
|
+
this.emit("transcript-rotated", transcriptPath, conversationId);
|
|
755
|
+
}
|
|
756
|
+
if (previous === null || current.size !== previous.size || current.mtimeMs !== previous.mtimeMs || current.ino !== previous.ino || this.hasUnreadTail(conversationId, transcriptPath, current.size)) {
|
|
757
|
+
this.followTranscript(conversationId, transcriptPath, "resumed");
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* A conversation released by the follow cap in this lifetime whose file
|
|
763
|
+
* holds bytes past its cursor: its eviction drain did not finish (e.g. a
|
|
764
|
+
* transient stat/open error), so the final message would otherwise wait for
|
|
765
|
+
* the next write (Stage 2 R1-F8). Limited to released conversations — the
|
|
766
|
+
* offsets file also holds other daemons' cursors (F2), and following those
|
|
767
|
+
* on stale offsets would pull another session's history in.
|
|
768
|
+
*/
|
|
769
|
+
hasUnreadTail(conversationId, transcriptPath, size) {
|
|
770
|
+
if (!this.releasedConversations.has(conversationId)) return false;
|
|
771
|
+
if (this.activeReads.has(transcriptPath)) return false;
|
|
772
|
+
const entry = this.offsets.get(transcriptPath);
|
|
773
|
+
if (!entry) return false;
|
|
774
|
+
return size > entry.byteOffset + Buffer.byteLength(entry.carryover, "utf-8");
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Start following one transcript by stat-polling that file (no `fs.watch`
|
|
778
|
+
* handle), then route it through the same handler a filesystem event would
|
|
779
|
+
* have: `add` for a transcript created after start, `change` for an existing
|
|
780
|
+
* one (first sight of it is treated as a resume).
|
|
781
|
+
*/
|
|
782
|
+
followTranscript(conversationId, transcriptPath, how) {
|
|
783
|
+
if (this.isShuttingDown || !this.isWatching) return;
|
|
784
|
+
if (this.followed.has(conversationId)) return;
|
|
785
|
+
let watcher;
|
|
786
|
+
try {
|
|
787
|
+
watcher = watchOneFilePolled(transcriptPath, {
|
|
788
|
+
persistent: true,
|
|
789
|
+
ignoreInitial: true,
|
|
790
|
+
usePolling: true,
|
|
791
|
+
interval: TRANSCRIPT_POLL_INTERVAL_MS,
|
|
792
|
+
awaitWriteFinish: false
|
|
793
|
+
// partial-line carryover handles partial writes
|
|
794
|
+
});
|
|
795
|
+
} catch (error) {
|
|
796
|
+
this.emitError(error, "Could not follow transcript by polling");
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
while (this.followed.size >= MAX_FOLLOWED_TRANSCRIPTS) {
|
|
800
|
+
let oldestId = null;
|
|
801
|
+
let oldestMs = Number.POSITIVE_INFINITY;
|
|
802
|
+
for (const [id, f] of this.followed) {
|
|
803
|
+
if (f.lastActivityMs < oldestMs) {
|
|
804
|
+
oldestMs = f.lastActivityMs;
|
|
805
|
+
oldestId = id;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (oldestId === null) break;
|
|
809
|
+
this.unfollow(oldestId, "follow cap reached", { drainTail: true });
|
|
810
|
+
}
|
|
811
|
+
const followed = {
|
|
812
|
+
watcher,
|
|
813
|
+
transcriptPath,
|
|
814
|
+
lastActivityMs: Date.now()
|
|
815
|
+
};
|
|
816
|
+
this.followed.set(conversationId, followed);
|
|
817
|
+
const touch = () => {
|
|
818
|
+
followed.lastActivityMs = Date.now();
|
|
819
|
+
};
|
|
820
|
+
watcher.on("add", (filePath) => {
|
|
821
|
+
touch();
|
|
822
|
+
this.onFileAdded(filePath);
|
|
823
|
+
});
|
|
824
|
+
watcher.on("change", (filePath) => {
|
|
825
|
+
touch();
|
|
826
|
+
this.onFileChanged(filePath);
|
|
827
|
+
});
|
|
828
|
+
watcher.on("unlink", (filePath) => this.onFileUnlinked(filePath));
|
|
829
|
+
watcher.on("error", (error) => {
|
|
830
|
+
this.emitError(error, "Chokidar error");
|
|
831
|
+
});
|
|
832
|
+
watcher.on("ready", () => {
|
|
833
|
+
const timer = setTimeout(() => {
|
|
834
|
+
if (this.followed.get(conversationId)?.watcher !== watcher) return;
|
|
835
|
+
if (!this.activeConversations.has(conversationId)) return;
|
|
836
|
+
void this.scheduleReadNewLines(transcriptPath, conversationId);
|
|
837
|
+
}, FOLLOW_CATCH_UP_READ_DELAY_MS);
|
|
838
|
+
if (timer.unref) timer.unref();
|
|
839
|
+
});
|
|
840
|
+
logger.info("Following transcript", {
|
|
841
|
+
conversationId,
|
|
842
|
+
how,
|
|
843
|
+
followedCount: this.followed.size,
|
|
844
|
+
pollIntervalMs: TRANSCRIPT_POLL_INTERVAL_MS
|
|
845
|
+
});
|
|
846
|
+
if (how === "added") this.onFileAdded(transcriptPath);
|
|
847
|
+
else this.onFileChanged(transcriptPath);
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Stop following one transcript. Its offset is kept, its swept stat is
|
|
851
|
+
* refreshed so the resume sweep only re-follows it on a NEW write, and it
|
|
852
|
+
* leaves activeConversations so a re-follow is a resume.
|
|
853
|
+
*
|
|
854
|
+
* drainTail (eviction): read whatever is past the offset now. Otherwise a
|
|
855
|
+
* write that landed within the last poll interval would sit under the
|
|
856
|
+
* refreshed snapshot, unread until that conversation writes again — for a
|
|
857
|
+
* final message, indefinitely. An evicted conversation also joins
|
|
858
|
+
* releasedConversations, which protects its offset from LRU eviction so a
|
|
859
|
+
* re-follow never replays history. Without drainTail (its directory was
|
|
860
|
+
* removed) the offset is LRU-evictable.
|
|
861
|
+
*/
|
|
862
|
+
unfollow(conversationId, reason, opts = {}) {
|
|
863
|
+
const followed = this.followed.get(conversationId);
|
|
864
|
+
if (!followed) return;
|
|
865
|
+
if (this.knownConversationDirs.has(conversationId)) {
|
|
866
|
+
this.transcriptSnapshots.set(conversationId, this.snapshotTranscript(followed.transcriptPath));
|
|
867
|
+
}
|
|
868
|
+
if (opts.drainTail) {
|
|
869
|
+
this.releasedConversations.add(conversationId);
|
|
870
|
+
void this.scheduleReadNewLines(followed.transcriptPath, conversationId);
|
|
871
|
+
} else {
|
|
872
|
+
this.releasedConversations.delete(conversationId);
|
|
873
|
+
}
|
|
874
|
+
this.followed.delete(conversationId);
|
|
875
|
+
this.activeConversations.delete(conversationId);
|
|
876
|
+
followed.watcher.close().catch((error) => {
|
|
877
|
+
this.emitError(error, "Transcript follower close failed (non-fatal)");
|
|
878
|
+
});
|
|
879
|
+
logger.info("Stopped following transcript", { conversationId, reason });
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* The file at this path is no longer the one the cursor describes (deleted,
|
|
883
|
+
* its directory removed, or replaced by a different inode). Restart it from
|
|
884
|
+
* byte 0, as the former tree watch's `unlink` → onFileUnlinked did. The
|
|
885
|
+
* released-follower offset protection ends with the file it protected —
|
|
886
|
+
* except when the reset is made by the read that is about to consume the
|
|
887
|
+
* new file (`keepProtection`): that read advances the cursor over content it
|
|
888
|
+
* delivers, so the cursor must stay protected or a later re-follow would
|
|
889
|
+
* replay it (Stage 2 R1-F6).
|
|
890
|
+
*/
|
|
891
|
+
resetCursorForReplacement(filePath, conversationId, reason, opts = {}) {
|
|
892
|
+
const entry = this.offsets.get(filePath);
|
|
893
|
+
if (entry) {
|
|
894
|
+
logger.warn("Transcript replaced or removed; resetting offset", {
|
|
895
|
+
filePath,
|
|
896
|
+
conversationId,
|
|
897
|
+
reason,
|
|
898
|
+
previousOffset: entry.byteOffset
|
|
899
|
+
});
|
|
900
|
+
entry.byteOffset = 0;
|
|
901
|
+
entry.carryover = "";
|
|
902
|
+
entry.lastKnownSize = 0;
|
|
903
|
+
entry.lastModifiedMs = Date.now();
|
|
904
|
+
}
|
|
905
|
+
this.fileInodes.delete(filePath);
|
|
906
|
+
if (!opts.keepProtection) this.releasedConversations.delete(conversationId);
|
|
907
|
+
this.bumpCursorResets(filePath);
|
|
908
|
+
}
|
|
909
|
+
/** Only a read chain in flight can hold a stale cursor, so only then is the
|
|
910
|
+
* count kept; the chain's end prunes it (Stage 2 R1-F7). Outside resets
|
|
911
|
+
* land at an `await`, by which time the chain is registered in activeReads. */
|
|
912
|
+
bumpCursorResets(filePath) {
|
|
913
|
+
if (!this.activeReads.has(filePath)) return;
|
|
914
|
+
this.cursorResets.set(filePath, (this.cursorResets.get(filePath) ?? 0) + 1);
|
|
915
|
+
}
|
|
916
|
+
transcriptPathFor(conversationId) {
|
|
917
|
+
return path2.join(this.brainRoot, conversationId, SYSTEM_GENERATED_DIR, LOGS_DIR, TRANSCRIPT_BASENAME);
|
|
918
|
+
}
|
|
919
|
+
/** UUID-named directories directly under the brain root; null on failure. */
|
|
920
|
+
listConversationDirs() {
|
|
921
|
+
try {
|
|
922
|
+
return fs2.readdirSync(this.brainRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory() && CONVERSATION_DIR_REGEX.test(entry.name)).map((entry) => entry.name);
|
|
923
|
+
} catch {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
snapshotTranscript(transcriptPath) {
|
|
928
|
+
try {
|
|
929
|
+
const stat = fs2.statSync(transcriptPath);
|
|
930
|
+
return stat.isFile() ? { size: stat.size, mtimeMs: stat.mtimeMs, ino: stat.ino } : null;
|
|
931
|
+
} catch {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/** True only when the path is confirmed absent (ENOENT / ENOTDIR). */
|
|
936
|
+
isAbsent(filePath) {
|
|
937
|
+
try {
|
|
938
|
+
fs2.statSync(filePath);
|
|
939
|
+
return false;
|
|
940
|
+
} catch (error) {
|
|
941
|
+
const code = error.code;
|
|
942
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
statMtimeMs(dirPath) {
|
|
946
|
+
try {
|
|
947
|
+
return fs2.statSync(dirPath).mtimeMs;
|
|
948
|
+
} catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
679
951
|
}
|
|
680
952
|
/**
|
|
681
953
|
* Fires when chokidar reports a transcript.jsonl was deleted or moved
|
|
@@ -709,6 +981,9 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
709
981
|
entry.carryover = "";
|
|
710
982
|
entry.lastKnownSize = 0;
|
|
711
983
|
entry.lastModifiedMs = Date.now();
|
|
984
|
+
this.fileInodes.delete(filePath);
|
|
985
|
+
this.releasedConversations.delete(conversationId);
|
|
986
|
+
this.bumpCursorResets(filePath);
|
|
712
987
|
this.activeConversations.delete(conversationId);
|
|
713
988
|
}
|
|
714
989
|
onFileChanged(filePath) {
|
|
@@ -728,7 +1003,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
728
1003
|
}
|
|
729
1004
|
// ─── Backfill at startup ────────────────────────────────────────────────
|
|
730
1005
|
/**
|
|
731
|
-
* Walk the brain root once at startup and
|
|
1006
|
+
* Walk the brain root once at startup and follow (as a resume) any
|
|
732
1007
|
* transcript.jsonl that already exists and shows recent activity. This
|
|
733
1008
|
* covers the case where agy is already running when codevibe-agy starts.
|
|
734
1009
|
*/
|
|
@@ -740,6 +1015,13 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
740
1015
|
if (!dir.isDirectory()) continue;
|
|
741
1016
|
const conversationId = dir.name;
|
|
742
1017
|
if (!/^[0-9a-fA-F-]{36}$/.test(conversationId)) continue;
|
|
1018
|
+
if (!this.knownConversationDirs.has(conversationId)) {
|
|
1019
|
+
this.knownConversationDirs.add(conversationId);
|
|
1020
|
+
this.transcriptSnapshots.set(
|
|
1021
|
+
conversationId,
|
|
1022
|
+
this.snapshotTranscript(this.transcriptPathFor(conversationId))
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
743
1025
|
const dirPath = path2.join(this.brainRoot, conversationId);
|
|
744
1026
|
const transcriptPath = path2.join(
|
|
745
1027
|
dirPath,
|
|
@@ -777,16 +1059,16 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
777
1059
|
transcriptPath,
|
|
778
1060
|
conversationId
|
|
779
1061
|
});
|
|
780
|
-
this.
|
|
781
|
-
this.ensureOffset(transcriptPath);
|
|
782
|
-
this.emit("transcript-resume", transcriptPath, conversationId);
|
|
783
|
-
void this.scheduleReadNewLines(transcriptPath, conversationId);
|
|
1062
|
+
this.followTranscript(conversationId, transcriptPath, "resumed");
|
|
784
1063
|
} else {
|
|
785
1064
|
logger.info("Backfilling recent transcriptless directory at startup", {
|
|
786
1065
|
dirPath,
|
|
787
1066
|
conversationId
|
|
788
1067
|
});
|
|
789
1068
|
this.emit("conversation-discovered", conversationId);
|
|
1069
|
+
if (!this.followed.has(conversationId)) {
|
|
1070
|
+
this.pendingTranscripts.set(conversationId, transcriptPath);
|
|
1071
|
+
}
|
|
790
1072
|
}
|
|
791
1073
|
}
|
|
792
1074
|
} catch (error) {
|
|
@@ -807,12 +1089,23 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
807
1089
|
conversationId,
|
|
808
1090
|
error: String(error)
|
|
809
1091
|
});
|
|
1092
|
+
if (this.admission && this.isWatching && generation === this.readGeneration && !this.retryTimers.has(filePath)) {
|
|
1093
|
+
const timer = setTimeout(() => {
|
|
1094
|
+
this.retryTimers.delete(filePath);
|
|
1095
|
+
if (this.isWatching && generation === this.readGeneration) void this.scheduleReadNewLines(filePath, conversationId);
|
|
1096
|
+
}, 5e3);
|
|
1097
|
+
timer.unref();
|
|
1098
|
+
this.retryTimers.set(filePath, timer);
|
|
1099
|
+
}
|
|
810
1100
|
});
|
|
811
1101
|
this.activeReads.set(filePath, read);
|
|
1102
|
+
this.ownedReads.add(read);
|
|
812
1103
|
void read.finally(() => {
|
|
1104
|
+
this.ownedReads.delete(read);
|
|
813
1105
|
if (this.activeReads.get(filePath) === read) {
|
|
814
1106
|
this.activeReads.delete(filePath);
|
|
815
1107
|
this.pendingReads.delete(filePath);
|
|
1108
|
+
this.cursorResets.delete(filePath);
|
|
816
1109
|
}
|
|
817
1110
|
});
|
|
818
1111
|
return read;
|
|
@@ -830,6 +1123,23 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
830
1123
|
logger.warn("No offset entry \u2014 skipping read", { filePath });
|
|
831
1124
|
return;
|
|
832
1125
|
}
|
|
1126
|
+
if (this.admission) {
|
|
1127
|
+
const result = await readAdmittedTranscript({
|
|
1128
|
+
filePath,
|
|
1129
|
+
conversationId,
|
|
1130
|
+
initialOffset: entry.byteOffset,
|
|
1131
|
+
admission: this.admission,
|
|
1132
|
+
isCurrent: () => generation === this.readGeneration && !this.isShuttingDown
|
|
1133
|
+
});
|
|
1134
|
+
if (result) {
|
|
1135
|
+
if (generation !== this.readGeneration) return;
|
|
1136
|
+
entry.byteOffset = result.position.byteOffset;
|
|
1137
|
+
entry.carryover = "";
|
|
1138
|
+
entry.lastKnownSize = result.size;
|
|
1139
|
+
entry.lastModifiedMs = result.mtimeMs;
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
833
1143
|
let stat;
|
|
834
1144
|
try {
|
|
835
1145
|
stat = fs2.statSync(filePath);
|
|
@@ -837,6 +1147,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
837
1147
|
logger.warn("stat failed; file may have been deleted", { filePath, error });
|
|
838
1148
|
return;
|
|
839
1149
|
}
|
|
1150
|
+
const lastInode = this.fileInodes.get(filePath);
|
|
1151
|
+
if (lastInode !== void 0 && lastInode !== stat.ino) {
|
|
1152
|
+
this.resetCursorForReplacement(filePath, conversationId, "inode changed", { keepProtection: true });
|
|
1153
|
+
this.emit("transcript-rotated", filePath, conversationId);
|
|
1154
|
+
}
|
|
1155
|
+
this.fileInodes.set(filePath, stat.ino);
|
|
840
1156
|
if (stat.size < entry.lastKnownSize) {
|
|
841
1157
|
logger.warn("Transcript file rotated (size shrunk); resetting offset", {
|
|
842
1158
|
filePath,
|
|
@@ -849,6 +1165,12 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
849
1165
|
entry.lastKnownSize = stat.size;
|
|
850
1166
|
this.emit("transcript-rotated", filePath, conversationId);
|
|
851
1167
|
}
|
|
1168
|
+
const resetsAtStart = this.cursorResets.get(filePath) ?? 0;
|
|
1169
|
+
const staleRead = () => {
|
|
1170
|
+
if ((this.cursorResets.get(filePath) ?? 0) === resetsAtStart) return false;
|
|
1171
|
+
this.pendingReads.add(filePath);
|
|
1172
|
+
return true;
|
|
1173
|
+
};
|
|
852
1174
|
const carryoverBytes = Buffer.byteLength(entry.carryover, "utf-8");
|
|
853
1175
|
const readStart = entry.byteOffset + carryoverBytes;
|
|
854
1176
|
if (stat.size <= readStart) {
|
|
@@ -871,14 +1193,14 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
871
1193
|
let lineStartOffset = entry.byteOffset;
|
|
872
1194
|
try {
|
|
873
1195
|
for await (const chunk of stream) {
|
|
874
|
-
if (generation !== this.readGeneration) {
|
|
1196
|
+
if (generation !== this.readGeneration || staleRead()) {
|
|
875
1197
|
stream.destroy();
|
|
876
1198
|
return;
|
|
877
1199
|
}
|
|
878
1200
|
buffered += chunk;
|
|
879
1201
|
let newlineIndex = buffered.indexOf("\n");
|
|
880
1202
|
while (newlineIndex !== -1) {
|
|
881
|
-
if (generation !== this.readGeneration) {
|
|
1203
|
+
if (generation !== this.readGeneration || staleRead()) {
|
|
882
1204
|
stream.destroy();
|
|
883
1205
|
return;
|
|
884
1206
|
}
|
|
@@ -900,7 +1222,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
900
1222
|
this.emitError(error);
|
|
901
1223
|
return;
|
|
902
1224
|
}
|
|
903
|
-
if (generation !== this.readGeneration) return;
|
|
1225
|
+
if (generation !== this.readGeneration || staleRead()) return;
|
|
904
1226
|
entry.byteOffset = lineStartOffset;
|
|
905
1227
|
entry.carryover = buffered;
|
|
906
1228
|
entry.lastKnownSize = stat.size;
|
|
@@ -964,7 +1286,7 @@ var TranscriptTailer = class extends import_events.EventEmitter {
|
|
|
964
1286
|
let oldestMtime = Number.POSITIVE_INFINITY;
|
|
965
1287
|
for (const [p, entry] of this.offsets.entries()) {
|
|
966
1288
|
const cid = this.extractConversationId(p);
|
|
967
|
-
if (cid && this.activeConversations.has(cid)) {
|
|
1289
|
+
if (cid && (this.activeConversations.has(cid) || this.releasedConversations.has(cid))) {
|
|
968
1290
|
continue;
|
|
969
1291
|
}
|
|
970
1292
|
if (entry.lastModifiedMs < oldestMtime) {
|
|
@@ -1962,8 +2284,8 @@ function escapeShellArg(value) {
|
|
|
1962
2284
|
return value.replace(/'/g, `'\\''`);
|
|
1963
2285
|
}
|
|
1964
2286
|
function sleep(ms) {
|
|
1965
|
-
return new Promise((
|
|
1966
|
-
const t = setTimeout(
|
|
2287
|
+
return new Promise((resolve2) => {
|
|
2288
|
+
const t = setTimeout(resolve2, ms);
|
|
1967
2289
|
if (t.unref) t.unref();
|
|
1968
2290
|
});
|
|
1969
2291
|
}
|
|
@@ -2894,7 +3216,7 @@ var PromptResponder = class {
|
|
|
2894
3216
|
};
|
|
2895
3217
|
}
|
|
2896
3218
|
}
|
|
2897
|
-
async sendFreeFormWhenNoApprovalActive(text) {
|
|
3219
|
+
async sendFreeFormWhenNoApprovalActive(text, beforeType) {
|
|
2898
3220
|
const target = this.resolveTmuxTarget();
|
|
2899
3221
|
if (!target) return { ok: false, reason: "no-tmux-target" };
|
|
2900
3222
|
if (!this.paneObserver) {
|
|
@@ -2914,9 +3236,20 @@ var PromptResponder = class {
|
|
|
2914
3236
|
}
|
|
2915
3237
|
return null;
|
|
2916
3238
|
};
|
|
3239
|
+
let preparationFailed = false;
|
|
2917
3240
|
try {
|
|
2918
3241
|
const beforeText = await chooserIsAbsent();
|
|
2919
3242
|
if (beforeText) return beforeText;
|
|
3243
|
+
if (beforeType) {
|
|
3244
|
+
try {
|
|
3245
|
+
await beforeType();
|
|
3246
|
+
} catch (error) {
|
|
3247
|
+
preparationFailed = true;
|
|
3248
|
+
throw error;
|
|
3249
|
+
}
|
|
3250
|
+
const afterPreparation = await chooserIsAbsent();
|
|
3251
|
+
if (afterPreparation) return afterPreparation;
|
|
3252
|
+
}
|
|
2920
3253
|
await this.typeLiteral(target, text);
|
|
2921
3254
|
await delay(ENTER_DELAY_MS);
|
|
2922
3255
|
const beforeEnter = await chooserIsAbsent();
|
|
@@ -2924,6 +3257,7 @@ var PromptResponder = class {
|
|
|
2924
3257
|
await this.sendKey(target, "Enter");
|
|
2925
3258
|
return { ok: true };
|
|
2926
3259
|
} catch (error) {
|
|
3260
|
+
if (preparationFailed) throw error;
|
|
2927
3261
|
return {
|
|
2928
3262
|
ok: false,
|
|
2929
3263
|
reason: "tmux-failed",
|
|
@@ -3056,6 +3390,16 @@ var MobilePromptDeduper = class {
|
|
|
3056
3390
|
this.replaceEntries(sessionId, entries);
|
|
3057
3391
|
return match;
|
|
3058
3392
|
}
|
|
3393
|
+
/** Inspect without spending the token before durable native admission. */
|
|
3394
|
+
peekIfDuplicate(sessionId, echo) {
|
|
3395
|
+
const normalized = this.normalize(echo);
|
|
3396
|
+
if (!normalized) return null;
|
|
3397
|
+
for (const entry of this.validEntries(sessionId)) {
|
|
3398
|
+
const matchType = this.matchType(entry.normalized, normalized);
|
|
3399
|
+
if (matchType) return { matchType, originalLength: entry.normalized.length, echoLength: normalized.length };
|
|
3400
|
+
}
|
|
3401
|
+
return null;
|
|
3402
|
+
}
|
|
3059
3403
|
// ─── Internals ──────────────────────────────────────────────────────────
|
|
3060
3404
|
sweep() {
|
|
3061
3405
|
const cutoff = this.now() - this.expiryMs;
|
|
@@ -3135,14 +3479,14 @@ function sanitizeErrorMessage(msg) {
|
|
|
3135
3479
|
return out.trim().substring(0, 100);
|
|
3136
3480
|
}
|
|
3137
3481
|
function fireDaemonBeacon(name, params) {
|
|
3138
|
-
return new Promise((
|
|
3482
|
+
return new Promise((resolve2) => {
|
|
3139
3483
|
let req;
|
|
3140
3484
|
let settled = false;
|
|
3141
3485
|
const settle = () => {
|
|
3142
3486
|
if (settled) return;
|
|
3143
3487
|
settled = true;
|
|
3144
3488
|
clearTimeout(overall);
|
|
3145
|
-
|
|
3489
|
+
resolve2();
|
|
3146
3490
|
};
|
|
3147
3491
|
const overall = setTimeout(() => {
|
|
3148
3492
|
try {
|
|
@@ -3212,7 +3556,7 @@ function fireDaemonBeacon(name, params) {
|
|
|
3212
3556
|
|
|
3213
3557
|
// src/event-mapper.ts
|
|
3214
3558
|
var import_uuid2 = require("uuid");
|
|
3215
|
-
var
|
|
3559
|
+
var import_codevibe_core4 = require("@quantiya/codevibe-core");
|
|
3216
3560
|
var CONTENT_MAX = 150 * 1024;
|
|
3217
3561
|
var TOOL_OUTPUT_MAX = 50 * 1024;
|
|
3218
3562
|
var TOOL_DIFF_MAX = 100 * 1024;
|
|
@@ -3220,10 +3564,10 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3220
3564
|
const { event } = emit;
|
|
3221
3565
|
const base = {
|
|
3222
3566
|
sessionId,
|
|
3223
|
-
source:
|
|
3567
|
+
source: import_codevibe_core4.EventSource.DESKTOP
|
|
3224
3568
|
};
|
|
3225
3569
|
const orderingKey = `${sessionId}:${emit.conversationId}`;
|
|
3226
|
-
const nextTimestamp = () => (0,
|
|
3570
|
+
const nextTimestamp = () => (0, import_codevibe_core4.prepareEventTimestamp)({ orderingKey, agentClock: event.created_at, notBeforeMs });
|
|
3227
3571
|
const baseMetadata = {
|
|
3228
3572
|
step_index: event.step_index,
|
|
3229
3573
|
agy_source: event.source,
|
|
@@ -3263,7 +3607,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3263
3607
|
{
|
|
3264
3608
|
...base,
|
|
3265
3609
|
timestamp: nextTimestamp(),
|
|
3266
|
-
type:
|
|
3610
|
+
type: import_codevibe_core4.EventType.USER_PROMPT,
|
|
3267
3611
|
content: truncate(cleaned, CONTENT_MAX),
|
|
3268
3612
|
metadata: { ...baseMetadata }
|
|
3269
3613
|
}
|
|
@@ -3278,7 +3622,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3278
3622
|
events.push({
|
|
3279
3623
|
...base,
|
|
3280
3624
|
timestamp: nextTimestamp(),
|
|
3281
|
-
type:
|
|
3625
|
+
type: import_codevibe_core4.EventType.REASONING,
|
|
3282
3626
|
content: truncate(event.thinking, CONTENT_MAX),
|
|
3283
3627
|
metadata: { ...baseMetadata }
|
|
3284
3628
|
});
|
|
@@ -3287,7 +3631,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3287
3631
|
events.push({
|
|
3288
3632
|
...base,
|
|
3289
3633
|
timestamp: nextTimestamp(),
|
|
3290
|
-
type:
|
|
3634
|
+
type: import_codevibe_core4.EventType.ASSISTANT_RESPONSE,
|
|
3291
3635
|
content: truncate(event.content, CONTENT_MAX),
|
|
3292
3636
|
metadata: { ...baseMetadata }
|
|
3293
3637
|
});
|
|
@@ -3324,7 +3668,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3324
3668
|
{
|
|
3325
3669
|
...base,
|
|
3326
3670
|
timestamp: nextTimestamp(),
|
|
3327
|
-
type:
|
|
3671
|
+
type: import_codevibe_core4.EventType.NOTIFICATION,
|
|
3328
3672
|
content: truncate(content, CONTENT_MAX),
|
|
3329
3673
|
metadata: {
|
|
3330
3674
|
...baseMetadata,
|
|
@@ -3344,7 +3688,7 @@ function mapTranscriptEvent(emit, sessionId, notBeforeMs) {
|
|
|
3344
3688
|
{
|
|
3345
3689
|
...base,
|
|
3346
3690
|
timestamp: nextTimestamp(),
|
|
3347
|
-
type:
|
|
3691
|
+
type: import_codevibe_core4.EventType.TOOL_USE,
|
|
3348
3692
|
content: formatToolResultContent(event.type, toolName, truncatedContent, isErrorStatus),
|
|
3349
3693
|
metadata: {
|
|
3350
3694
|
...baseMetadata,
|
|
@@ -3502,7 +3846,7 @@ function truncate(s, maxBytes) {
|
|
|
3502
3846
|
}
|
|
3503
3847
|
|
|
3504
3848
|
// src/tool-activity-integration.ts
|
|
3505
|
-
var
|
|
3849
|
+
var import_codevibe_core5 = require("@quantiya/codevibe-core");
|
|
3506
3850
|
var TOOL_ACTIVITY_CONTENT = "tool_activity";
|
|
3507
3851
|
var DEFAULT_WINDOW_MS = 1e4;
|
|
3508
3852
|
var DEFAULT_MAX_OCCURRENCES = 50;
|
|
@@ -3569,9 +3913,9 @@ function agyOccurrenceId(emit, matchedCall) {
|
|
|
3569
3913
|
createdAt: emit.event.created_at,
|
|
3570
3914
|
stepIndex: emit.event.step_index,
|
|
3571
3915
|
type: emit.event.type,
|
|
3572
|
-
contentDigest: (0,
|
|
3916
|
+
contentDigest: (0, import_codevibe_core5.digestOf)(emit.event.content ?? "")
|
|
3573
3917
|
};
|
|
3574
|
-
return `agy:${(0,
|
|
3918
|
+
return `agy:${(0, import_codevibe_core5.digestOf)(identity).slice("sha256:".length)}`;
|
|
3575
3919
|
}
|
|
3576
3920
|
var AgyToolActivityIntegration = class {
|
|
3577
3921
|
constructor(deps) {
|
|
@@ -3580,31 +3924,34 @@ var AgyToolActivityIntegration = class {
|
|
|
3580
3924
|
this.sessionsToRehome = /* @__PURE__ */ new Set();
|
|
3581
3925
|
this.deps = deps;
|
|
3582
3926
|
}
|
|
3583
|
-
isActive() {
|
|
3584
|
-
return this.consolidator !== null;
|
|
3927
|
+
isActive(sessionId) {
|
|
3928
|
+
return this.consolidator !== null && (sessionId === void 0 || this.backendSessionId === sessionId);
|
|
3585
3929
|
}
|
|
3586
3930
|
async start(sessionId) {
|
|
3587
3931
|
if (this.consolidator && this.backendSessionId === sessionId) return;
|
|
3588
3932
|
if (this.consolidator) await this.stop();
|
|
3589
|
-
const outbox = new
|
|
3933
|
+
const outbox = new import_codevibe_core5.ToolActivityOutbox({
|
|
3590
3934
|
agent: "agy",
|
|
3591
3935
|
logger: this.deps.logger,
|
|
3592
3936
|
root: this.deps.root
|
|
3593
3937
|
});
|
|
3594
|
-
|
|
3595
|
-
const ledger = new import_codevibe_core4.ToolActivityLedger({
|
|
3938
|
+
const ledger = new import_codevibe_core5.ToolActivityLedger({
|
|
3596
3939
|
agent: "agy",
|
|
3597
3940
|
sessionId,
|
|
3598
3941
|
logger: this.deps.logger,
|
|
3599
3942
|
root: this.deps.root
|
|
3600
3943
|
});
|
|
3601
|
-
|
|
3944
|
+
await this.deps.recoverPending?.(sessionId, outbox, ledger);
|
|
3945
|
+
const legacyTransport = this.buildTransport();
|
|
3946
|
+
const transport = this.deps.selectTransport ? await this.deps.selectTransport(sessionId, legacyTransport) : legacyTransport;
|
|
3947
|
+
if (transport.protocol !== "SYNC_V2") await this.rehomePendingWindows(outbox, sessionId);
|
|
3948
|
+
const consolidator = new import_codevibe_core5.ToolActivityConsolidator({
|
|
3602
3949
|
agent: "agy",
|
|
3603
3950
|
sessionId,
|
|
3604
3951
|
logger: this.deps.logger,
|
|
3605
3952
|
outbox,
|
|
3606
3953
|
ledger,
|
|
3607
|
-
transport
|
|
3954
|
+
transport,
|
|
3608
3955
|
windowMs: this.deps.windowMs ?? DEFAULT_WINDOW_MS,
|
|
3609
3956
|
maxOccurrences: DEFAULT_MAX_OCCURRENCES
|
|
3610
3957
|
});
|
|
@@ -3636,7 +3983,7 @@ var AgyToolActivityIntegration = class {
|
|
|
3636
3983
|
...target ? { normalizedTarget: target } : {},
|
|
3637
3984
|
ts: emit.event.created_at,
|
|
3638
3985
|
byteOffset: emit.byteOffset,
|
|
3639
|
-
...inputIdentity ? { digest: (0,
|
|
3986
|
+
...inputIdentity ? { digest: (0, import_codevibe_core5.digestOf)(inputIdentity) } : {}
|
|
3640
3987
|
});
|
|
3641
3988
|
}
|
|
3642
3989
|
async flush() {
|
|
@@ -3686,7 +4033,8 @@ var AgyToolActivityIntegration = class {
|
|
|
3686
4033
|
}
|
|
3687
4034
|
}
|
|
3688
4035
|
if (!eligible) continue;
|
|
3689
|
-
|
|
4036
|
+
const legacyWindows = pending.valid.filter((envelope) => envelope.syncSessionGenerationId === void 0);
|
|
4037
|
+
for (const envelope of legacyWindows) {
|
|
3690
4038
|
const rehomed = { ...envelope, backendSessionId: replacementSessionId };
|
|
3691
4039
|
await outbox.persist(replacementSessionId, rehomed);
|
|
3692
4040
|
await outbox.prune(oldSessionId, envelope.clientEventId);
|
|
@@ -3695,7 +4043,7 @@ var AgyToolActivityIntegration = class {
|
|
|
3695
4043
|
this.deps.logger.info("[tool-activity] Rehomed durable Agy windows", {
|
|
3696
4044
|
oldSessionId,
|
|
3697
4045
|
replacementSessionId,
|
|
3698
|
-
count:
|
|
4046
|
+
count: legacyWindows.length
|
|
3699
4047
|
});
|
|
3700
4048
|
}
|
|
3701
4049
|
}
|
|
@@ -3705,7 +4053,7 @@ var AgyToolActivityIntegration = class {
|
|
|
3705
4053
|
if (!session || session.sessionId !== sessionId) {
|
|
3706
4054
|
throw terminalSessionError(sessionId);
|
|
3707
4055
|
}
|
|
3708
|
-
const encrypted = (0,
|
|
4056
|
+
const encrypted = (0, import_codevibe_core5.encryptForEmit)(
|
|
3709
4057
|
override?.sessionKey ?? session.sessionKey ?? null,
|
|
3710
4058
|
session.sessionIsEncrypted ?? false,
|
|
3711
4059
|
TOOL_ACTIVITY_CONTENT,
|
|
@@ -3717,8 +4065,8 @@ var AgyToolActivityIntegration = class {
|
|
|
3717
4065
|
const sessionKeyGen = override?.sessionKeyGen ?? session.sessionKeyGen;
|
|
3718
4066
|
const input = {
|
|
3719
4067
|
sessionId,
|
|
3720
|
-
type:
|
|
3721
|
-
source:
|
|
4068
|
+
type: import_codevibe_core5.EventType.TOOL_ACTIVITY,
|
|
4069
|
+
source: import_codevibe_core5.EventSource.DESKTOP,
|
|
3722
4070
|
content: encrypted.content,
|
|
3723
4071
|
metadata: encrypted.metadata,
|
|
3724
4072
|
timestamp: envelope.windowStart,
|
|
@@ -3733,10 +4081,10 @@ var AgyToolActivityIntegration = class {
|
|
|
3733
4081
|
try {
|
|
3734
4082
|
await sendOnce(sessionId, envelope);
|
|
3735
4083
|
} catch (error) {
|
|
3736
|
-
if ((0,
|
|
4084
|
+
if ((0, import_codevibe_core5.isTerminalSessionError)(error)) {
|
|
3737
4085
|
throw new Error(`[tool-activity] Agy session ${sessionId} retired; awaiting replacement`);
|
|
3738
4086
|
}
|
|
3739
|
-
if ((0,
|
|
4087
|
+
if ((0, import_codevibe_core5.isSessionKeyStaleError)(error) && this.deps.refreshSessionKey) {
|
|
3740
4088
|
const refreshed = await this.deps.refreshSessionKey(sessionId);
|
|
3741
4089
|
if (refreshed) {
|
|
3742
4090
|
await sendOnce(sessionId, envelope, refreshed);
|
|
@@ -3798,8 +4146,12 @@ var McpServer = class _McpServer {
|
|
|
3798
4146
|
* under this single sessionId. /resume does NOT create a new
|
|
3799
4147
|
* session — same row. */
|
|
3800
4148
|
this.session = null;
|
|
3801
|
-
this.promptPublications = new
|
|
4149
|
+
this.promptPublications = new import_codevibe_core6.PromptPublicationTracker();
|
|
3802
4150
|
this.transcriptHandlers = /* @__PURE__ */ new Set();
|
|
4151
|
+
this.nativeSourceSelection = null;
|
|
4152
|
+
this.nativeSources = /* @__PURE__ */ new Map();
|
|
4153
|
+
this.nativeToolStart = null;
|
|
4154
|
+
this.nativeAdmissionChain = Promise.resolve();
|
|
3803
4155
|
this.toolActivityKeyRefresh = null;
|
|
3804
4156
|
// E1 (§4/§6a-4) — per-promptId durable raise entries for the agy producer.
|
|
3805
4157
|
// Keyed by the E1 promptId (= derive(ownerToken)); each holds the SECRET
|
|
@@ -3952,9 +4304,23 @@ var McpServer = class _McpServer {
|
|
|
3952
4304
|
this.wrapperPid = options.wrapperPid ?? null;
|
|
3953
4305
|
this.cliLogPath = options.cliLogPath ?? null;
|
|
3954
4306
|
this.launchSettleTimeoutMs = options.launchSettleTimeoutMs ?? LAUNCH_SETTLE_TIMEOUT_MS;
|
|
3955
|
-
this.appSyncClient = options.appSyncClient ?? new
|
|
4307
|
+
this.appSyncClient = options.appSyncClient ?? new import_codevibe_core6.AppSyncClient();
|
|
3956
4308
|
this.toolActivity = options.toolActivityIntegration === void 0 ? new AgyToolActivityIntegration({
|
|
3957
4309
|
logger,
|
|
4310
|
+
selectTransport: async (sessionId, legacyTransport) => {
|
|
4311
|
+
const session = this.session;
|
|
4312
|
+
if (!session || session.sessionId !== sessionId) throw Error("SYNC_SOURCE_OWNER_CHANGED");
|
|
4313
|
+
const selection = await this.selectNativeSource(session);
|
|
4314
|
+
if (this.session !== session || !this.started) throw Error("SYNC_SOURCE_OWNER_CHANGED");
|
|
4315
|
+
return selection.protocol === "LEGACY" ? legacyTransport : this.appSyncClient.toolActivitySyncTransport({
|
|
4316
|
+
sessionScope: selection.sessionScope,
|
|
4317
|
+
getEncryptionContext: selection.getEncryptionContext
|
|
4318
|
+
});
|
|
4319
|
+
},
|
|
4320
|
+
recoverPending: async (sessionId, outbox, ledger) => {
|
|
4321
|
+
await this.appSyncClient.recoverCompanionSyncSources({ sessionId });
|
|
4322
|
+
await this.appSyncClient.recoverToolActivityWindows({ sessionId, outbox, ledger });
|
|
4323
|
+
},
|
|
3958
4324
|
createEvent: (input) => this.appSyncClient.createEvent(input),
|
|
3959
4325
|
getSession: () => this.session,
|
|
3960
4326
|
refreshSessionKey: (sessionId) => this.refreshToolActivitySessionKey(sessionId),
|
|
@@ -3970,7 +4336,11 @@ var McpServer = class _McpServer {
|
|
|
3970
4336
|
this.workingDirectory = process.cwd();
|
|
3971
4337
|
this.approvalDetector = options.approvalDetector ?? new ApprovalDetector(options.detectorOptions);
|
|
3972
4338
|
this.paneObserver = options.paneObserver ?? new TmuxPaneObserver();
|
|
3973
|
-
this.transcriptTailer = options.transcriptTailer ?? new TranscriptTailer(
|
|
4339
|
+
this.transcriptTailer = options.transcriptTailer ?? new TranscriptTailer({ admission: {
|
|
4340
|
+
restore: (source, initial) => this.restoreNativeSource(source, initial),
|
|
4341
|
+
admit: (emit, source) => this.admitNativeSource(emit, source),
|
|
4342
|
+
skip: (gap, source) => this.skipNativeSource(gap, source)
|
|
4343
|
+
} });
|
|
3974
4344
|
this.mobileDeduper = options.mobileDeduper ?? new MobilePromptDeduper();
|
|
3975
4345
|
this.promptResponder = options.promptResponder ?? new PromptResponder({
|
|
3976
4346
|
tmuxTarget: this.tmuxTarget ?? void 0,
|
|
@@ -4005,7 +4375,7 @@ var McpServer = class _McpServer {
|
|
|
4005
4375
|
const authed = await this.appSyncClient.authenticateWithStoredTokens();
|
|
4006
4376
|
if (!authed) {
|
|
4007
4377
|
const kind = this.appSyncClient.getLastAuthFailureKind();
|
|
4008
|
-
const failure = (0,
|
|
4378
|
+
const failure = (0, import_codevibe_core6.describeStoredAuthFailure)(
|
|
4009
4379
|
kind,
|
|
4010
4380
|
this.appSyncClient.getLastAuthFailureDetail()
|
|
4011
4381
|
);
|
|
@@ -4024,9 +4394,32 @@ var McpServer = class _McpServer {
|
|
|
4024
4394
|
this.stopRequestedDuringCreate = false;
|
|
4025
4395
|
this.registerSignalHandlers();
|
|
4026
4396
|
this.startTmuxLifecycleMonitor();
|
|
4027
|
-
|
|
4397
|
+
try {
|
|
4398
|
+
const statusUserId = this.appSyncClient.getCurrentUserId();
|
|
4399
|
+
const signal = AbortSignal.timeout(5e3);
|
|
4400
|
+
const timeout = new Promise((_, reject) => signal.addEventListener(
|
|
4401
|
+
"abort",
|
|
4402
|
+
() => reject(new Error("status read timed out")),
|
|
4403
|
+
{ once: true }
|
|
4404
|
+
));
|
|
4405
|
+
const subscription = await Promise.race([this.appSyncClient.getSubscriptionStatus(signal), timeout]);
|
|
4406
|
+
if (this.appSyncClient.getCurrentUserId() !== statusUserId) throw new Error("account changed during read");
|
|
4407
|
+
const sync = await Promise.race([this.appSyncClient.getCompanionSyncState(signal), timeout]);
|
|
4408
|
+
if (this.appSyncClient.getCurrentUserId() !== statusUserId) throw new Error("account changed during read");
|
|
4409
|
+
if (subscription.tier !== sync.tier) throw new Error("tier changed during read");
|
|
4410
|
+
logger.info("Standalone Companion sync status", {
|
|
4411
|
+
tier: sync.tier,
|
|
4412
|
+
protocol: sync.protocol,
|
|
4413
|
+
syncState: sync.usage?.syncState ?? null,
|
|
4414
|
+
resetAtEpochMs: sync.resetAtEpochMs
|
|
4415
|
+
});
|
|
4416
|
+
} catch {
|
|
4417
|
+
logger.warn("Standalone Companion sync status unavailable; cloud admission remains authoritative");
|
|
4418
|
+
}
|
|
4028
4419
|
if (!this.started) return { httpPort: 0 };
|
|
4029
|
-
(0,
|
|
4420
|
+
await (0, import_codevibe_core6.registerDeviceEncryptionKey)(this.appSyncClient, logger);
|
|
4421
|
+
if (!this.started) return { httpPort: 0 };
|
|
4422
|
+
(0, import_codevibe_core6.startDeviceKeyWatcher)(this.appSyncClient, logger);
|
|
4030
4423
|
try {
|
|
4031
4424
|
const swept = await this.appSyncClient.sweepOrphanSessions({
|
|
4032
4425
|
agentType: "ANTIGRAVITY",
|
|
@@ -4126,8 +4519,8 @@ var McpServer = class _McpServer {
|
|
|
4126
4519
|
let hostedRetirementError = null;
|
|
4127
4520
|
if (launchSettlement) {
|
|
4128
4521
|
let timer;
|
|
4129
|
-
const timeout = new Promise((
|
|
4130
|
-
timer = setTimeout(
|
|
4522
|
+
const timeout = new Promise((resolve2) => {
|
|
4523
|
+
timer = setTimeout(resolve2, this.launchSettleTimeoutMs);
|
|
4131
4524
|
timer.unref?.();
|
|
4132
4525
|
});
|
|
4133
4526
|
try {
|
|
@@ -4166,6 +4559,9 @@ var McpServer = class _McpServer {
|
|
|
4166
4559
|
}
|
|
4167
4560
|
this.listenerHandles = [];
|
|
4168
4561
|
await Promise.allSettled([...this.transcriptHandlers]);
|
|
4562
|
+
await this.transcriptTailer.waitForReads();
|
|
4563
|
+
await this.nativeAdmissionChain;
|
|
4564
|
+
await this.stopNativeSources();
|
|
4169
4565
|
if (this.session) {
|
|
4170
4566
|
try {
|
|
4171
4567
|
this.appSyncClient.stopHeartbeat(this.session.sessionId);
|
|
@@ -4176,7 +4572,7 @@ var McpServer = class _McpServer {
|
|
|
4176
4572
|
} catch (err) {
|
|
4177
4573
|
logger.warn("[tool-activity] Agy shutdown drain failed", { error: String(err) });
|
|
4178
4574
|
}
|
|
4179
|
-
const retired = await this.deactivateLaunchRow(this.session.sessionId);
|
|
4575
|
+
const retired = await this.deactivateLaunchRow(this.session.sessionId, this.session);
|
|
4180
4576
|
if (retired) this.durableObligationOpen = false;
|
|
4181
4577
|
if (!retired) {
|
|
4182
4578
|
hostedRetirementError = new Error(
|
|
@@ -4258,7 +4654,7 @@ var McpServer = class _McpServer {
|
|
|
4258
4654
|
* their settlement marker/obligation alive on `false` instead of treating the
|
|
4259
4655
|
* attempt as completion. The log stays inside so no failure is ever silent.
|
|
4260
4656
|
*/
|
|
4261
|
-
async deactivateLaunchRow(sessionId) {
|
|
4657
|
+
async deactivateLaunchRow(sessionId, quiescentSession) {
|
|
4262
4658
|
try {
|
|
4263
4659
|
this.appSyncClient.stopHeartbeat(sessionId);
|
|
4264
4660
|
} catch (error) {
|
|
@@ -4269,7 +4665,14 @@ var McpServer = class _McpServer {
|
|
|
4269
4665
|
}
|
|
4270
4666
|
for (let attempt = 1; attempt <= SESSION_RETIRE_MAX_ATTEMPTS; attempt += 1) {
|
|
4271
4667
|
try {
|
|
4272
|
-
await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
|
|
4668
|
+
const retired = await this.appSyncClient.updateSession({ sessionId, status: "INACTIVE" });
|
|
4669
|
+
if (quiescentSession && retired?.sessionGenerationId) {
|
|
4670
|
+
await this.appSyncClient.completeDesktopSyncRetirement({ sessionId }, retired, async () => {
|
|
4671
|
+
await Promise.allSettled([...this.transcriptHandlers]);
|
|
4672
|
+
await this.nativeAdmissionChain;
|
|
4673
|
+
await this.stopNativeSources(quiescentSession);
|
|
4674
|
+
});
|
|
4675
|
+
}
|
|
4273
4676
|
logger.info("Marked superseded/interrupted launch session INACTIVE", { sessionId });
|
|
4274
4677
|
return true;
|
|
4275
4678
|
} catch (err) {
|
|
@@ -4280,7 +4683,7 @@ var McpServer = class _McpServer {
|
|
|
4280
4683
|
error: String(err)
|
|
4281
4684
|
});
|
|
4282
4685
|
if (attempt < SESSION_RETIRE_MAX_ATTEMPTS) {
|
|
4283
|
-
await new Promise((
|
|
4686
|
+
await new Promise((resolve2) => setTimeout(resolve2, SESSION_RETIRE_RETRY_MS * attempt));
|
|
4284
4687
|
}
|
|
4285
4688
|
}
|
|
4286
4689
|
}
|
|
@@ -4313,7 +4716,7 @@ var McpServer = class _McpServer {
|
|
|
4313
4716
|
let sessionKey = null;
|
|
4314
4717
|
let sessionKeyGen = null;
|
|
4315
4718
|
try {
|
|
4316
|
-
const result = await (0,
|
|
4719
|
+
const result = await (0, import_codevibe_core6.resumeOrCreateSession)(
|
|
4317
4720
|
{
|
|
4318
4721
|
sessionId,
|
|
4319
4722
|
userId,
|
|
@@ -4399,8 +4802,9 @@ var McpServer = class _McpServer {
|
|
|
4399
4802
|
const stop = this.appSyncClient.subscribeToEvents(
|
|
4400
4803
|
sessionId,
|
|
4401
4804
|
(evt) => {
|
|
4402
|
-
|
|
4805
|
+
return this.handleMobileEvent(evt).catch((err) => {
|
|
4403
4806
|
logger.error("handleMobileEvent failed", { error: String(err) });
|
|
4807
|
+
if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw err;
|
|
4404
4808
|
});
|
|
4405
4809
|
},
|
|
4406
4810
|
(err) => logger.warn("AppSync subscription error", { error: String(err) }),
|
|
@@ -4482,7 +4886,7 @@ var McpServer = class _McpServer {
|
|
|
4482
4886
|
return activeId === conversationId;
|
|
4483
4887
|
}
|
|
4484
4888
|
// ─── Transcript flow ────────────────────────────────────────────────────
|
|
4485
|
-
async handleTranscriptEmit(emit) {
|
|
4889
|
+
async handleTranscriptEmit(emit, admission) {
|
|
4486
4890
|
if (!this.started) return;
|
|
4487
4891
|
const { conversationId, byteOffset, event } = emit;
|
|
4488
4892
|
if (this.sessionDisabled) {
|
|
@@ -4508,14 +4912,18 @@ var McpServer = class _McpServer {
|
|
|
4508
4912
|
}
|
|
4509
4913
|
}
|
|
4510
4914
|
const mapped = mapTranscriptEvent(emit, session.sessionId, notBeforeMs);
|
|
4915
|
+
if (admission && (mapped.events.length > 2 || mapped.events.some((input) => !["USER_PROMPT", "ASSISTANT_RESPONSE", "REASONING", "TOOL_USE", "NOTIFICATION"].includes(input.type)))) {
|
|
4916
|
+
throw Error("SYNC_NATIVE_SPECIALIZED_PRODUCER_REQUIRED");
|
|
4917
|
+
}
|
|
4511
4918
|
for (const reg of mapped.pendingCallRegistrations) {
|
|
4512
4919
|
this.approvalDetector.registerPendingCall(reg);
|
|
4513
4920
|
}
|
|
4514
|
-
for (const input of mapped.events) {
|
|
4515
|
-
if (input.type ===
|
|
4516
|
-
await this.
|
|
4921
|
+
for (const [index, input] of mapped.events.entries()) {
|
|
4922
|
+
if (input.type === import_codevibe_core6.EventType.USER_PROMPT || input.type === import_codevibe_core6.EventType.ASSISTANT_RESPONSE || input.type === import_codevibe_core6.EventType.NOTIFICATION) {
|
|
4923
|
+
if (admission) await this.toolActivity.flush();
|
|
4924
|
+
else await this.flushToolActivityAtBoundary(input.type);
|
|
4517
4925
|
}
|
|
4518
|
-
if (input.type ===
|
|
4926
|
+
if (input.type === import_codevibe_core6.EventType.TOOL_USE && typeof input.metadata === "object" && input.metadata) {
|
|
4519
4927
|
const meta = input.metadata;
|
|
4520
4928
|
const resultStep = typeof meta.step_index === "number" ? meta.step_index : void 0;
|
|
4521
4929
|
const agyType = typeof meta.agy_type === "string" ? meta.agy_type : void 0;
|
|
@@ -4532,6 +4940,7 @@ var McpServer = class _McpServer {
|
|
|
4532
4940
|
continue;
|
|
4533
4941
|
}
|
|
4534
4942
|
} catch (error) {
|
|
4943
|
+
if (admission) throw error;
|
|
4535
4944
|
logger.warn("[tool-activity] Agy result observation failed; emitting raw TOOL_USE fallback", {
|
|
4536
4945
|
sessionId: session.sessionId,
|
|
4537
4946
|
conversationId,
|
|
@@ -4541,11 +4950,12 @@ var McpServer = class _McpServer {
|
|
|
4541
4950
|
}
|
|
4542
4951
|
}
|
|
4543
4952
|
}
|
|
4544
|
-
if (input.type ===
|
|
4953
|
+
if (input.type === import_codevibe_core6.EventType.USER_PROMPT && input.source === import_codevibe_core6.EventSource.DESKTOP) {
|
|
4545
4954
|
const clearedPromptIds = this.approvalDetector.onPanePromptCleared();
|
|
4546
4955
|
await this.resolveAcceptedInputPrompts(session, clearedPromptIds);
|
|
4547
|
-
const dedupe = this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
|
|
4956
|
+
const dedupe = admission ? null : this.mobileDeduper.consumeIfDuplicate(session.sessionId, input.content);
|
|
4548
4957
|
if (dedupe) {
|
|
4958
|
+
if (admission) admission.echoContent = input.content;
|
|
4549
4959
|
logger.info("Suppressed desktop echo of mobile prompt", {
|
|
4550
4960
|
sessionId: session.sessionId,
|
|
4551
4961
|
matchType: dedupe.matchType
|
|
@@ -4553,6 +4963,10 @@ var McpServer = class _McpServer {
|
|
|
4553
4963
|
continue;
|
|
4554
4964
|
}
|
|
4555
4965
|
}
|
|
4966
|
+
if (admission) {
|
|
4967
|
+
admission.outputs[index] = input;
|
|
4968
|
+
continue;
|
|
4969
|
+
}
|
|
4556
4970
|
try {
|
|
4557
4971
|
if (!await this.createEncryptedEvent(session, input)) continue;
|
|
4558
4972
|
} catch (err) {
|
|
@@ -4564,6 +4978,161 @@ var McpServer = class _McpServer {
|
|
|
4564
4978
|
}
|
|
4565
4979
|
}
|
|
4566
4980
|
}
|
|
4981
|
+
selectNativeSource(session) {
|
|
4982
|
+
if (!this.started || this.session !== session) return Promise.reject(Error("SYNC_SOURCE_OWNER_CHANGED"));
|
|
4983
|
+
if (this.nativeSourceSelection?.session === session) return this.nativeSourceSelection.pending;
|
|
4984
|
+
const pending = this.appSyncClient.selectCompanionSourceTransport({ sessionId: session.sessionId });
|
|
4985
|
+
const selected = { session, pending };
|
|
4986
|
+
this.nativeSourceSelection = selected;
|
|
4987
|
+
void pending.catch(() => {
|
|
4988
|
+
if (this.nativeSourceSelection === selected) this.nativeSourceSelection = null;
|
|
4989
|
+
});
|
|
4990
|
+
return pending;
|
|
4991
|
+
}
|
|
4992
|
+
assertNativeOwner(owner) {
|
|
4993
|
+
if (!this.started || this.sessionDisabled || this.session !== owner.session || owner.controller.signal.aborted) {
|
|
4994
|
+
throw Error("SYNC_SOURCE_OWNER_CHANGED");
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
async nativeOwner(source) {
|
|
4998
|
+
if (!this.started || this.sessionDisabled) throw Error("SYNC_SOURCE_STOPPED");
|
|
4999
|
+
await this.ensureLaunchSession();
|
|
5000
|
+
const session = this.session;
|
|
5001
|
+
if (!session || !this.isMainConversation(source.conversationId)) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
|
|
5002
|
+
const selection = await this.selectNativeSource(session);
|
|
5003
|
+
if (!this.started || this.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
|
|
5004
|
+
if (selection.protocol === "LEGACY") return null;
|
|
5005
|
+
if (!this.toolActivity?.isActive(session.sessionId)) {
|
|
5006
|
+
if (this.nativeToolStart && this.nativeToolStart.session !== session) throw Error("SYNC_SOURCE_OWNER_CHANGED");
|
|
5007
|
+
if (!this.nativeToolStart) {
|
|
5008
|
+
const start = this.toolActivity ? this.toolActivity.start(session.sessionId) : Promise.reject(Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE"));
|
|
5009
|
+
const attempt = { session, pending: start };
|
|
5010
|
+
this.nativeToolStart = attempt;
|
|
5011
|
+
void start.finally(() => {
|
|
5012
|
+
if (this.nativeToolStart === attempt) this.nativeToolStart = null;
|
|
5013
|
+
}).catch(() => void 0);
|
|
5014
|
+
}
|
|
5015
|
+
await this.nativeToolStart.pending;
|
|
5016
|
+
}
|
|
5017
|
+
if (!this.toolActivity?.isActive(session.sessionId) || this.session !== session || !this.started) throw Error("SYNC_SOURCE_OWNERSHIP_UNAVAILABLE");
|
|
5018
|
+
const streamSha256 = crypto3.createHash("sha256").update(JSON.stringify([source.filePath, source.conversationId])).digest("hex");
|
|
5019
|
+
const key = `${selection.sessionScope.sessionId}:${selection.sessionScope.sessionGenerationId}:${streamSha256}`;
|
|
5020
|
+
const existing = this.nativeSources.get(key);
|
|
5021
|
+
if (existing) {
|
|
5022
|
+
this.assertNativeOwner(existing);
|
|
5023
|
+
return existing;
|
|
5024
|
+
}
|
|
5025
|
+
const owner = {
|
|
5026
|
+
session,
|
|
5027
|
+
selection,
|
|
5028
|
+
streamSha256,
|
|
5029
|
+
cursor: selection.writer.sourcePair({ provider: "agy", streamSha256 }),
|
|
5030
|
+
controller: new AbortController(),
|
|
5031
|
+
drain: null,
|
|
5032
|
+
drainRequested: false,
|
|
5033
|
+
timer: null,
|
|
5034
|
+
retryMs: 5e3
|
|
5035
|
+
};
|
|
5036
|
+
this.nativeSources.set(key, owner);
|
|
5037
|
+
return owner;
|
|
5038
|
+
}
|
|
5039
|
+
async restoreNativeSource(source, initial) {
|
|
5040
|
+
const owner = await this.nativeOwner(source);
|
|
5041
|
+
if (!owner) return null;
|
|
5042
|
+
const position = await owner.cursor.open(initial);
|
|
5043
|
+
this.assertNativeOwner(owner);
|
|
5044
|
+
await owner.selection.writer.mobileEchoes("agy").reconcile(owner.streamSha256, position.byteOffset);
|
|
5045
|
+
this.drainNativeSource(owner);
|
|
5046
|
+
return position;
|
|
5047
|
+
}
|
|
5048
|
+
async skipNativeSource(gap, source) {
|
|
5049
|
+
const owner = await this.nativeOwner(source);
|
|
5050
|
+
if (!owner) throw Error("SYNC_SOURCE_PROTOCOL_CHANGED");
|
|
5051
|
+
await owner.cursor.skip(gap);
|
|
5052
|
+
this.assertNativeOwner(owner);
|
|
5053
|
+
}
|
|
5054
|
+
async admitNativeSource(emit, source) {
|
|
5055
|
+
const owner = await this.nativeOwner(source);
|
|
5056
|
+
if (!owner) throw Error("SYNC_SOURCE_PROTOCOL_CHANGED");
|
|
5057
|
+
const run = this.nativeAdmissionChain.then(async () => {
|
|
5058
|
+
this.assertNativeOwner(owner);
|
|
5059
|
+
if ((await owner.cursor.open(0)).byteOffset >= source.endOffset) return;
|
|
5060
|
+
const admission = { outputs: [null, null] };
|
|
5061
|
+
await this.handleTranscriptEmit(emit, admission);
|
|
5062
|
+
this.assertNativeOwner(owner);
|
|
5063
|
+
const echoFrame = {
|
|
5064
|
+
streamSha256: owner.streamSha256,
|
|
5065
|
+
occurrence: `${source.startOffset}:${source.endOffset}`,
|
|
5066
|
+
sourceSha256: source.sha256,
|
|
5067
|
+
startOffset: source.startOffset,
|
|
5068
|
+
endOffset: source.endOffset
|
|
5069
|
+
};
|
|
5070
|
+
const echoes = owner.selection.writer.mobileEchoes("agy");
|
|
5071
|
+
for (let index = 0; index < admission.outputs.length; index++) {
|
|
5072
|
+
const input = admission.outputs[index];
|
|
5073
|
+
if (input?.type === import_codevibe_core6.EventType.USER_PROMPT && await echoes.claim(echoFrame, input.content, emit.event.created_at)) admission.outputs[index] = null;
|
|
5074
|
+
}
|
|
5075
|
+
const factory = (index) => {
|
|
5076
|
+
const input = admission.outputs[index];
|
|
5077
|
+
return input ? (id) => owner.selection.prepareEvent(input, id) : null;
|
|
5078
|
+
};
|
|
5079
|
+
await owner.cursor.admit({
|
|
5080
|
+
provider: "agy",
|
|
5081
|
+
streamSha256: owner.streamSha256,
|
|
5082
|
+
occurrence: `${source.startOffset}:${source.endOffset}`,
|
|
5083
|
+
sourceSha256: source.sha256,
|
|
5084
|
+
startOffset: source.startOffset,
|
|
5085
|
+
endOffset: source.endOffset,
|
|
5086
|
+
anchor: source.anchor
|
|
5087
|
+
}, [factory(0), factory(1)]);
|
|
5088
|
+
this.assertNativeOwner(owner);
|
|
5089
|
+
await echoes.reconcile(owner.streamSha256, source.endOffset);
|
|
5090
|
+
this.drainNativeSource(owner);
|
|
5091
|
+
});
|
|
5092
|
+
this.nativeAdmissionChain = run.catch((error) => {
|
|
5093
|
+
logger.warn("[sync-source] Agy admission retained", { error: String(error) });
|
|
5094
|
+
});
|
|
5095
|
+
await run;
|
|
5096
|
+
}
|
|
5097
|
+
drainNativeSource(owner) {
|
|
5098
|
+
if (!this.started || this.session !== owner.session || owner.controller.signal.aborted) return;
|
|
5099
|
+
owner.drainRequested = true;
|
|
5100
|
+
if (owner.drain || owner.timer) return;
|
|
5101
|
+
owner.drainRequested = false;
|
|
5102
|
+
const run = (async () => {
|
|
5103
|
+
let failed = false;
|
|
5104
|
+
try {
|
|
5105
|
+
if (await owner.cursor.drain(64, owner.controller.signal) >= 64) owner.drainRequested = true;
|
|
5106
|
+
owner.retryMs = 5e3;
|
|
5107
|
+
} catch (error) {
|
|
5108
|
+
failed = true;
|
|
5109
|
+
owner.retryMs = Math.min(owner.retryMs * 2, 6e4);
|
|
5110
|
+
if (!owner.controller.signal.aborted) logger.warn("[sync-source] Agy delivery retained", { error: String(error) });
|
|
5111
|
+
}
|
|
5112
|
+
owner.drain = null;
|
|
5113
|
+
if (!this.started || this.session !== owner.session || owner.controller.signal.aborted) return;
|
|
5114
|
+
if (failed) {
|
|
5115
|
+
owner.timer = setTimeout(() => {
|
|
5116
|
+
owner.timer = null;
|
|
5117
|
+
this.drainNativeSource(owner);
|
|
5118
|
+
}, owner.retryMs);
|
|
5119
|
+
owner.timer.unref();
|
|
5120
|
+
} else if (owner.drainRequested) this.drainNativeSource(owner);
|
|
5121
|
+
})();
|
|
5122
|
+
owner.drain = run;
|
|
5123
|
+
void run.catch((error) => logger.error("[sync-source] unexpected Agy drain failure", { error: String(error) }));
|
|
5124
|
+
}
|
|
5125
|
+
async stopNativeSources(session) {
|
|
5126
|
+
const owned = [...this.nativeSources].filter(([, owner]) => !session || owner.session === session);
|
|
5127
|
+
for (const [, owner] of owned) {
|
|
5128
|
+
if (owner.timer) clearTimeout(owner.timer);
|
|
5129
|
+
owner.timer = null;
|
|
5130
|
+
owner.controller.abort(Error("SYNC_SOURCE_STOPPED"));
|
|
5131
|
+
}
|
|
5132
|
+
await Promise.all(owned.map(([, owner]) => owner.drain?.catch(() => void 0)));
|
|
5133
|
+
for (const [key] of owned) this.nativeSources.delete(key);
|
|
5134
|
+
if (!session || this.nativeSourceSelection?.session === session) this.nativeSourceSelection = null;
|
|
5135
|
+
}
|
|
4567
5136
|
async flushToolActivityAtBoundary(boundary) {
|
|
4568
5137
|
try {
|
|
4569
5138
|
await this.toolActivity?.flush();
|
|
@@ -4593,18 +5162,18 @@ var McpServer = class _McpServer {
|
|
|
4593
5162
|
const expectedSession = this.session;
|
|
4594
5163
|
try {
|
|
4595
5164
|
const session = await this.appSyncClient.getSession(sessionId);
|
|
4596
|
-
if (session?.status ===
|
|
5165
|
+
if (session?.status === import_codevibe_core6.SessionStatus.INACTIVE) {
|
|
4597
5166
|
const terminal = new Error(`Antigravity session is no longer active: ${sessionId}`);
|
|
4598
5167
|
terminal.errorType = "SessionTerminal";
|
|
4599
5168
|
throw terminal;
|
|
4600
5169
|
}
|
|
4601
|
-
if (!session || session.status !==
|
|
5170
|
+
if (!session || session.status !== import_codevibe_core6.SessionStatus.ACTIVE || !session.isEncrypted) {
|
|
4602
5171
|
return null;
|
|
4603
5172
|
}
|
|
4604
5173
|
const sessionKeyGen = session.sessionKeyGen ?? null;
|
|
4605
5174
|
const encryptedKeys = session.encryptedKeys ?? [];
|
|
4606
5175
|
if (encryptedKeys.length === 0) return null;
|
|
4607
|
-
const sessionKey = await
|
|
5176
|
+
const sessionKey = await import_codevibe_core6.keychainManager.getSessionKey(
|
|
4608
5177
|
sessionId,
|
|
4609
5178
|
encryptedKeys,
|
|
4610
5179
|
sessionKeyGen
|
|
@@ -4617,7 +5186,7 @@ var McpServer = class _McpServer {
|
|
|
4617
5186
|
liveSession.sessionIsEncrypted = true;
|
|
4618
5187
|
return { sessionKey, sessionKeyGen };
|
|
4619
5188
|
} catch (error) {
|
|
4620
|
-
if ((0,
|
|
5189
|
+
if ((0, import_codevibe_core6.isTerminalSessionError)(error)) throw error;
|
|
4621
5190
|
logger.warn("[tool-activity] Agy session-key refresh failed; window remains retryable", {
|
|
4622
5191
|
sessionId,
|
|
4623
5192
|
error: String(error)
|
|
@@ -4637,7 +5206,7 @@ var McpServer = class _McpServer {
|
|
|
4637
5206
|
* an encrypted keyless one (drop).
|
|
4638
5207
|
*/
|
|
4639
5208
|
encryptOutbound(session, input) {
|
|
4640
|
-
const enc = (0,
|
|
5209
|
+
const enc = (0, import_codevibe_core6.encryptForEmit)(
|
|
4641
5210
|
session.sessionKey ?? null,
|
|
4642
5211
|
session.sessionIsEncrypted ?? false,
|
|
4643
5212
|
input.content,
|
|
@@ -4667,7 +5236,7 @@ var McpServer = class _McpServer {
|
|
|
4667
5236
|
const promptId = input.producerKind === "PLUGIN_APPROVAL" ? input.promptId : void 0;
|
|
4668
5237
|
const promptEntry = promptId ? this.e1PromptRaises.get(promptId) : void 0;
|
|
4669
5238
|
const sendOnce = async () => {
|
|
4670
|
-
if (promptId && !(0,
|
|
5239
|
+
if (promptId && !(0, import_codevibe_core6.isOpenPromptRaise)(
|
|
4671
5240
|
promptEntry,
|
|
4672
5241
|
this.e1PromptRaises.get(promptId),
|
|
4673
5242
|
this.e1NeedsResolution.has(promptId)
|
|
@@ -4680,7 +5249,7 @@ var McpServer = class _McpServer {
|
|
|
4680
5249
|
try {
|
|
4681
5250
|
return await sendOnce();
|
|
4682
5251
|
} catch (error) {
|
|
4683
|
-
if (!(0,
|
|
5252
|
+
if (!(0, import_codevibe_core6.isSessionKeyStaleError)(error)) throw error;
|
|
4684
5253
|
const refreshed = await this.refreshToolActivitySessionKey(session.sessionId);
|
|
4685
5254
|
if (!refreshed) throw error;
|
|
4686
5255
|
return sendOnce();
|
|
@@ -4701,7 +5270,7 @@ var McpServer = class _McpServer {
|
|
|
4701
5270
|
* NOTIFICATION carrier, no fabricated options).
|
|
4702
5271
|
*/
|
|
4703
5272
|
newE1Raise(sessionId, mobileActionable, title) {
|
|
4704
|
-
const { record } = (0,
|
|
5273
|
+
const { record } = (0, import_codevibe_core6.newPromptRaise)({
|
|
4705
5274
|
sessionId,
|
|
4706
5275
|
producerKind: "PLUGIN_APPROVAL",
|
|
4707
5276
|
mobileActionable,
|
|
@@ -4737,14 +5306,12 @@ var McpServer = class _McpServer {
|
|
|
4737
5306
|
const operation = (async () => {
|
|
4738
5307
|
try {
|
|
4739
5308
|
if (this.promptPublications.isPublishing(promptId)) await this.promptPublications.waitFor(promptId);
|
|
4740
|
-
const
|
|
5309
|
+
const acknowledged = await this.appSyncClient.resolveCompanionPrompt({
|
|
4741
5310
|
sessionId: entry.record.sessionId || sessionId,
|
|
4742
|
-
|
|
4743
|
-
source: import_codevibe_core5.EventSource.DESKTOP,
|
|
4744
|
-
resolvedPromptId: promptId,
|
|
5311
|
+
promptId,
|
|
4745
5312
|
ownerToken: entry.record.ownerToken
|
|
4746
5313
|
});
|
|
4747
|
-
if (
|
|
5314
|
+
if (!acknowledged) return false;
|
|
4748
5315
|
this.e1NeedsResolution.delete(promptId);
|
|
4749
5316
|
this.e1NeedsReRaise.delete(promptId);
|
|
4750
5317
|
this.e1PromptRaises.delete(promptId);
|
|
@@ -4797,27 +5364,43 @@ var McpServer = class _McpServer {
|
|
|
4797
5364
|
* Content is E2E-encrypted; the raise identity rides plaintext top-level.
|
|
4798
5365
|
* Fail-closed on a missing session key — never cleartext.
|
|
4799
5366
|
*/
|
|
4800
|
-
async emitSuppressedPromptCarrier(session, record, customBannerText) {
|
|
5367
|
+
async emitSuppressedPromptCarrier(session, record, reRaiseToken, customBannerText) {
|
|
4801
5368
|
const bannerText = customBannerText || "\u26A0\uFE0F A prompt is waiting in your desktop terminal, but its options could not be shown here. Please answer it on your desktop.";
|
|
4802
5369
|
const input = {
|
|
4803
5370
|
sessionId: session.sessionId,
|
|
4804
|
-
type:
|
|
4805
|
-
source:
|
|
5371
|
+
type: import_codevibe_core6.EventType.NOTIFICATION,
|
|
5372
|
+
source: import_codevibe_core6.EventSource.DESKTOP,
|
|
4806
5373
|
content: bannerText,
|
|
4807
5374
|
metadata: {
|
|
4808
5375
|
e1SuppressedPrompt: true
|
|
4809
5376
|
},
|
|
4810
|
-
...(0,
|
|
5377
|
+
...(0, import_codevibe_core6.raiseFieldsFromRecord)(record),
|
|
4811
5378
|
notificationText: bannerText,
|
|
4812
|
-
timestamp: (0,
|
|
5379
|
+
timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
4813
5380
|
};
|
|
4814
|
-
await this.flushToolActivityAtBoundary(
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
return
|
|
5381
|
+
await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.NOTIFICATION);
|
|
5382
|
+
return this.publishE1Prompt(session, input, reRaiseToken);
|
|
5383
|
+
}
|
|
5384
|
+
async publishE1Prompt(session, input, reRaiseToken) {
|
|
5385
|
+
const promptId = input.promptId, entry = promptId ? this.e1PromptRaises.get(promptId) : void 0;
|
|
5386
|
+
if (!promptId || !(0, import_codevibe_core6.isOpenPromptRaise)(entry, this.e1PromptRaises.get(promptId), this.e1NeedsResolution.has(promptId))) return "CLOSED";
|
|
5387
|
+
if (!this.started || this.session !== session || input.sessionId !== session.sessionId) return "RETRY";
|
|
5388
|
+
const { notificationText: _legacyHint, ...plain } = input;
|
|
5389
|
+
const settle = (disposition) => {
|
|
5390
|
+
if ((0, import_codevibe_core6.isOpenPromptRaise)(entry, this.e1PromptRaises.get(promptId), this.e1NeedsResolution.has(promptId))) {
|
|
5391
|
+
if (disposition === "RETRY") this.e1NeedsReRaise.add(promptId);
|
|
5392
|
+
else if (disposition === "RERAISED" || disposition === "OMITTED") this.e1NeedsReRaise.delete(promptId);
|
|
5393
|
+
}
|
|
5394
|
+
return disposition;
|
|
5395
|
+
};
|
|
5396
|
+
return this.promptPublications.run(promptId, () => this.appSyncClient.publishCompanionPrompt(
|
|
5397
|
+
plain,
|
|
5398
|
+
async () => !!await this.createEncryptedEvent(session, input),
|
|
5399
|
+
{ reRaiseToken }
|
|
5400
|
+
).then(settle, (error) => {
|
|
5401
|
+
settle("RETRY");
|
|
5402
|
+
throw error;
|
|
5403
|
+
}));
|
|
4821
5404
|
}
|
|
4822
5405
|
/**
|
|
4823
5406
|
* E1 (§6a-2b) — badge-only raise for a SUPPRESSED prompt, applying the content-key
|
|
@@ -4842,7 +5425,7 @@ var McpServer = class _McpServer {
|
|
|
4842
5425
|
entry.contentPlain = customBannerText;
|
|
4843
5426
|
}
|
|
4844
5427
|
}
|
|
4845
|
-
this.emitSuppressedPromptCarrier(session, record, customBannerText).catch((e) => {
|
|
5428
|
+
this.emitSuppressedPromptCarrier(session, record, void 0, customBannerText).catch((e) => {
|
|
4846
5429
|
logger.error("E1: failed to emit suppressed-prompt badge carrier", {
|
|
4847
5430
|
sessionId: session.sessionId,
|
|
4848
5431
|
promptId: record.promptId,
|
|
@@ -4879,8 +5462,8 @@ var McpServer = class _McpServer {
|
|
|
4879
5462
|
...snap.metadataPlain !== void 0 && { metadataPlain: snap.metadataPlain }
|
|
4880
5463
|
};
|
|
4881
5464
|
this.e1PromptRaises.set(entry.record.promptId, entry);
|
|
4882
|
-
const
|
|
4883
|
-
if (
|
|
5465
|
+
const disposition = await this.reRaiseOneE1(replacementSession, entry, opts);
|
|
5466
|
+
if (disposition !== "RERAISED" && disposition !== "OMITTED" && !entry.resolutionStarted) unacked.push(snap);
|
|
4884
5467
|
}
|
|
4885
5468
|
if (!this.e1TtlRefreshTimer) this.startE1TtlRefresh();
|
|
4886
5469
|
return unacked;
|
|
@@ -4940,7 +5523,7 @@ var McpServer = class _McpServer {
|
|
|
4940
5523
|
if (this.reRaiseSuperseded(gen)) return;
|
|
4941
5524
|
const slice = Math.min(50, deadline - Date.now());
|
|
4942
5525
|
if (slice <= 0) return;
|
|
4943
|
-
await new Promise((
|
|
5526
|
+
await new Promise((resolve2) => setTimeout(resolve2, slice));
|
|
4944
5527
|
}
|
|
4945
5528
|
}
|
|
4946
5529
|
/**
|
|
@@ -4968,57 +5551,65 @@ var McpServer = class _McpServer {
|
|
|
4968
5551
|
*/
|
|
4969
5552
|
async reRaiseOneE1(session, entry, opts) {
|
|
4970
5553
|
const rec = entry.record;
|
|
4971
|
-
if (this.e1NeedsResolution.has(rec.promptId)) return
|
|
4972
|
-
if (!(0,
|
|
4973
|
-
return
|
|
5554
|
+
if (this.e1NeedsResolution.has(rec.promptId)) return "CLOSED";
|
|
5555
|
+
if (!(0, import_codevibe_core6.canRecoverPromptRaise)(entry, this.promptPublications.isPublishing(rec.promptId))) {
|
|
5556
|
+
return "RETRY";
|
|
4974
5557
|
}
|
|
4975
5558
|
const actionable = rec.mobileActionable && entry.contentPlain !== void 0;
|
|
4976
5559
|
const retryUntilAck = opts?.retryUntilAck === true;
|
|
4977
5560
|
const gen = opts?.gen;
|
|
4978
5561
|
const MAX_ATTEMPTS = 4;
|
|
4979
5562
|
for (let attempt = 1; ; attempt++) {
|
|
4980
|
-
if (!(0,
|
|
5563
|
+
if (!(0, import_codevibe_core6.isOpenPromptRaise)(
|
|
4981
5564
|
entry,
|
|
4982
5565
|
this.e1PromptRaises.get(rec.promptId),
|
|
4983
5566
|
this.e1NeedsResolution.has(rec.promptId)
|
|
4984
|
-
)) return
|
|
5567
|
+
)) return "CLOSED";
|
|
4985
5568
|
if (retryUntilAck && this.reRaiseSuperseded(gen)) {
|
|
4986
5569
|
this.e1NeedsReRaise.add(rec.promptId);
|
|
4987
5570
|
logger.warn("E1: block-until-ack re-raise aborted (lifecycle superseded/stopping) \u2014 flagged for retry-until-ack (entry retained)", {
|
|
4988
5571
|
sessionId: session.sessionId,
|
|
4989
5572
|
promptId: rec.promptId
|
|
4990
5573
|
});
|
|
4991
|
-
return
|
|
5574
|
+
return "RETRY";
|
|
4992
5575
|
}
|
|
4993
5576
|
try {
|
|
5577
|
+
let disposition;
|
|
4994
5578
|
if (actionable) {
|
|
4995
5579
|
const input = {
|
|
4996
5580
|
sessionId: session.sessionId,
|
|
4997
|
-
type:
|
|
4998
|
-
source:
|
|
5581
|
+
type: import_codevibe_core6.EventType.INTERACTIVE_PROMPT,
|
|
5582
|
+
source: import_codevibe_core6.EventSource.DESKTOP,
|
|
4999
5583
|
content: entry.contentPlain,
|
|
5000
5584
|
metadata: entry.metadataPlain ?? {},
|
|
5001
|
-
...(0,
|
|
5002
|
-
timestamp: (0,
|
|
5585
|
+
...(0, import_codevibe_core6.raiseFieldsFromRecord)(rec),
|
|
5586
|
+
timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
5003
5587
|
};
|
|
5004
|
-
await this.flushToolActivityAtBoundary(
|
|
5005
|
-
if (!(0,
|
|
5588
|
+
await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.INTERACTIVE_PROMPT);
|
|
5589
|
+
if (!(0, import_codevibe_core6.isOpenPromptRaise)(
|
|
5006
5590
|
entry,
|
|
5007
5591
|
this.e1PromptRaises.get(rec.promptId),
|
|
5008
5592
|
this.e1NeedsResolution.has(rec.promptId)
|
|
5009
|
-
)) return
|
|
5010
|
-
|
|
5593
|
+
)) return "CLOSED";
|
|
5594
|
+
disposition = await this.publishE1Prompt(session, input, opts?.reRaiseToken);
|
|
5011
5595
|
} else {
|
|
5012
5596
|
rec.mobileActionable = false;
|
|
5013
|
-
|
|
5597
|
+
disposition = await this.emitSuppressedPromptCarrier(
|
|
5598
|
+
session,
|
|
5599
|
+
rec,
|
|
5600
|
+
opts?.reRaiseToken,
|
|
5601
|
+
entry.contentPlain
|
|
5602
|
+
);
|
|
5014
5603
|
}
|
|
5604
|
+
if (disposition === "CLOSED" || disposition === "RETRY") return disposition;
|
|
5015
5605
|
this.e1NeedsReRaise.delete(rec.promptId);
|
|
5016
|
-
logger.info("E1:
|
|
5606
|
+
logger.info("E1: prompt publication settled", {
|
|
5017
5607
|
sessionId: session.sessionId,
|
|
5018
5608
|
promptId: rec.promptId,
|
|
5019
|
-
actionable
|
|
5609
|
+
actionable,
|
|
5610
|
+
disposition
|
|
5020
5611
|
});
|
|
5021
|
-
return
|
|
5612
|
+
return disposition;
|
|
5022
5613
|
} catch (e) {
|
|
5023
5614
|
if (!retryUntilAck && attempt >= MAX_ATTEMPTS) {
|
|
5024
5615
|
this.e1NeedsReRaise.add(rec.promptId);
|
|
@@ -5027,7 +5618,7 @@ var McpServer = class _McpServer {
|
|
|
5027
5618
|
promptId: rec.promptId,
|
|
5028
5619
|
error: e instanceof Error ? e.message : String(e)
|
|
5029
5620
|
});
|
|
5030
|
-
return
|
|
5621
|
+
return "RETRY";
|
|
5031
5622
|
}
|
|
5032
5623
|
if (retryUntilAck) {
|
|
5033
5624
|
const delay2 = Math.min(
|
|
@@ -5036,7 +5627,7 @@ var McpServer = class _McpServer {
|
|
|
5036
5627
|
);
|
|
5037
5628
|
await this.interruptibleBackoff(delay2, gen);
|
|
5038
5629
|
} else {
|
|
5039
|
-
await new Promise((
|
|
5630
|
+
await new Promise((resolve2) => setTimeout(resolve2, 250 * attempt));
|
|
5040
5631
|
}
|
|
5041
5632
|
}
|
|
5042
5633
|
}
|
|
@@ -5075,16 +5666,14 @@ var McpServer = class _McpServer {
|
|
|
5075
5666
|
const session = this.session;
|
|
5076
5667
|
if (!session) return;
|
|
5077
5668
|
const sid = session.sessionId;
|
|
5078
|
-
const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid && !this.e1NeedsResolution.has(e.record.promptId) && (0,
|
|
5669
|
+
const promptIds = [...this.e1PromptRaises.values()].filter((e) => e.record.sessionId === sid && !this.e1NeedsResolution.has(e.record.promptId) && (0, import_codevibe_core6.canRecoverPromptRaise)(e, this.promptPublications.isPublishing(e.record.promptId))).map((e) => e.record.promptId);
|
|
5079
5670
|
if (promptIds.length === 0) return;
|
|
5080
5671
|
try {
|
|
5081
|
-
|
|
5082
|
-
for (const promptId of agedOut) {
|
|
5672
|
+
await this.appSyncClient.refreshSessionOpenPromptTtl({ sessionId: sid, promptIds }, async (promptId, reRaiseToken) => {
|
|
5083
5673
|
const entry = this.e1PromptRaises.get(promptId);
|
|
5084
|
-
if (entry
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
}
|
|
5674
|
+
if (!entry || this.session !== session || entry.record.sessionId !== sid || this.e1NeedsResolution.has(promptId)) return "CLOSED";
|
|
5675
|
+
return this.reRaiseOneE1(session, entry, { reRaiseToken });
|
|
5676
|
+
});
|
|
5088
5677
|
} catch (e) {
|
|
5089
5678
|
logger.warn("E1: TTL-refresh tick failed (non-fatal, retries next tick)", {
|
|
5090
5679
|
error: e instanceof Error ? e.message : String(e)
|
|
@@ -5145,6 +5734,7 @@ var McpServer = class _McpServer {
|
|
|
5145
5734
|
}
|
|
5146
5735
|
this.subscription = null;
|
|
5147
5736
|
}
|
|
5737
|
+
await this.stopNativeSources(old);
|
|
5148
5738
|
try {
|
|
5149
5739
|
await this.toolActivity?.prepareForReplacement();
|
|
5150
5740
|
} catch (error) {
|
|
@@ -5187,10 +5777,10 @@ var McpServer = class _McpServer {
|
|
|
5187
5777
|
}
|
|
5188
5778
|
const result = { ...evt };
|
|
5189
5779
|
try {
|
|
5190
|
-
result.content =
|
|
5780
|
+
result.content = import_codevibe_core6.cryptoService.decryptContent(evt.content, session.sessionKey);
|
|
5191
5781
|
const rawMeta = parseMaybeJson(evt.metadata);
|
|
5192
5782
|
if (rawMeta && typeof rawMeta === "object" && typeof rawMeta.encrypted === "string") {
|
|
5193
|
-
const decryptedMeta =
|
|
5783
|
+
const decryptedMeta = import_codevibe_core6.cryptoService.decryptMetadata(
|
|
5194
5784
|
rawMeta.encrypted,
|
|
5195
5785
|
session.sessionKey
|
|
5196
5786
|
);
|
|
@@ -5314,35 +5904,35 @@ var McpServer = class _McpServer {
|
|
|
5314
5904
|
const content = state.paneDisplayHeader || state.matchedPaneHeader || state.pendingCall?.command || state.pendingCall?.filePath || "approval requested";
|
|
5315
5905
|
const input = {
|
|
5316
5906
|
sessionId: session.sessionId,
|
|
5317
|
-
type:
|
|
5318
|
-
source:
|
|
5907
|
+
type: import_codevibe_core6.EventType.INTERACTIVE_PROMPT,
|
|
5908
|
+
source: import_codevibe_core6.EventSource.DESKTOP,
|
|
5319
5909
|
content,
|
|
5320
5910
|
metadata: optionsForMobile,
|
|
5321
5911
|
// E1 (§4) — raise identity top-level: promptId + ownerToken/producerKind/
|
|
5322
5912
|
// mobileActionable (the backend's classifyRaise upserts the open-prompt row).
|
|
5323
|
-
...(0,
|
|
5913
|
+
...(0, import_codevibe_core6.raiseFieldsFromRecord)(e1rec),
|
|
5324
5914
|
// Per-conversation monotonic timestamp (event-timestamp-ordering fix
|
|
5325
5915
|
// v5.4, 2026-05-24). orderingKey scopes the counter to this conv so
|
|
5326
5916
|
// INTERACTIVE_PROMPT can't be forced to lastMs+1 by a newer event
|
|
5327
5917
|
// from a sibling conversation under v9's multi-conv-per-session
|
|
5328
5918
|
// model.
|
|
5329
|
-
timestamp: (0,
|
|
5919
|
+
timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({
|
|
5330
5920
|
orderingKey: `${session.sessionId}:${state.conversationId}`
|
|
5331
5921
|
})
|
|
5332
5922
|
};
|
|
5333
5923
|
try {
|
|
5334
5924
|
this.stashE1RaiseContent(e1rec.promptId, content, optionsForMobile);
|
|
5335
|
-
await this.flushToolActivityAtBoundary(
|
|
5925
|
+
await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.INTERACTIVE_PROMPT);
|
|
5336
5926
|
if (this.e1NeedsResolution.has(e1rec.promptId) || this.e1PromptRaises.get(e1rec.promptId)?.record !== e1rec) return;
|
|
5337
|
-
|
|
5927
|
+
await this.publishE1Prompt(session, input);
|
|
5338
5928
|
} catch (err) {
|
|
5339
5929
|
logger.error("createEvent INTERACTIVE_PROMPT failed", {
|
|
5340
5930
|
promptId: state.promptId,
|
|
5341
5931
|
error: String(err)
|
|
5342
5932
|
});
|
|
5343
|
-
this.
|
|
5344
|
-
|
|
5345
|
-
|
|
5933
|
+
if (this.e1PromptRaises.get(e1rec.promptId)?.record === e1rec && !this.e1NeedsResolution.has(e1rec.promptId)) {
|
|
5934
|
+
this.e1NeedsReRaise.add(e1rec.promptId);
|
|
5935
|
+
}
|
|
5346
5936
|
}
|
|
5347
5937
|
}
|
|
5348
5938
|
// ─── Mobile → desktop flow ──────────────────────────────────────────────
|
|
@@ -5380,7 +5970,7 @@ var McpServer = class _McpServer {
|
|
|
5380
5970
|
if (shouldDecrypt && sessionKey) {
|
|
5381
5971
|
try {
|
|
5382
5972
|
logger.info("Decrypting attachment", { id: attachment.id });
|
|
5383
|
-
buffer =
|
|
5973
|
+
buffer = import_codevibe_core6.cryptoService.decryptData(buffer, sessionKey);
|
|
5384
5974
|
logger.info("Attachment decrypted successfully", {
|
|
5385
5975
|
id: attachment.id,
|
|
5386
5976
|
decryptedSize: buffer.length
|
|
@@ -5435,7 +6025,7 @@ var McpServer = class _McpServer {
|
|
|
5435
6025
|
await this.ensureLaunchSession();
|
|
5436
6026
|
if (!this.session) return;
|
|
5437
6027
|
const session = this.session;
|
|
5438
|
-
if (evt.source !==
|
|
6028
|
+
if (evt.source !== import_codevibe_core6.EventSource.MOBILE) return;
|
|
5439
6029
|
if (evt.sessionId !== session.sessionId) {
|
|
5440
6030
|
logger.warn("Dropping mobile event with mismatched sessionId", {
|
|
5441
6031
|
expected: session.sessionId,
|
|
@@ -5446,19 +6036,20 @@ var McpServer = class _McpServer {
|
|
|
5446
6036
|
let decrypted;
|
|
5447
6037
|
try {
|
|
5448
6038
|
decrypted = this.decryptInbound(session, evt);
|
|
5449
|
-
} catch {
|
|
6039
|
+
} catch (error) {
|
|
6040
|
+
if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw error;
|
|
5450
6041
|
return;
|
|
5451
6042
|
}
|
|
5452
6043
|
evt = decrypted;
|
|
5453
|
-
await this.markDelivered(evt);
|
|
5454
|
-
if (evt.type ===
|
|
6044
|
+
if (!await this.markDelivered(evt)) return;
|
|
6045
|
+
if (evt.type === import_codevibe_core6.EventType.USER_PROMPT || evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
|
|
5455
6046
|
const mobileFloorMs = Date.parse(evt.timestamp);
|
|
5456
6047
|
this.mobilePromptFloorBySession.set(session.sessionId, {
|
|
5457
6048
|
floorMs: Number.isNaN(mobileFloorMs) ? Date.now() : mobileFloorMs,
|
|
5458
6049
|
setAtWallMs: Date.now()
|
|
5459
6050
|
});
|
|
5460
6051
|
}
|
|
5461
|
-
if (evt.type ===
|
|
6052
|
+
if (evt.type === import_codevibe_core6.EventType.USER_PROMPT || evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
|
|
5462
6053
|
let promptContent = evt.content;
|
|
5463
6054
|
const attachments = evt.attachments ?? [];
|
|
5464
6055
|
if (attachments.length > 0) {
|
|
@@ -5497,16 +6088,16 @@ var McpServer = class _McpServer {
|
|
|
5497
6088
|
const rawMeta = parseMaybeJson(evt.metadata);
|
|
5498
6089
|
const meta = rawMeta && typeof rawMeta === "object" ? rawMeta : {};
|
|
5499
6090
|
const evtAny = evt;
|
|
5500
|
-
const responsePromptId = evt.type ===
|
|
6091
|
+
const responsePromptId = evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE ? typeof meta.promptId === "string" && meta.promptId || typeof meta.prompt_id === "string" && meta.prompt_id || typeof evtAny.promptId === "string" && evtAny.promptId || typeof evtAny.prompt_id === "string" && evtAny.prompt_id || null : null;
|
|
5501
6092
|
const advertisedOptionNumbers = Array.from(new Set(
|
|
5502
6093
|
pendingPrompts.flatMap((prompt) => Object.keys(prompt.submitMap))
|
|
5503
6094
|
));
|
|
5504
|
-
const intent = (0,
|
|
6095
|
+
const intent = (0, import_codevibe_core6.classifyMobilePromptInput)(
|
|
5505
6096
|
promptContent,
|
|
5506
6097
|
advertisedOptionNumbers
|
|
5507
6098
|
);
|
|
5508
6099
|
if (intent.kind === "option") {
|
|
5509
|
-
const activePrompt = evt.type ===
|
|
6100
|
+
const activePrompt = evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE ? pendingPrompts.find((prompt) => prompt.promptId === responsePromptId) ?? null : pendingPrompts.length === 1 ? pendingPrompts[0] : null;
|
|
5510
6101
|
if (!activePrompt || !Object.prototype.hasOwnProperty.call(activePrompt.submitMap, intent.option)) {
|
|
5511
6102
|
logger.warn("Numeric input does not identify one current advertised prompt; dropping", {
|
|
5512
6103
|
sessionId: session.sessionId,
|
|
@@ -5516,7 +6107,7 @@ var McpServer = class _McpServer {
|
|
|
5516
6107
|
});
|
|
5517
6108
|
return;
|
|
5518
6109
|
}
|
|
5519
|
-
if (evt.type ===
|
|
6110
|
+
if (evt.type === import_codevibe_core6.EventType.PROMPT_RESPONSE) {
|
|
5520
6111
|
if (responsePromptId !== activePrompt.promptId) {
|
|
5521
6112
|
logger.warn("PROMPT_RESPONSE does not identify the current advertised prompt; dropping", {
|
|
5522
6113
|
sessionId: session.sessionId,
|
|
@@ -5526,13 +6117,13 @@ var McpServer = class _McpServer {
|
|
|
5526
6117
|
return;
|
|
5527
6118
|
}
|
|
5528
6119
|
}
|
|
5529
|
-
this.
|
|
6120
|
+
await this.trackDurableMobileEcho(session, evt, intent.option, "primary");
|
|
5530
6121
|
const optionResult = await this.promptResponder.sendApprovalReply(
|
|
5531
6122
|
activePrompt.promptId,
|
|
5532
6123
|
intent.option
|
|
5533
6124
|
);
|
|
5534
6125
|
if (!optionResult.ok) {
|
|
5535
|
-
this.mobileDeduper.forget(session.sessionId, intent.option);
|
|
6126
|
+
if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, intent.option);
|
|
5536
6127
|
return;
|
|
5537
6128
|
}
|
|
5538
6129
|
await this.resolveE1PluginApproval(session.sessionId, activePrompt.promptId);
|
|
@@ -5541,7 +6132,7 @@ var McpServer = class _McpServer {
|
|
|
5541
6132
|
return;
|
|
5542
6133
|
}
|
|
5543
6134
|
promptContent = intent.followUpText;
|
|
5544
|
-
await new Promise((
|
|
6135
|
+
await new Promise((resolve2) => setTimeout(resolve2, 250));
|
|
5545
6136
|
} else {
|
|
5546
6137
|
const dismissal = await this.promptResponder.dismissActivePrompt();
|
|
5547
6138
|
if (!dismissal.ok) {
|
|
@@ -5554,10 +6145,12 @@ var McpServer = class _McpServer {
|
|
|
5554
6145
|
const clearedPromptIds = this.approvalDetector.onPanePromptCleared();
|
|
5555
6146
|
await this.resolveAcceptedInputPrompts(session, clearedPromptIds);
|
|
5556
6147
|
}
|
|
5557
|
-
this.
|
|
5558
|
-
|
|
6148
|
+
const result = await this.promptResponder.sendFreeFormWhenNoApprovalActive(
|
|
6149
|
+
promptContent,
|
|
6150
|
+
() => this.trackDurableMobileEcho(session, evt, promptContent, "free_form")
|
|
6151
|
+
);
|
|
5559
6152
|
if (!result.ok) {
|
|
5560
|
-
this.mobileDeduper.forget(session.sessionId, promptContent);
|
|
6153
|
+
if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) this.mobileDeduper.forget(session.sessionId, promptContent);
|
|
5561
6154
|
logger.warn("sendFreeForm failed", { reason: result.reason, details: result.details });
|
|
5562
6155
|
return;
|
|
5563
6156
|
}
|
|
@@ -5565,18 +6158,37 @@ var McpServer = class _McpServer {
|
|
|
5565
6158
|
return;
|
|
5566
6159
|
}
|
|
5567
6160
|
}
|
|
6161
|
+
async trackDurableMobileEcho(session, event, content, lane) {
|
|
6162
|
+
if (!(0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(event)) {
|
|
6163
|
+
this.mobileDeduper.track(session.sessionId, content);
|
|
6164
|
+
return;
|
|
6165
|
+
}
|
|
6166
|
+
const selected = await this.selectNativeSource(session);
|
|
6167
|
+
if (selected.protocol !== "SYNC_V2" || !this.started || this.sessionDisabled || this.session !== session || event.sessionId !== selected.sessionScope.sessionId || event.sessionGenerationId !== selected.sessionScope.sessionGenerationId) throw Error("SYNC_ECHO_OWNER_CHANGED");
|
|
6168
|
+
if (content.trim()) await selected.writer.mobileEchoes("agy").arm(
|
|
6169
|
+
{ eventId: event.eventId, timestamp: event.timestamp, type: event.type, lane },
|
|
6170
|
+
content,
|
|
6171
|
+
{ nowMs: Date.now(), windowMs: 15e3, clockPrecisionMs: 1e3, allowSuffix: true, normalization: "whitespace" }
|
|
6172
|
+
);
|
|
6173
|
+
if (!this.started || this.sessionDisabled || this.session !== session) throw Error("SYNC_ECHO_OWNER_CHANGED");
|
|
6174
|
+
}
|
|
5568
6175
|
async emitPromptSafetyNotification(session, content) {
|
|
5569
6176
|
const input = {
|
|
5570
6177
|
sessionId: session.sessionId,
|
|
5571
|
-
type:
|
|
5572
|
-
source:
|
|
6178
|
+
type: import_codevibe_core6.EventType.NOTIFICATION,
|
|
6179
|
+
source: import_codevibe_core6.EventSource.DESKTOP,
|
|
5573
6180
|
content,
|
|
5574
6181
|
metadata: { promptSafetyBlocked: true },
|
|
5575
|
-
timestamp: (0,
|
|
6182
|
+
timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: session.sessionId })
|
|
5576
6183
|
};
|
|
5577
|
-
await this.flushToolActivityAtBoundary(
|
|
6184
|
+
await this.flushToolActivityAtBoundary(import_codevibe_core6.EventType.NOTIFICATION);
|
|
5578
6185
|
try {
|
|
5579
|
-
|
|
6186
|
+
if (!this.started || this.sessionDisabled || this.session !== session) return;
|
|
6187
|
+
const result = await this.appSyncClient.publishCompanionNotice(
|
|
6188
|
+
input,
|
|
6189
|
+
async () => !!await this.createEncryptedEvent(session, input)
|
|
6190
|
+
);
|
|
6191
|
+
logger.debug("Prompt-safety notification publication outcome", { sessionId: session.sessionId, result });
|
|
5580
6192
|
} catch (error) {
|
|
5581
6193
|
logger.warn("Failed to emit prompt-safety notification", { error: String(error) });
|
|
5582
6194
|
}
|
|
@@ -5586,23 +6198,20 @@ var McpServer = class _McpServer {
|
|
|
5586
6198
|
* soon as the plugin receives + decrypts the event and BEFORE the
|
|
5587
6199
|
* tmux send is attempted, mirroring codex/claude plugin semantics:
|
|
5588
6200
|
* "delivered" means the desktop daemon has the message in hand, not
|
|
5589
|
-
* that it has acted on it yet.
|
|
5590
|
-
*
|
|
6201
|
+
* that it has acted on it yet. Legacy checkmarks remain best-effort;
|
|
6202
|
+
* R2 requires exact cloud authority before terminal routing.
|
|
5591
6203
|
*/
|
|
5592
6204
|
async markDelivered(evt) {
|
|
5593
6205
|
try {
|
|
5594
|
-
await this.appSyncClient.updateEventStatus(
|
|
5595
|
-
eventId: evt.eventId,
|
|
5596
|
-
sessionId: evt.sessionId,
|
|
5597
|
-
timestamp: evt.timestamp,
|
|
5598
|
-
deliveryStatus: import_codevibe_core5.DeliveryStatus.DELIVERED
|
|
5599
|
-
});
|
|
6206
|
+
return await (0, import_codevibe_core6.acknowledgeMobileInputDelivery)(this.appSyncClient.updateEventStatus.bind(this.appSyncClient), evt);
|
|
5600
6207
|
} catch (err) {
|
|
5601
6208
|
logger.warn("updateEventStatus(DELIVERED) failed", {
|
|
5602
6209
|
eventId: evt.eventId,
|
|
5603
6210
|
sessionId: evt.sessionId,
|
|
5604
6211
|
error: String(err)
|
|
5605
6212
|
});
|
|
6213
|
+
if ((0, import_codevibe_core6.requiresSyncInputDeliveryReceipt)(evt)) throw err;
|
|
6214
|
+
return true;
|
|
5606
6215
|
}
|
|
5607
6216
|
}
|
|
5608
6217
|
/**
|
|
@@ -5615,12 +6224,7 @@ var McpServer = class _McpServer {
|
|
|
5615
6224
|
*/
|
|
5616
6225
|
async markExecuted(evt) {
|
|
5617
6226
|
try {
|
|
5618
|
-
await this.appSyncClient.updateEventStatus(
|
|
5619
|
-
eventId: evt.eventId,
|
|
5620
|
-
sessionId: evt.sessionId,
|
|
5621
|
-
timestamp: evt.timestamp,
|
|
5622
|
-
deliveryStatus: import_codevibe_core5.DeliveryStatus.EXECUTED
|
|
5623
|
-
});
|
|
6227
|
+
await (0, import_codevibe_core6.acknowledgeMobileInputExecution)(this.appSyncClient.updateEventStatus.bind(this.appSyncClient), evt);
|
|
5624
6228
|
} catch (err) {
|
|
5625
6229
|
logger.warn("updateEventStatus(EXECUTED) failed", {
|
|
5626
6230
|
eventId: evt.eventId,
|
|
@@ -5631,10 +6235,14 @@ var McpServer = class _McpServer {
|
|
|
5631
6235
|
}
|
|
5632
6236
|
// ─── HTTP API push hook (internal/programmatic) ────────────────────────
|
|
5633
6237
|
async handleHttpPushEvent(payload) {
|
|
6238
|
+
if (!isHttpEventPayload(payload)) throw Object.assign(Error("invalid HTTP event payload"), { code: "INVALID_SYNC_INPUT" });
|
|
5634
6239
|
const session = this.findSessionByBackendId(payload.sessionId);
|
|
5635
|
-
if (!session) {
|
|
6240
|
+
if (!session || !this.started || this.sessionDisabled) {
|
|
5636
6241
|
throw new SessionNotFoundError(payload.sessionId);
|
|
5637
6242
|
}
|
|
6243
|
+
const assertOwner = () => {
|
|
6244
|
+
if (!this.started || this.sessionDisabled || this.session !== session) throw new SessionNotFoundError(payload.sessionId);
|
|
6245
|
+
};
|
|
5638
6246
|
const normalizedMeta = parseMaybeJson(payload.metadata);
|
|
5639
6247
|
const metadata = normalizedMeta && typeof normalizedMeta === "object" ? normalizedMeta : {};
|
|
5640
6248
|
const input = {
|
|
@@ -5647,15 +6255,12 @@ var McpServer = class _McpServer {
|
|
|
5647
6255
|
// orderingKey is session-scoped. Slightly coarser than the
|
|
5648
6256
|
// per-conv key used by transcript emits, but acceptable because
|
|
5649
6257
|
// hook-script pushes are off the hot transcript path.
|
|
5650
|
-
timestamp: (0,
|
|
6258
|
+
timestamp: (0, import_codevibe_core6.prepareEventTimestamp)({ orderingKey: payload.sessionId })
|
|
5651
6259
|
};
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
);
|
|
5657
|
-
}
|
|
5658
|
-
return { eventId: created.eventId };
|
|
6260
|
+
return this.appSyncClient.publishCompanionEvent(input, async () => {
|
|
6261
|
+
assertOwner();
|
|
6262
|
+
return this.createEncryptedEvent(session, input);
|
|
6263
|
+
}, { assertOwner });
|
|
5659
6264
|
}
|
|
5660
6265
|
findSessionByBackendId(backendSessionId) {
|
|
5661
6266
|
if (!this.session) return null;
|
|
@@ -6024,7 +6629,7 @@ async function main() {
|
|
|
6024
6629
|
tmuxTarget: process.env.CODEVIBE_AGY_TMUX_TARGET ?? void 0,
|
|
6025
6630
|
wrapperPid: process.env.CODEVIBE_AGY_WRAPPER_PID ? parseInt(process.env.CODEVIBE_AGY_WRAPPER_PID, 10) : void 0
|
|
6026
6631
|
});
|
|
6027
|
-
(0,
|
|
6632
|
+
(0, import_codevibe_core6.installDaemonProcessGuards)(logger, {
|
|
6028
6633
|
onFatal: () => {
|
|
6029
6634
|
void server.stop().finally(() => process.exit(1));
|
|
6030
6635
|
}
|