Skip to content

Feat/realtime broadcast presence - #615

Open
edgarsskore wants to merge 13 commits into
mainfrom
feat/realtime-broadcast-presence
Open

Feat/realtime broadcast presence#615
edgarsskore wants to merge 13 commits into
mainfrom
feat/realtime-broadcast-presence

Conversation

@edgarsskore

@edgarsskore edgarsskore commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • Bug Fixes
    • Prevent duplicate tool execution with bounded in-process deduping plus stricter pending-only claiming and dispatch.
    • Persist call results before notifying; sanitize NUL bytes in stored outputs/errors.
    • Improve realtime reliability with presence/capability gating, tighter doorbell routing, tier-aware heartbeat, bounded retries/backoff, and safer shutdown behavior.
  • New Features
    • Cap stored tool output history at 4KB (oversized outputs are omitted with a marker).
  • Tests
    • Added/expanded remote-transport, reconnect-backoff, and NUL-sanitization regression coverage.
  • Chores
    • Record precise shutdown timestamps for offline status; harden line-count test setup/teardown.

edgarsskore and others added 9 commits July 24, 2026 16:21
Join the private user:{id} channel instead of the shared postgres_changes
queue: presence track = live "online" signal, new_call doorbell receive (with
row-fetch retry), result doorbell send after the row write. Conditional claim
(UPDATE ... eq(status,'pending').select('id')) makes execution exactly-once
under dual delivery during the transition.

- 30-min bookkeeping last_seen write (presence carries liveness), re-asserts
  status:'online' only when the channel is joined (no masking a deaf device);
  clean-shutdown last_seen stamp.
- Jittered reconnect backoff + self-heal-during-backoff bail-out; realtime
  JWT re-auth on token refresh.
- capabilities: { transport_broadcast_v1, app_version } for adoption tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tool result containing a NUL byte (binary file reads, process output) made
the jsonb `result` write fail with Postgres 22P05 "unsupported Unicode escape
sequence" — leaving the call stuck 'executing' so the user waited out a 5-min
timeout for a tool that actually ran (~300-350/day in prod). The device is the
only writer of the result column, so the fix lives here.

- stripNullBytes(): recursively strips NUL from the result before the write,
  with a fast path that only reparses when a NUL is actually present.
- Fail-fast fallback: if a result write still fails for any reason, record a
  terminal 'failed' with a text-only error_message so the user gets an
  immediate honest error instead of a phantom timeout.

Reproduced end-to-end (null-byte tool output -> stuck row / 22P05) and verified
fixed (result stored NUL-free, call completes in ~1s) on staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P1 — fail-open DB claim could execute a tool twice. markCallExecuting returns
true on a transient write error, and during the transition BOTH transports
deliver every call, so a correlated REST blip let both deliveries proceed
(legacy claims-on-error and runs; broadcast then finds the row still pending,
claims cleanly, and runs the same tool again). Side-effecting commands could
run twice. Added an in-memory seen-call-id guard in handleNewToolCall: dual
delivery is always intra-process, so the local check is authoritative and
cannot fail open. The DB claim stays for row-state/cross-restart/observability.

P2 — stripNullBytes corrupted valid content. It serialized to JSON and regexed
the ESCAPE TEXT, so content containing the six literal chars backslash-u-0-0-0-0
(e.g. reading a source file with that escape) was silently altered, and a
doubled-backslash form threw SyntaxError -> the call was reported failed though
the tool succeeded. Rewritten as a recursive walk over strings/keys that strips
real NUL characters only. New test covers both regressions plus the original
Postgres 22P05 case.

P2 — test-remote-channel-reconnect.js was broken by the new channel contract
(FakeChannel lacked track/untrack) and is auto-discovered by npm test, so the
whole DCMCP suite failed on this branch. Added the presence fakes (resolving
with a status string, as realtime-js does) and stubbed the new jittered
reconnect sleep so the suite stays sub-second. npm test: 46/46 green.

Also: strip NUL from error_message (text rejects it too, and result === null
means the fail-fast fallback would not fire); check track()'s resolved status
instead of logging every resolution as success; skip untrack() on a non-joined
channel so a dead socket can't stall past the 5s shutdown deadline; include
call_id in the doorbell-row-missing event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Drop remote_channel_doorbell_received: it fired on EVERY remote tool call
  (~126k/day in prod) for permanent per-call telemetry volume, and it is
  redundant — dispatch stamps metadata.transport, which rides the existing
  mcp_command_executed event, so transport usage is already segmentable
  server-side. The doorbell FAILURE paths are still captured.
- Seen-call-id cap 500 -> 100 (~10 KB). Dual delivery arrives within
  milliseconds, so the window only needs to outlive that; 100 is still several
  minutes of heavy agent traffic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tbeat

- setSession pushed the token it was HANDED to realtime, but auth.setSession()
  refreshes an expired one internally first (device asleep >1h with
  --persist-session). Pushing the stale parameter overwrote the fresh token
  realtime already had, so every private-channel join failed until the next
  refresh — ~50 min deaf. Push the CURRENT session token instead.
- untrack() on shutdown: a half-open socket still reports state 'joined', so the
  state guard didn't help — the presence push buffers and settles via
  realtime-js's 10s timeout, past device.ts's 5s force-exit, skipping
  unsubscribe() and the durable offline write. Bounded with a 1s race.
- broadcast { ack: true } + presence { enabled: true } on the device channel.
  Without ack, send() resolves 'ok' at socket-write, so notifyResult's status
  check and its failure telemetry could never fire.
- updateHeartbeat: skip the write entirely when not joined. Bumping last_seen on
  a deaf device kept its row perpetually young, so the staleness sweep could
  never age out a stale 'online' — exactly the row dispatch falls back to when
  presence is unavailable.
- Dropped remote_channel_private_join_failed: it fired on every CHANNEL_ERROR,
  a 1:1 duplicate of remote_channel_subscription_error with no added
  specificity. Filter the surviving event on the error text instead.
- RECREATE_TIMEOUT_MS comment corrected: the guard is held for backoff (<=45s)
  PLUS the 30s cap, so the watchdog blind spot is ~75s, not 30s.
- Test: replaced the comment claiming a backoff assertion with an actual one
  (grows with consecutive attempts, always positive, capped at 30s).

npm test: 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etry

- LEGACY FALLBACK IS INDEPENDENT AGAIN. The postgres_changes listener had been
  moved onto the private user:{id} channel, so the transition's safety net
  shared a single point of failure with the thing it backs up: an 008 policy
  problem (or any private-channel auth failure) took out BOTH transports and the
  device went completely dark instead of degrading to the old path. It now lives
  on its own public channel with its own lifecycle, rebuilt alongside the
  private one on recreate and torn down on unsubscribe. Costs one extra channel
  per device (its own connection carries 2, far under the 100 quota). Removed
  entirely at the flip.
- track() failures are retried (3 attempts) and createChannel no longer resolves
  before presence lands. Previously a single non-'ok' track left a fully working
  device invisible — the server treats absent presence as authoritative offline,
  so dispatch threw "No devices available" with nothing to re-track until the
  channel bounced. checkConnectionHealth also re-tracks a joined channel whose
  presence never published, so it self-heals.
- Backoff test was flaky (~10%, measured) AND vacuous: the fake heals on
  disconnect, so every recreate succeeded, reconnectAttempt reset to 0, and all
  samples came from the attempt-1 distribution — "grows" was a coin flip and the
  cap was never exercised (and was asserted at 30s when the formula tops out at
  45s). Now models a persistent outage so attempts actually climb; 12/12 clean.
- PUBLISH.md: release gate for this transport (008 applied + new server
  deployed, in that order) — the migrations README lives in the other repo,
  which is not where the person cutting an npm release is looking.

npm test: 46/46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
P1 — the device advertised transport_broadcast_v1 during registerDevice(),
before createChannel() was even attempted. The server treats that flag as
binding: for a flagged device absent presence is authoritative offline, so
dispatch throws "No devices available". If the private channel could not join
(008 missing, authz, a Realtime incident) but the legacy postgres_changes
channel was perfectly healthy, the server refused to dispatch at all — the
device went dark, which is precisely what the independent legacy channel was
added to prevent. The flag is now written only after SUBSCRIBED *and* a
successful presence track, and withdrawn when presence definitively fails, so a
device that cannot keep the promise reports itself legacy and stays reachable.
This also makes the PUBLISH.md release gate far less load-bearing.

P1 — recreateChannel() destroyed the legacy channel and only rebuilt it AFTER
createChannel() resolved, so any failure (or timeout) left the safety net dead
for the whole outage, with every health tick repeating the teardown. It is now
rebuilt before createChannel. Related: trackPresenceWithRetry's worst case
(~31.5s: three 10s realtime pushes plus backoff) exceeded RECREATE_TIMEOUT_MS
(30s), so a recreate where presence never acked ALWAYS timed out — the two
constants were in silent conflict. Raised to 45s.

Also: bound removeLegacyChannel() in unsubscribe() (it was an unbounded await in
front of the deliberately 1s-bounded untrack, defeating it and risking the 5s
force-exit); include deviceId in createChannel's prerequisite guard (it is the
presence KEY — a null key gets a random one and the device is invisible while
every local signal says healthy); add an in-flight guard to the presence
self-heal so 10s ticks can't stack pushes on a wedged socket; stop serializing
the whole result object in the hot-path debug log (13 MB payloads cost more
there than everything the sanitizer was benchmarked against).

New test file covering the branch's headline mechanism, which had none:
exactly-once under dual delivery (including the fail-open claim case), the
device-id filter preceding dedupe, seen-set bounding, onDoorbell routing
(other-device, already-claimed, missing row, fetch retry), and the
updateCallResult-before-notifyResult ordering the server depends on.

npm test: 47/47.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three blackout bugs, all from the same invariant: the server tiers BOTH its
offline sweep and its presence overlay on the device-written
capabilities.transport_broadcast_v1 flag, never on the app version.

1. Heartbeat cadence followed the BUILD, not the tier. It was 30 min flat,
   while a device without the flag is judged by the server's 45s legacy
   threshold — and the flag is only written after presence is proven. So any
   device that could not prove the private channel (008 not applied, an authz
   hiccup, exhausted track() retries) was swept offline ~45s after registering
   and stayed undispatchable, though its independent legacy channel was joined
   and could run every call. Split into CAPABLE_HEARTBEAT_INTERVAL (5 min) and
   LEGACY_HEARTBEAT_INTERVAL (15s), chosen per-tick by heartbeatIntervalMs();
   the fixed setInterval became a self-rescheduling scheduleHeartbeat() that
   re-arms on every tier change and writes immediately when dropping to the
   fast tier.

2. updateHeartbeat gated its write on the PRIVATE channel, so a device
   reachable only via the legacy channel never wrote last_seen at all — fixing
   the cadence alone would not have helped. Now gated on isReachable()
   (private OR legacy joined), which also keeps the deliberate silence for a
   genuinely deaf device so the sweep can still age its row out.

3. `status` is transport-agnostic — it is what the server's resolveTargetDevice
   filters on — but every degraded state of the private channel wrote 'offline'.
   Since realtime-js re-fires CHANNEL_ERROR on every failed rejoin, the row
   oscillated against the heartbeat and roughly half of all dispatches failed
   for healthy machines. Now driven by syncReachabilityStatus() off the same
   isReachable() predicate, with writes serialized through a single-slot chain
   so a teardown's write cannot overtake a join's.

Also: the capability was only ever withdrawn from trackPresenceInner, which is
unreachable unless the channel is already joined — so a device that could never
join kept advertising itself and the server's presence overlay reported it
authoritatively OFFLINE until the process restarted. It now withdraws after
TRANSPORT_WITHDRAW_AFTER_ATTEMPTS (3) failed recreates, bounded by its own
timeout because recreateChannel's catch is outside RECREATE_TIMEOUT_MS's reach.
Not fewer than 3: ordinary half-open recovery legitimately costs two attempts.

Shutdown path: a heartbeat or reachability write could land after setOffline()'s
subprocess write and leave an exited process marked online for a full sweep
tier. Gated both writers on shuttingDown. auth.getSession() was unbounded
network I/O (it refreshes within ~90s of expiry, with its own ~30s retry
budget) and could outlast device.ts's 5s force-exit, losing the durable offline
write entirely — now bounded with a cached-token fallback. Bounded
channel.unsubscribe() and tightened the leave bounds so the whole path fits.

New test/test-remote-heartbeat-tier.js (17 cases) covers the tier pairing, the
reachability gate, write ordering, the withdrawal (and that a single blip does
NOT withdraw), the guard release on a hanging write, and the bounded session
fetch. Each was verified to fail with its fix reverted.

Pairs with the server-side change lowering DEVICE_OFFLINE_TIMEOUT_CAPABLE_MS to
15 min; the two cadences must stay in step and nothing enforces the copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuCmPHU99uRKefvCwtWHYR
The Broadcast/Presence release gate lives outside both repos, alongside the
manual test plan, rather than in PUBLISH.md. Reverts PUBLISH.md to main's
content so base..head no longer touches it.

The gate content itself is unchanged and still required reading before any npm
release carrying transport_broadcast_v1 — it is kept with the operator's other
rollout material.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuCmPHU99uRKefvCwtWHYR
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 40f52cfc-ae98-4c2e-9612-e16c8f5b0d44

📥 Commits

Reviewing files that changed from the base of the PR and between 686098e and 6aeb171.

📒 Files selected for processing (3)
  • src/remote-device/remote-channel.ts
  • src/utils/toolHistory.ts
  • test/test-remote-transport.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/utils/toolHistory.ts
  • test/test-remote-transport.js
  • src/remote-device/remote-channel.ts

📝 Walkthrough

Walkthrough

The PR adds local duplicate-call suppression, conditional database claiming, sanitized result persistence, presence-gated realtime transport, legacy-channel fallback, bounded reconnection, tiered heartbeats, shutdown timeouts, capped tool history, and expanded regression coverage.

Changes

Remote device resilience

Layer / File(s) Summary
Call delivery and duplicate suppression
src/remote-device/device.ts, src/remote-device/remote-channel.ts, test/test-remote-transport.js
Tool calls use bounded in-process deduplication, pending-row claims, retry-aware doorbell routing, and result notification after persistence.
Result sanitization and offline persistence
src/remote-device/remote-channel.ts, src/remote-device/scripts/blocking-offline-update.js, test/test-strip-null-bytes.js
Nested result data and error messages have NUL bytes removed, failed result writes create terminal failures, and offline updates include last_seen.
Session, presence, and channel setup
src/remote-device/remote-channel.ts, test/test-remote-transport.js
Current JWTs are applied to Realtime, device capabilities and identity are registered, and private presence plus legacy channels are initialized.
Reconnect backoff and capability withdrawal
src/remote-device/remote-channel.ts, test/test-remote-channel-reconnect.js, test/test-remote-transport.js
Channel recreation uses jittered bounded backoff, rebuilds both transports, retries presence, and withdraws broadcast capability after sustained failures.
Heartbeat, status, and bounded shutdown
src/remote-device/remote-channel.ts, test/test-remote-transport.js
Heartbeat cadence follows capability and reachability, status writes are serialized, session lookup has a fallback, and unsubscribe operations are time-bounded.
Bounded history and test cleanup
src/utils/toolHistory.ts, test/test-line-count.js
Stored tool output is capped at 4KB, and line-count tests restore filesystem configuration after execution.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Device
  participant PrivateChannel
  participant LegacyChannel
  participant Supabase
  Device->>Supabase: register device and capabilities
  Device->>PrivateChannel: join and track presence
  PrivateChannel-->>Device: acknowledge presence
  Device->>PrivateChannel: enable broadcast capability
  PrivateChannel-->>Device: deliver new_call
  Device->>Supabase: verify pending call
  LegacyChannel-->>Device: provide fallback delivery
  Device->>Supabase: persist result and status
Loading

Possibly related PRs

Suggested labels: size:XL

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and matches the main change around realtime broadcast presence and related transport handling.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/realtime-broadcast-presence

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
src/remote-device/remote-channel.ts (2)

802-810: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: sleep() was inserted under withTimeout()'s JSDoc.

The block comment describing "Run an async op but reject if it doesn't settle within ms" now documents sleep. Move sleep above the comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/remote-device/remote-channel.ts` around lines 802 - 810, Move the private
sleep method above the JSDoc block describing withTimeout so that comment
remains attached to the intended timeout helper; keep sleep’s implementation
unchanged.

5-6: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: drop the constructed RegExp to silence the SAST rule.

NUL_CHAR is a module-level constant, so the ReDoS finding is a false positive, but replaceAll removes the pattern entirely and is equally fast.

-const NUL_CHAR = String.fromCharCode(0);
-const NUL_RE = new RegExp(NUL_CHAR, 'g');
+const NUL_CHAR = String.fromCharCode(0);

…with value.replace(NUL_RE, '')value.replaceAll(NUL_CHAR, '') at Lines 22 and 33 (requires ES2021 lib; verify tsconfig target).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/remote-device/remote-channel.ts` around lines 5 - 6, Replace the
NUL_RE-based replacements in the relevant methods with
value.replaceAll(NUL_CHAR, '') and remove the now-unused NUL_RE constant. Verify
the project’s TypeScript target/lib supports ES2021 replaceAll.

Source: Linters/SAST tools

test/test-remote-dedupe-and-doorbell.js (1)

41-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

new MCPDevice() registers SIGINT/SIGTERM handlers per test.

The constructor calls setupShutdownHandlers(), so each makeDevice() adds two process listeners (7 devices here). It stays under Node's 10-listener warning threshold today, but any added test will start emitting MaxListenersExceededWarning. Consider process.removeAllListeners('SIGINT'/'SIGTERM') in makeDevice after construction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-dedupe-and-doorbell.js` around lines 41 - 61, The makeDevice
test helper accumulates MCPDevice shutdown signal listeners across instances.
After constructing the MCPDevice in makeDevice, remove the SIGINT and SIGTERM
listeners registered by the constructor before returning the device, without
altering the device setup or execution behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/remote-device/device.ts`:
- Around line 357-361: Prevent unhandled rejections from async tool-call
handling: in src/remote-device/device.ts lines 357-361, wrap the
updateCallResult/notifyResult failure-reporting sequence in its own try/catch so
handleNewToolCall cannot reject from the catch path; in
src/remote-device/remote-channel.ts lines 654-703, attach .catch() handlers to
both the current and legacy onToolCall?.({ new: row }) invocations.

In `@src/remote-device/remote-channel.ts`:
- Around line 1330-1350: Correct the teardown budget in the shutdown flow around
LEAVE_BOUND_MS and its Promise.race calls so the worst-case duration stays
within device.ts’s 5-second force-exit; reduce one 500ms bound to 250ms, while
preserving the durable offline write path and the existing untrack protection.
- Around line 654-703: Update the onToolCall invocation in the doorbell row
handling flow to observe rejected promises from the async handler. Attach a
catch handler to this.onToolCall?.({ new: row }) that records the failure using
the existing error-reporting mechanism, preventing handleNewToolCall rejections
from becoming unhandled.
- Around line 984-989: Update the debug logging near the call-result update to
avoid JSON.stringify on updateData.result; derive resultBytes from an
inexpensive size estimate or existing metadata, while preserving the current
summary format and null handling. Ensure console.debug arguments do not trigger
full payload serialization on the hot path.

---

Nitpick comments:
In `@src/remote-device/remote-channel.ts`:
- Around line 802-810: Move the private sleep method above the JSDoc block
describing withTimeout so that comment remains attached to the intended timeout
helper; keep sleep’s implementation unchanged.
- Around line 5-6: Replace the NUL_RE-based replacements in the relevant methods
with value.replaceAll(NUL_CHAR, '') and remove the now-unused NUL_RE constant.
Verify the project’s TypeScript target/lib supports ES2021 replaceAll.

In `@test/test-remote-dedupe-and-doorbell.js`:
- Around line 41-61: The makeDevice test helper accumulates MCPDevice shutdown
signal listeners across instances. After constructing the MCPDevice in
makeDevice, remove the SIGINT and SIGTERM listeners registered by the
constructor before returning the device, without altering the device setup or
execution behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0ad8b814-0798-4ab4-bab1-5601ed72077d

📥 Commits

Reviewing files that changed from the base of the PR and between 1eccc8b and ab7b85a.

📒 Files selected for processing (8)
  • src/remote-device/device.ts
  • src/remote-device/remote-channel.ts
  • src/remote-device/scripts/blocking-offline-update.js
  • test/bench-strip-null-bytes.mjs
  • test/test-remote-channel-reconnect.js
  • test/test-remote-dedupe-and-doorbell.js
  • test/test-remote-heartbeat-tier.js
  • test/test-strip-null-bytes.js

Comment thread src/remote-device/device.ts Outdated
Comment thread src/remote-device/remote-channel.ts Outdated
Comment thread src/remote-device/remote-channel.ts Outdated
Comment thread src/remote-device/remote-channel.ts Outdated
Tests: 5 artefacts for this feature down to 3, by subject.
  - delete test/bench-strip-null-bytes.mjs — a micro-benchmark with no
    assertions, so it could never fail while still costing a slot in npm test
  - merge test-remote-dedupe-and-doorbell.js and test-remote-heartbeat-tier.js
    into test-remote-transport.js (28 cases: exactly-once, doorbell routing,
    result ordering, heartbeat tiers, reachability/status, capability
    withdrawal, shutdown), which also removes a duplicated Supabase fake
  - test-remote-channel-reconnect.js and test-strip-null-bytes.js keep their
    own subjects

Comments in remote-channel.ts: 498 lines to 308 (36% -> 25% of the file), no
code change. Removed the "an earlier version did X, which broke Y" narratives —
that belongs in git history and is already in the round-6/7/8 commit message —
compressed the constants block, and dropped the degraded-path rationale that was
restated in several places. What stays is the non-obvious mechanics: why the
teardown calls are bounded, why the withdrawal needs 3 attempts and not 2, why
the two heartbeat cadences have to match the server's sweep tiers.

Still above the repo's 12-17% baseline because the new code carries invariants
that are genuinely easy to get wrong, and each surviving note exists because a
review caught it being wrong first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuCmPHU99uRKefvCwtWHYR

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
test/test-remote-transport.js (5)

169-173: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Bounded-set test passes for a "clear everything" eviction too.

Asserting only size <= 100 doesn't distinguish LRU trimming from a full wipe, and a wipe silently breaks dedupe right after the boundary. Add a check that the most recent id is still remembered.

♻️ Suggested strengthening
   for (let i = 0; i < 250; i++) await device.handleNewToolCall(payloadFor(`bulk-${i}`));
   assert(device.seenCallIds.size <= 100, `set grew to ${device.seenCallIds.size}`);
+  assert(device.seenCallIds.has('bulk-249'), 'eviction must keep the most recent ids');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 169 - 173, Strengthen the “the
seen-call-id set stays bounded” test by asserting that the newest bulk call ID,
such as `bulk-249`, remains in `device.seenCallIds` after processing all 250
calls. Keep the existing size bound assertion to verify both bounded capacity
and retention of the most recent ID.

369-414: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Encode the withdrawal threshold as a named constant.

for (let i = 0; i < 3; i++) at Line 382 and the single-attempt case at Line 412 both encode the implementation's failure threshold implicitly. A named constant (mirroring the SERVER_* constants at the top) makes the coupling explicit if the threshold changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 369 - 414, Define a named
constant for the recreate-failure withdrawal threshold, following the existing
SERVER_* constant conventions. Use that constant for the sustained-failure loop
count and derive the single-failure test case from it so both tests remain
coupled to the same threshold.

201-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for exhausted retries.

The happy retry path and the missing-row path are covered, but not "all 3 fetch attempts fail" — the branch that must stop at 3 attempts and not deliver.

♻️ Suggested extra case
+await test('doorbell gives up after exhausting row-fetch retries', async () => {
+  const { rc, client } = makeRemoteChannel({ row: { id: 'x', status: 'pending' }, failFetches: 3 });
+  const delivered = [];
+  rc.onToolCall = (p) => delivered.push(p);
+  rc.sleep = () => Promise.resolve();
+  await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID });
+  assert(client.attempts() === 3, `must stop at 3 attempts, got ${client.attempts()}`);
+  assert(delivered.length === 0, 'must not deliver when every fetch failed');
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 201 - 217, Add a test alongside
the existing doorbell retry tests that configures three consecutive row-fetch
failures, invokes rc.onDoorbell, and verifies exactly three client attempts
occur with no tool call delivered. Reuse the existing makeRemoteChannel, sleep
stub, and delivered collection patterns.

94-113: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The fake swallows table and filter information, weakening every write assertion.

from() ignores the table name and update() records only the payload, so a regression that writes the right payload to the wrong table or with a wrong/missing .eq() filter still passes writes.length === 1. Capturing the table and filters is cheap and makes these assertions meaningful.

♻️ Proposed refactor
-  const chain = {
-    update: (payload) => {
-      writes.push(payload);
-      return chain;
-    },
-    select: () => chain,
-    insert: () => chain,
-    eq: () => result(),
-  };
+  const makeChain = (table) => {
+    let current = null;
+    const chain = {
+      update: (payload) => {
+        current = { table, payload, filters: [] };
+        writes.push(Object.assign(current, payload));
+        return chain;
+      },
+      select: () => chain,
+      insert: () => chain,
+      eq: (col, value) => {
+        current?.filters.push([col, value]);
+        return result();
+      },
+    };
+    return chain;
+  };

from: (table) => makeChain(table) then lets tests assert on writes[0].table / writes[0].filters.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 94 - 113, Update the fake
transport setup around the chain object and returned from() method so it
captures the table passed to from() and each filter supplied through eq().
Record writes as structured entries containing the payload, table, and filters,
while preserving the existing chain behavior and write tracking used by the
tests.

480-488: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Prefer process.exitCode and clear the race timer.

The 3s timer at Line 482 is never cleared, so it only fails to delay exit because process.exit() kills it. process.exit() can also truncate buffered stdout when the output is piped (common in CI), losing the pass/fail lines. Setting process.exitCode and clearing the timer avoids both.

♻️ Proposed change
   let settled = false;
+  let timer;
   await Promise.race([
     rc.setOffline(DEVICE_ID).then(() => { settled = true; }),
-    new Promise((r) => setTimeout(r, 3000)),
+    new Promise((r) => { timer = setTimeout(r, 3000); }),
   ]);
+  clearTimeout(timer);
   assert(settled, 'setOffline must settle rather than block the shutdown path');
 });
 
 console.log(`\n${failures ? '🔴' : '✅'} remote transport: ${failures} failing test(s).`);
-process.exit(failures ? 1 : 0);
+process.exitCode = failures ? 1 : 0;

Note: with exitCode, any lingering handle (e.g. the heartbeat timer) will keep the process alive — worth confirming teardown is complete before switching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 480 - 488, Update the shutdown
race near rc.setOffline to retain the 3-second timeout handle and clear it after
Promise.race settles. Replace the final process.exit(...) call with
process.exitCode assignment so output flushes normally, while confirming
existing teardown leaves no lingering handles that prevent natural termination.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@test/test-remote-transport.js`:
- Around line 169-173: Strengthen the “the seen-call-id set stays bounded” test
by asserting that the newest bulk call ID, such as `bulk-249`, remains in
`device.seenCallIds` after processing all 250 calls. Keep the existing size
bound assertion to verify both bounded capacity and retention of the most recent
ID.
- Around line 369-414: Define a named constant for the recreate-failure
withdrawal threshold, following the existing SERVER_* constant conventions. Use
that constant for the sustained-failure loop count and derive the single-failure
test case from it so both tests remain coupled to the same threshold.
- Around line 201-217: Add a test alongside the existing doorbell retry tests
that configures three consecutive row-fetch failures, invokes rc.onDoorbell, and
verifies exactly three client attempts occur with no tool call delivered. Reuse
the existing makeRemoteChannel, sleep stub, and delivered collection patterns.
- Around line 94-113: Update the fake transport setup around the chain object
and returned from() method so it captures the table passed to from() and each
filter supplied through eq(). Record writes as structured entries containing the
payload, table, and filters, while preserving the existing chain behavior and
write tracking used by the tests.
- Around line 480-488: Update the shutdown race near rc.setOffline to retain the
3-second timeout handle and clear it after Promise.race settles. Replace the
final process.exit(...) call with process.exitCode assignment so output flushes
normally, while confirming existing teardown leaves no lingering handles that
prevent natural termination.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f498781a-617d-4d4c-9eaf-ba582b8b6d5f

📥 Commits

Reviewing files that changed from the base of the PR and between ab7b85a and 1ae35a5.

📒 Files selected for processing (2)
  • src/remote-device/remote-channel.ts
  • test/test-remote-transport.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/remote-device/remote-channel.ts

All four findings verified against HEAD and valid (CodeRabbit was reviewing an
earlier commit, but the issues survive):

1. MAJOR — unhandled rejection could kill the device process. handleNewToolCall
   is async and its catch block awaits updateCallResult/notifyResult, which can
   themselves throw (`if (!this.client) throw`). Both call sites discarded the
   promise, so a second failure surfaced as an unhandled rejection — fatal on
   Node 15+. The failure-reporting path now has its own try/catch, and both call
   sites go through dispatchToolCall(), which observes the rejection.
2. MINOR — same root cause at the doorbell and legacy call sites; covered by the
   same helper.
3. MAJOR — updateCallResult's "log a summary, not the payload" comment was an
   own-goal: the log line then ran JSON.stringify over the whole sanitized
   result purely to report its length, eagerly, on every call. On the 13 MB
   payloads this path exists to handle that is a full extra serialization. Now
   gated on DEBUG_MODE.
4. MINOR — the teardown budget did not add up: 250 drain + 3x500 leave + 500
   session + 3000 spawnSync = 5250ms against a 5s force-exit, while the comment
   claimed everything fit in 1.5s. Leave bounds cut to 300ms, total 4650ms, and
   the arithmetic is now written out.

Two new test cases cover the rejection paths (async and sync throw); both were
verified to fail with the fix reverted.

Comments in remote-channel.ts cut again, 308 -> 275 (23%). Findings 3 and 4 were
both cases of a long comment drifting from or contradicting its own code, which
is the argument for keeping them short.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuCmPHU99uRKefvCwtWHYR

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/test-remote-transport.js`:
- Around line 239-243: Update the “a synchronously throwing handler is contained
too” test to reuse the unhandled-rejection listener established by the preceding
test and await a setImmediate drain after onDoorbell(). Assert that the
collected event list remains empty, while preserving the existing non-rejection
assertion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 24e86f0c-ccf6-4d94-be12-c7e6e87f0c52

📥 Commits

Reviewing files that changed from the base of the PR and between 1ae35a5 and 8adb08a.

📒 Files selected for processing (3)
  • src/remote-device/device.ts
  • src/remote-device/remote-channel.ts
  • test/test-remote-transport.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/remote-device/device.ts
  • src/remote-device/remote-channel.ts

Comment on lines +239 to +243
await test('a synchronously throwing handler is contained too', async () => {
const { rc } = makeRemoteChannel({ row: { id: 'x', status: 'pending' } });
rc.onToolCall = () => { throw new Error('sync throw'); };
await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID }); // must not reject
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that synchronous throws do not become unhandled rejections.

This only verifies that onDoorbell() itself does not reject. A discarded handleNewToolCall() promise could still emit unhandledRejection on the next event-loop turn. Reuse the listener and setImmediate drain from the preceding test, then assert the event list remains empty.

Proposed test adjustment
   const { rc } = makeRemoteChannel({ row: { id: 'x', status: 'pending' } });
   rc.onToolCall = () => { throw new Error('sync throw'); };
-  await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID }); // must not reject
+  const unhandled = [];
+  const onUnhandled = (e) => unhandled.push(e);
+  process.on('unhandledRejection', onUnhandled);
+  try {
+    await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID });
+    await new Promise((resolve) => setImmediate(resolve));
+  } finally {
+    process.off('unhandledRejection', onUnhandled);
+  }
+  assert(unhandled.length === 0, `unhandled rejection escaped: ${unhandled[0]?.message}`);
 });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await test('a synchronously throwing handler is contained too', async () => {
const { rc } = makeRemoteChannel({ row: { id: 'x', status: 'pending' } });
rc.onToolCall = () => { throw new Error('sync throw'); };
await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID }); // must not reject
});
await test('a synchronously throwing handler is contained too', async () => {
const { rc } = makeRemoteChannel({ row: { id: 'x', status: 'pending' } });
rc.onToolCall = () => { throw new Error('sync throw'); };
const unhandled = [];
const onUnhandled = (e) => unhandled.push(e);
process.on('unhandledRejection', onUnhandled);
try {
await rc.onDoorbell({ call_id: 'x', device_id: DEVICE_ID });
await new Promise((resolve) => setImmediate(resolve));
} finally {
process.off('unhandledRejection', onUnhandled);
}
assert(unhandled.length === 0, `unhandled rejection escaped: ${unhandled[0]?.message}`);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-remote-transport.js` around lines 239 - 243, Update the “a
synchronously throwing handler is contained too” test to reuse the
unhandled-rejection listener established by the preceding test and await a
setImmediate drain after onDoorbell(). Assert that the collected event list
remains empty, while preserving the existing non-rejection assertion.

toolHistory keeps the full ServerResult of every call in memory, and
get_recent_tool_calls serialises those entries straight back out. That is
unbounded in two compounding ways:

  - one large output (a big read_file, a wide list_directory) makes every later
    history dump that includes it large too, and
  - any tool whose output CONTAINS a history dump nests the whole history inside
    itself. Observed while testing: a start_process running
    `cat /tmp/dc_..._get_recent_tool_calls_....json` recorded that dump as its
    own output, and each nesting level roughly doubles the JSON escaping. 83KB
    of arguments on disk produced a 1.89MB in-memory dump.

That 1.89MB result then exceeded Supabase Realtime's max_record_bytes, which is
how it surfaced — the transport returned `undefined` (fixed separately on the
server side).

Extending EXCLUDED_TOOLS cannot fix this: get_recent_tool_calls is already
excluded, and the nesting arrives through ordinary tools that merely happen to
read a dump back. Capping the stored output does. A preview loses nothing real —
this history is a "what happened recently" aid, not a result cache.

Outputs over 4KB are replaced with a short marker, keeping the { content: [...] }
shape so readers and formatters need no special case. An unserialisable output
(circular) is also dropped rather than retained, since it could never be
returned to a client anyway.

Note this only bounds NEW entries; a long-running process keeps whatever it has
already accumulated until restart.

Also fixes test/test-line-count.js, which set allowedDirectories to its own test
directory at import time and never restored it — leaking into the user's real
~/.claude-server-commander/config.json and refusing every later filesystem call
outside test_output. It was the only test in the suite without a restore. Now
saves the config in setup(), restores in teardown(), and runs teardown on the
failure path too (it previously called process.exit(1) and skipped cleanup).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuCmPHU99uRKefvCwtWHYR

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/utils/toolHistory.ts`:
- Around line 240-260: Update capOutput to measure serialized output size in
UTF-8 bytes using Buffer.byteLength rather than string length, and when the cap
is exceeded return a minimal ServerResult containing only the replacement text
marker and any required error flag. Do not spread the original output, so
structuredContent and _meta cannot be retained.

In `@test/test-line-count.js`:
- Around line 20-28: Update the test setup and teardown around originalConfig so
teardown restores only the original allowedDirectories value through the
existing configuration API, rather than passing the full snapshot to
updateConfig. Preserve unrelated configuration changes made during the test, and
keep the setup behavior that restricts allowedDirectories to TEST_DIR.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: affeda87-3616-4d5e-9bb6-da5b365a7df6

📥 Commits

Reviewing files that changed from the base of the PR and between 8adb08a and 686098e.

📒 Files selected for processing (2)
  • src/utils/toolHistory.ts
  • test/test-line-count.js

Comment thread src/utils/toolHistory.ts
Comment on lines +240 to +260
private capOutput(output: ServerResult): ServerResult {
let size: number;
try {
size = JSON.stringify(output)?.length ?? 0;
} catch {
// Circular or otherwise unserialisable — it could never be returned to a
// client anyway, so don't retain it.
size = Number.POSITIVE_INFINITY;
}
if (size <= this.MAX_STORED_OUTPUT_BYTES) return output;

const shown = Number.isFinite(size) ? `${size} bytes` : 'unserialisable';
return {
...(output as any),
content: [
{
type: 'text',
text: `[output omitted from history: ${shown}, over the ${this.MAX_STORED_OUTPUT_BYTES}-byte cap]`,
},
],
} as ServerResult;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Make the replacement result actually bounded.

Line 253 retains the original structuredContent and _meta, so oversized fields are still persisted after content is replaced. Measure UTF-8 bytes with Buffer.byteLength rather than UTF-16 .length, and return only the bounded marker plus any required error flag.

Proposed fix
   private capOutput(output: ServerResult): ServerResult {
     let size: number;
     try {
-      size = JSON.stringify(output)?.length ?? 0;
+      size = Buffer.byteLength(JSON.stringify(output), 'utf-8');
     } catch {
       // Circular or otherwise unserialisable — it could never be returned to a
       // client anyway, so don't retain it.
       size = Number.POSITIVE_INFINITY;
     }
     if (size <= this.MAX_STORED_OUTPUT_BYTES) return output;

     const shown = Number.isFinite(size) ? `${size} bytes` : 'unserialisable';
     return {
-      ...(output as any),
       content: [
         {
           type: 'text',
           text: `[output omitted from history: ${shown}, over the ${this.MAX_STORED_OUTPUT_BYTES}-byte cap]`,
         },
       ],
-    } as ServerResult;
+      isError: output.isError,
+    };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private capOutput(output: ServerResult): ServerResult {
let size: number;
try {
size = JSON.stringify(output)?.length ?? 0;
} catch {
// Circular or otherwise unserialisable — it could never be returned to a
// client anyway, so don't retain it.
size = Number.POSITIVE_INFINITY;
}
if (size <= this.MAX_STORED_OUTPUT_BYTES) return output;
const shown = Number.isFinite(size) ? `${size} bytes` : 'unserialisable';
return {
...(output as any),
content: [
{
type: 'text',
text: `[output omitted from history: ${shown}, over the ${this.MAX_STORED_OUTPUT_BYTES}-byte cap]`,
},
],
} as ServerResult;
private capOutput(output: ServerResult): ServerResult {
let size: number;
try {
size = Buffer.byteLength(JSON.stringify(output), 'utf-8');
} catch {
// Circular or otherwise unserialisable — it could never be returned to a
// client anyway, so don't retain it.
size = Number.POSITIVE_INFINITY;
}
if (size <= this.MAX_STORED_OUTPUT_BYTES) return output;
const shown = Number.isFinite(size) ? `${size} bytes` : 'unserialisable';
return {
content: [
{
type: 'text',
text: `[output omitted from history: ${shown}, over the ${this.MAX_STORED_OUTPUT_BYTES}-byte cap]`,
},
],
isError: output.isError,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/toolHistory.ts` around lines 240 - 260, Update capOutput to measure
serialized output size in UTF-8 bytes using Buffer.byteLength rather than string
length, and when the cap is exceeded return a minimal ServerResult containing
only the replacement text marker and any required error flag. Do not spread the
original output, so structuredContent and _meta cannot be retained.

Comment thread test/test-line-count.js
Comment on lines +20 to +28
originalConfig = await configManager.getConfig();
// Ensure test dir is allowed
await configManager.setValue('allowedDirectories', [TEST_DIR]);
await fs.mkdir(TEST_DIR, { recursive: true });
}

async function teardown() {
if (originalConfig) {
await configManager.updateConfig(originalConfig);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C 5 '\b(getConfig|updateConfig|setValue)\s*\(' src test

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 50388


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the config manager implementation and relevant test imports.
fd -a 'config(s|Manager)?.ts|line-count.js' . | sed 's#^\./##'

echo '--- candidate config files ---'
fd 'config.*\.ts$|.*(config).*ts$' src test 2>/dev/null || true

echo '--- imports in test/test-line-count.js ---'
sed -n '1,60p' test/test-line-count.js

echo '--- precise configManager definitions/exports/usages ---'
rg -n -C 8 'class .*Config|function .*Config|async function (getConfig|updateConfig|setValue)|default .*Config|export .*configManager|const configManager' src test --glob '!**/test-edit-block-line-endings.js' --glob '!**/test-edit-block.js' | head -n 240

echo '--- src exact config manager file outline ---'
ast-grep outline src/tools/config-manager.ts --view expanded 2>/dev/null || true
sed -n '1,220p' src/tools/config-manager.ts

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 16783


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/config-manager.ts outline ---'
ast-grep outline src/config-manager.ts --view expanded 2>/dev/null || true

echo '--- source method sections in src/config-manager.ts ---'
rg -n -C 12 'class ConfigManager|getConfig|setValue\(|updateConfig\(|saveConfig\(|loadConfig|configPath|writeChain|scheduleSave' src/config-manager.ts

echo '--- source lines 50-180 src/config-manager.ts ---'
sed -n '50,180p' src/config-manager.ts

echo '--- source lines 180-340 src/config-manager.ts ---'
sed -n '180,340p' src/config-manager.ts

Repository: wonderwhy-er/DesktopCommanderMCP

Length of output: 21542


Restore only allowedDirectories instead of the full configuration.

updateConfig() merges the passed object into the current config before saving, but setup() already replaced the live config’s allowedDirectories and teardown() is passing the original snapshot back. Capture/restore the original allowedDirectories at teardown so any user/config manager change to another field made during the test is not lost.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/test-line-count.js` around lines 20 - 28, Update the test setup and
teardown around originalConfig so teardown restores only the original
allowedDirectories value through the existing configuration API, rather than
passing the full snapshot to updateConfig. Preserve unrelated configuration
changes made during the test, and keep the setup behavior that restricts
allowedDirectories to TEST_DIR.

Three findings from a cross-repo review of this branch.

1. recreateChannel() rebuilt both channels inside realtime-js's post-disconnect
   'disconnecting' window. removeChannel() on the last channel makes realtime-js
   call disconnect() itself; _teardownConnection() then nulls the conn.onclose
   that would clear the state, so only an internal ~100ms fallback timer does.
   connect() early-returns for that whole window, so subscribe()'s socket.connect()
   was a silent no-op and BOTH channels — including the deliberately independent
   legacy one — sat in 'joining' until the 10s join timeout. Every first recreate
   was wasted, leaving the device dark on both transports for ~20-45s and burning
   one of three TRANSPORT_WITHDRAW_AFTER_ATTEMPTS before a genuine attempt.
   Reproduced against the installed realtime-js 2.90.0. Now waits for the client
   to leave 'disconnecting' before rebuilding.

2. The per-entry tool-history output cap ran only in addCall(), never on the
   load-from-disk path, so it did nothing for anyone upgrading with an existing
   tool-history.jsonl — i.e. exactly the population it was written for. Verified
   against a real 4.0MB/140-record file holding a single 3.8MB read_file entry:
   under the 5MB whole-file trim, so it loaded uncapped and get_recent_tool_calls
   still returned it in full. Now capped on the way in as well as on the way out.

3. 'concurrent status writes stay ordered' could not fail. The fake recorded
   writes at .update() invocation, which happens synchronously before
   setOnlineStatus's only await, so it only ever observed the order writes were
   ISSUED — which holds with or without the statusWriteChain serialisation. The
   fake now records on completion with per-write latency; with queueStatusWrite
   reverted to fire-and-forget the test goes red on the ordering assertion
   ("got online,offline"), and passes as shipped.

Build clean; test/test-remote-transport.js 30/30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BdFJB6KxeWHdYqbi63KrPP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant