switchroom 0.14.4 → 0.14.5

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.
@@ -49352,8 +49352,8 @@ var {
49352
49352
  } = import__.default;
49353
49353
 
49354
49354
  // src/build-info.ts
49355
- var VERSION = "0.14.4";
49356
- var COMMIT_SHA = "a9f2d29a";
49355
+ var VERSION = "0.14.5";
49356
+ var COMMIT_SHA = "c12d4240";
49357
49357
 
49358
49358
  // src/cli/agent.ts
49359
49359
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.4",
3
+ "version": "0.14.5",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23422,6 +23422,46 @@ function extractAssistantText(obj) {
23422
23422
  }
23423
23423
  return parts.join(" ").trim();
23424
23424
  }
23425
+ function computeFirstAttachCursor(file, size) {
23426
+ const SCAN_CAP = 1024 * 1024;
23427
+ const scanStart = Math.max(0, size - SCAN_CAP);
23428
+ let buf;
23429
+ try {
23430
+ const fd = openSync(file, "r");
23431
+ try {
23432
+ buf = Buffer.allocUnsafe(size - scanStart);
23433
+ readSync(fd, buf, 0, buf.length, scanStart);
23434
+ } finally {
23435
+ closeSync(fd);
23436
+ }
23437
+ } catch {
23438
+ return size;
23439
+ }
23440
+ let lastEnqueueOffset = -1;
23441
+ let turnEndedAfterEnqueue = false;
23442
+ let lineStart = 0;
23443
+ let skipPartial = scanStart > 0;
23444
+ for (let i = 0;i <= buf.length; i++) {
23445
+ if (i !== buf.length && buf[i] !== 10)
23446
+ continue;
23447
+ if (skipPartial) {
23448
+ skipPartial = false;
23449
+ } else if (i > lineStart) {
23450
+ const line = buf.toString("utf8", lineStart, i);
23451
+ if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
23452
+ lastEnqueueOffset = scanStart + lineStart;
23453
+ turnEndedAfterEnqueue = false;
23454
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
23455
+ turnEndedAfterEnqueue = true;
23456
+ }
23457
+ }
23458
+ lineStart = i + 1;
23459
+ }
23460
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
23461
+ return lastEnqueueOffset;
23462
+ }
23463
+ return size;
23464
+ }
23425
23465
  function startSessionTail(config2) {
23426
23466
  const cwd = config2.cwd ?? process.cwd();
23427
23467
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join3(homedir2(), ".claude");
@@ -23558,11 +23598,16 @@ function startSessionTail(config2) {
23558
23598
  } else {
23559
23599
  pendingPartial = "";
23560
23600
  try {
23561
- cursor = statSync3(file).size;
23601
+ const size = statSync3(file).size;
23602
+ cursor = computeFirstAttachCursor(file, size);
23603
+ if (cursor < size) {
23604
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
23605
+ } else {
23606
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
23607
+ }
23562
23608
  } catch {
23563
23609
  cursor = 0;
23564
23610
  }
23565
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
23566
23611
  }
23567
23612
  const attachSid = sessionIdForFile(file);
23568
23613
  if (attachSid)
@@ -50068,11 +50068,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
50068
50068
  }
50069
50069
 
50070
50070
  // ../src/build-info.ts
50071
- var VERSION = "0.14.4";
50072
- var COMMIT_SHA = "a9f2d29a";
50073
- var COMMIT_DATE = "2026-05-28T20:55:22+10:00";
50071
+ var VERSION = "0.14.5";
50072
+ var COMMIT_SHA = "c12d4240";
50073
+ var COMMIT_DATE = "2026-05-28T21:57:39+10:00";
50074
50074
  var LATEST_PR = null;
50075
- var COMMITS_AHEAD_OF_TAG = 5;
50075
+ var COMMITS_AHEAD_OF_TAG = 2;
50076
50076
 
50077
50077
  // gateway/boot-version.ts
50078
50078
  function formatRelativeAgo(iso) {
@@ -17460,6 +17460,46 @@ function extractAssistantText(obj) {
17460
17460
  }
17461
17461
  return parts.join(" ").trim();
17462
17462
  }
17463
+ function computeFirstAttachCursor(file, size) {
17464
+ const SCAN_CAP = 1024 * 1024;
17465
+ const scanStart = Math.max(0, size - SCAN_CAP);
17466
+ let buf;
17467
+ try {
17468
+ const fd = openSync(file, "r");
17469
+ try {
17470
+ buf = Buffer.allocUnsafe(size - scanStart);
17471
+ readSync(fd, buf, 0, buf.length, scanStart);
17472
+ } finally {
17473
+ closeSync(fd);
17474
+ }
17475
+ } catch {
17476
+ return size;
17477
+ }
17478
+ let lastEnqueueOffset = -1;
17479
+ let turnEndedAfterEnqueue = false;
17480
+ let lineStart = 0;
17481
+ let skipPartial = scanStart > 0;
17482
+ for (let i = 0;i <= buf.length; i++) {
17483
+ if (i !== buf.length && buf[i] !== 10)
17484
+ continue;
17485
+ if (skipPartial) {
17486
+ skipPartial = false;
17487
+ } else if (i > lineStart) {
17488
+ const line = buf.toString("utf8", lineStart, i);
17489
+ if (line.includes('"type":"queue-operation"') && line.includes('"operation":"enqueue"')) {
17490
+ lastEnqueueOffset = scanStart + lineStart;
17491
+ turnEndedAfterEnqueue = false;
17492
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
17493
+ turnEndedAfterEnqueue = true;
17494
+ }
17495
+ }
17496
+ lineStart = i + 1;
17497
+ }
17498
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
17499
+ return lastEnqueueOffset;
17500
+ }
17501
+ return size;
17502
+ }
17463
17503
  function startSessionTail(config2) {
17464
17504
  const cwd = config2.cwd ?? process.cwd();
17465
17505
  const claudeHome = config2.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join4(homedir3(), ".claude");
@@ -17596,11 +17636,16 @@ function startSessionTail(config2) {
17596
17636
  } else {
17597
17637
  pendingPartial = "";
17598
17638
  try {
17599
- cursor = statSync4(file).size;
17639
+ const size = statSync4(file).size;
17640
+ cursor = computeFirstAttachCursor(file, size);
17641
+ if (cursor < size) {
17642
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`);
17643
+ } else {
17644
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
17645
+ }
17600
17646
  } catch {
17601
17647
  cursor = 0;
17602
17648
  }
17603
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`);
17604
17649
  }
17605
17650
  const attachSid = sessionIdForFile(file);
17606
17651
  if (attachSid)
@@ -603,6 +603,66 @@ export interface SessionTailHandle {
603
603
  getActiveFile(): string | null
604
604
  }
605
605
 
606
+ /**
607
+ * Byte offset to seek to on the FIRST attach to a session transcript.
608
+ *
609
+ * Normally EOF — we only want NEW events, not replayed history. But if the
610
+ * agent restarted MID-TURN (the bridge's session-tail starts only after
611
+ * claude has already written this turn's `queue-operation enqueue` line),
612
+ * a plain seek-to-EOF skips that enqueue. `enqueue` is the ONLY event that
613
+ * carries the chatId and that sets the gateway's `currentTurn`, so missing
614
+ * it leaves the first post-restart turn with no currentTurn — killing the
615
+ * progress card, draft-mirror, and silence-poke for that turn.
616
+ *
617
+ * Fix: in a bounded tail scan, find the last `enqueue` that has NO
618
+ * `turn_duration` (turn_end) after it — an in-flight turn — and return its
619
+ * line offset so it (and the turn's subsequent events) replay. A completed
620
+ * turn (a `turn_duration` follows the enqueue) returns EOF: no replay.
621
+ */
622
+ export function computeFirstAttachCursor(file: string, size: number): number {
623
+ const SCAN_CAP = 1024 * 1024 // bound the tail read at 1 MiB
624
+ const scanStart = Math.max(0, size - SCAN_CAP)
625
+ let buf: Buffer
626
+ try {
627
+ const fd = openSync(file, 'r')
628
+ try {
629
+ buf = Buffer.allocUnsafe(size - scanStart)
630
+ readSync(fd, buf, 0, buf.length, scanStart)
631
+ } finally {
632
+ closeSync(fd)
633
+ }
634
+ } catch {
635
+ return size
636
+ }
637
+ let lastEnqueueOffset = -1
638
+ let turnEndedAfterEnqueue = false
639
+ let lineStart = 0
640
+ // If the scan didn't start at byte 0, the first line is a partial — skip it.
641
+ let skipPartial = scanStart > 0
642
+ for (let i = 0; i <= buf.length; i++) {
643
+ if (i !== buf.length && buf[i] !== 0x0a) continue
644
+ if (skipPartial) {
645
+ skipPartial = false
646
+ } else if (i > lineStart) {
647
+ const line = buf.toString('utf8', lineStart, i)
648
+ if (
649
+ line.includes('"type":"queue-operation"') &&
650
+ line.includes('"operation":"enqueue"')
651
+ ) {
652
+ lastEnqueueOffset = scanStart + lineStart
653
+ turnEndedAfterEnqueue = false
654
+ } else if (lastEnqueueOffset >= 0 && line.includes('"subtype":"turn_duration"')) {
655
+ turnEndedAfterEnqueue = true
656
+ }
657
+ }
658
+ lineStart = i + 1
659
+ }
660
+ if (lastEnqueueOffset >= 0 && !turnEndedAfterEnqueue) {
661
+ return lastEnqueueOffset
662
+ }
663
+ return size
664
+ }
665
+
606
666
  /**
607
667
  * Start tailing the active Claude Code session file. The tailer:
608
668
  * 1. Polls the projects dir for the most recent .jsonl
@@ -778,14 +838,20 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
778
838
  log?.(`session-tail: re-attached to ${file} (cursor=${cursor}, restored)`)
779
839
  } else {
780
840
  // First attach to this file — seek to current end so we only see
781
- // new events, not history.
841
+ // new events, EXCEPT replay from an in-flight turn's enqueue if the
842
+ // agent restarted mid-turn (see firstAttachCursor).
782
843
  pendingPartial = ''
783
844
  try {
784
- cursor = statSync(file).size
845
+ const size = statSync(file).size
846
+ cursor = computeFirstAttachCursor(file, size)
847
+ if (cursor < size) {
848
+ log?.(`session-tail: attached to ${file} (cursor=${cursor}, replaying in-flight turn from offset; size=${size})`)
849
+ } else {
850
+ log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
851
+ }
785
852
  } catch {
786
853
  cursor = 0
787
854
  }
788
- log?.(`session-tail: attached to ${file} (cursor=${cursor})`)
789
855
  }
790
856
  // Eagerly create + subscribe the PreToolUse sidecar for this session
791
857
  // NOW (on attach), not lazily on the first JSONL tool_use — otherwise
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { computeFirstAttachCursor } from '../session-tail.js'
6
+
7
+ /**
8
+ * computeFirstAttachCursor: on first attach to a transcript, seek to EOF
9
+ * UNLESS the agent restarted mid-turn (an `enqueue` with no `turn_duration`
10
+ * after it). Missing that enqueue strands the first post-restart turn with
11
+ * no currentTurn (dead progress card / draft-mirror / silence-poke).
12
+ */
13
+
14
+ const ENQUEUE = '{"type":"queue-operation","operation":"enqueue","content":"chat:123 msg:1"}'
15
+ const DEQUEUE = '{"type":"queue-operation","operation":"dequeue"}'
16
+ const ASSISTANT = '{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}'
17
+ const TURN_DURATION = '{"type":"system","subtype":"turn_duration","durationMs":4200}'
18
+
19
+ function writeTranscript(dir: string, lines: string[]): { file: string; size: number } {
20
+ const file = join(dir, 'sess.jsonl')
21
+ writeFileSync(file, lines.join('\n') + '\n')
22
+ return { file, size: statSync(file).size }
23
+ }
24
+
25
+ function offsetOfLine(lines: string[], index: number): number {
26
+ let off = 0
27
+ for (let i = 0; i < index; i++) off += Buffer.byteLength(lines[i]!, 'utf8') + 1 // +1 for '\n'
28
+ return off
29
+ }
30
+
31
+ describe('computeFirstAttachCursor', () => {
32
+ let dir: string
33
+ beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'first-attach-')) })
34
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
35
+
36
+ it('in-flight turn (enqueue, no turn_duration after) → replays from the enqueue offset', () => {
37
+ const lines = [ASSISTANT, ENQUEUE, DEQUEUE, ASSISTANT] // enqueue at index 1, no turn_duration
38
+ const { file, size } = writeTranscript(dir, lines)
39
+ expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 1))
40
+ })
41
+
42
+ it('completed turn (turn_duration after the enqueue) → EOF, no replay', () => {
43
+ const lines = [ENQUEUE, DEQUEUE, ASSISTANT, TURN_DURATION]
44
+ const { file, size } = writeTranscript(dir, lines)
45
+ expect(computeFirstAttachCursor(file, size)).toBe(size)
46
+ })
47
+
48
+ it('no enqueue in the tail → EOF', () => {
49
+ const lines = [ASSISTANT, ASSISTANT, TURN_DURATION]
50
+ const { file, size } = writeTranscript(dir, lines)
51
+ expect(computeFirstAttachCursor(file, size)).toBe(size)
52
+ })
53
+
54
+ it('completed turn followed by a NEW in-flight turn → replays from the second enqueue', () => {
55
+ // turn 1: enqueue+turn_duration (done). turn 2: enqueue, still running.
56
+ const lines = [ENQUEUE, ASSISTANT, TURN_DURATION, ENQUEUE, DEQUEUE, ASSISTANT]
57
+ const { file, size } = writeTranscript(dir, lines)
58
+ expect(computeFirstAttachCursor(file, size)).toBe(offsetOfLine(lines, 3))
59
+ })
60
+
61
+ it('empty / missing file → returns the given size (degrades to EOF)', () => {
62
+ const missing = join(dir, 'nope.jsonl')
63
+ expect(computeFirstAttachCursor(missing, 0)).toBe(0)
64
+ })
65
+ })