@levistudio/redline 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts CHANGED
@@ -50,7 +50,7 @@ function getClientBundle(): Promise<string> {
50
50
 
51
51
  export function createServer(
52
52
  filePath: string,
53
- opts: { context?: string; csrfToken?: string; noAgent?: boolean } = {}
53
+ opts: { context?: string; csrfToken?: string; noAgent?: boolean; agentName?: string } = {}
54
54
  ) {
55
55
  const app = new Hono();
56
56
  const fileName = path.basename(filePath);
@@ -129,12 +129,23 @@ export function createServer(
129
129
 
130
130
  // Abandonment detection: if no browser is connected for ABANDON_GRACE_MS after
131
131
  // the first one ever connected, fire onAbandonCallback so the CLI can exit.
132
- // Default 10min — DevTools-offline debugging, brief network blips, and tab
133
- // sleeps all reconnect well within that. The previous 2min default tripped on
134
- // routine offline-mode testing. Override with REDLINE_ABANDON_MS for tests.
132
+ // Default 10min — this is now only the *backstop* for the no-beacon case
133
+ // (browser crash, kill -9, OS-killed tab). A cleanly closed tab fires an
134
+ // explicit /api/tab-closed beacon and takes the much shorter TAB_CLOSE_GRACE_MS
135
+ // path instead. The long backstop must stay generous: a bare SSE drop (laptop
136
+ // sleep, network blip, DevTools offline) is NOT a closed tab, and exiting on
137
+ // it kills a session the user means to continue. Override with
138
+ // REDLINE_ABANDON_MS for tests.
135
139
  const ABANDON_GRACE_MS = process.env.REDLINE_ABANDON_MS
136
140
  ? parseInt(process.env.REDLINE_ABANDON_MS, 10)
137
141
  : 10 * 60 * 1000;
142
+ // Grace applied after an explicit tab-close beacon. A reload also fires the
143
+ // beacon, so we can't exit immediately — but a reload reconnects its SSE
144
+ // within ~1s, while a real close never does. A few seconds covers the
145
+ // reconnect. Override with REDLINE_TABCLOSE_MS for tests.
146
+ const TAB_CLOSE_GRACE_MS = process.env.REDLINE_TABCLOSE_MS
147
+ ? parseInt(process.env.REDLINE_TABCLOSE_MS, 10)
148
+ : 8000;
138
149
  let hadBrowser = false;
139
150
  let abandonTimer: ReturnType<typeof setTimeout> | null = null;
140
151
  let onAbandonCallback: (() => void) | undefined;
@@ -194,15 +205,24 @@ export function createServer(
194
205
  }, REVISION_TIMEOUT_MS);
195
206
  }
196
207
 
208
+ function armAbandonTimer(graceMs: number) {
209
+ if (abandonTimer) clearTimeout(abandonTimer);
210
+ abandonTimer = setTimeout(() => {
211
+ abandonTimer = null;
212
+ // Re-check at fire time: a tab may have (re)connected during the grace
213
+ // — a reload, or a second tab — in which case nothing is abandoned.
214
+ if (browserClients.size > 0) return;
215
+ console.log(`\n[redline] No browser connected for ${graceMs / 1000}s — assuming abandoned.`);
216
+ onAbandonCallback?.();
217
+ }, graceMs);
218
+ }
219
+
197
220
  function checkBrowserPresence() {
198
221
  if (browserClients.size > 0) {
199
222
  hadBrowser = true;
200
223
  if (abandonTimer) { clearTimeout(abandonTimer); abandonTimer = null; }
201
224
  } else if (hadBrowser && !abandonTimer) {
202
- abandonTimer = setTimeout(() => {
203
- console.log(`\n[redline] No browser connected for ${ABANDON_GRACE_MS / 1000}s — assuming abandoned.`);
204
- onAbandonCallback?.();
205
- }, ABANDON_GRACE_MS);
225
+ armAbandonTimer(ABANDON_GRACE_MS);
206
226
  }
207
227
  }
208
228
 
@@ -255,7 +275,7 @@ export function createServer(
255
275
  const agentRepliedAt = latestRound?.agent_replied_at ?? null;
256
276
  const roundNumber = latestRound?.round ?? 1;
257
277
  const totalRounds = sidecar.rounds.length;
258
- return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false));
278
+ return c.html(pageTemplate(fileName, html, serializeCommentsForClient(comments), roundResolved, agentRepliedAt, roundNumber, totalRounds, sidecar.context, false, csrfToken, opts.noAgent ?? false, opts.agentName));
259
279
  });
260
280
 
261
281
  // Add a comment to the active round
@@ -410,7 +430,7 @@ export function createServer(
410
430
  });
411
431
 
412
432
  // CLI signals the agent subprocess is gone for good (restart cap exhausted,
413
- // missing claude CLI, etc). Surfaces a small persistent indicator in the
433
+ // missing provider CLI, etc). Surfaces a small persistent indicator in the
414
434
  // header so the user knows replies aren't coming and can restart redline.
415
435
  // No paired "agent-available" event — recovery requires a restart, so the
416
436
  // indicator stays until the page reloads.
@@ -424,6 +444,17 @@ export function createServer(
424
444
  return c.json({ ok: true });
425
445
  });
426
446
 
447
+ // A browser tab fired pagehide — it is closing, reloading, or navigating away.
448
+ // This is a hint, not proof of abandonment (a reload fires it too), so we
449
+ // shorten the abandon grace rather than exiting outright. On a real close the
450
+ // SSE never reconnects and the short timer fires; on a reload the new page
451
+ // reconnects within ~1s and checkBrowserPresence clears the timer. Crashes
452
+ // and kill -9 send no beacon and fall back to the long ABANDON_GRACE_MS.
453
+ app.post("/api/tab-closed", (c) => {
454
+ if (hadBrowser) armAbandonTimer(TAB_CLOSE_GRACE_MS);
455
+ return c.json({ ok: true });
456
+ });
457
+
427
458
  // Agent signals it is composing a reply (shows typing indicator in thread)
428
459
  app.post("/api/comment/:id/thinking", async (c) => {
429
460
  const id = c.req.param("id");
@@ -440,6 +471,7 @@ export function createServer(
440
471
  name?: string;
441
472
  requires_revision?: boolean;
442
473
  revision_reason?: string;
474
+ escalate?: boolean;
443
475
  }>();
444
476
  if (!body.message?.trim()) return c.json({ ok: false, error: "message is required" }, 400);
445
477
  const role = (body.role === "human" ? "human" : "agent") as "human" | "agent";
@@ -453,9 +485,12 @@ export function createServer(
453
485
  const entry: import("./sidecar").ThreadEntry = { role, message: body.message.trim(), at: new Date().toISOString() };
454
486
  if (name) entry.name = name;
455
487
  // Verdict only meaningful on agent replies; ignore on human entries.
456
- if (role === "agent" && typeof body.requires_revision === "boolean") {
457
- entry.requires_revision = body.requires_revision;
458
- if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
488
+ if (role === "agent") {
489
+ if (typeof body.requires_revision === "boolean") {
490
+ entry.requires_revision = body.requires_revision;
491
+ if (body.revision_reason?.trim()) entry.revision_reason = body.revision_reason.trim();
492
+ }
493
+ if (body.escalate === true) entry.escalate = true;
459
494
  }
460
495
  comment.thread.push(entry);
461
496
  return { skip: false as const, roundNumber: round.round, comment };
@@ -553,7 +588,8 @@ export function createServer(
553
588
  sidecar.context,
554
589
  true, // readOnly
555
590
  csrfToken,
556
- opts.noAgent ?? false
591
+ opts.noAgent ?? false,
592
+ opts.agentName
557
593
  ));
558
594
  });
559
595
 
@@ -646,5 +682,3 @@ export function createServer(
646
682
  onRevisionRecovered(cb: () => void) { onRevisionRecoveredCallback = cb; },
647
683
  };
648
684
  }
649
-
650
-
package/src/sidecar.ts CHANGED
@@ -13,6 +13,11 @@ export interface ThreadEntry {
13
13
  // action defaults to "Revise" or "Accept as-is". Only set on agent entries.
14
14
  requires_revision?: boolean;
15
15
  revision_reason?: string;
16
+ // Set true on an agent reply when the comment can't be acted on from inside
17
+ // this review — it needs the agent that *launched* redline (project context,
18
+ // tools, or authority the inline agent lacks). Surfaced in the closeout
19
+ // summary so the launching agent picks it up. Only set on agent entries.
20
+ escalate?: boolean;
16
21
  }
17
22
 
18
23
  // Latest agent verdict on a comment thread: