@rubytech/create-maxy-code 0.1.460 → 0.1.461

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.
Files changed (63) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +54 -40
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +169 -39
  11. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  12. package/payload/platform/plugins/cloudflare/mcp/package.json +1 -1
  13. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +21 -19
  14. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  15. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +18 -12
  16. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +14 -5
  17. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  18. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  19. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +98 -9
  20. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  21. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  22. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  23. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  24. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +110 -0
  25. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +6 -3
  26. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  27. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  28. package/payload/platform/scripts/logs-rotate.sh +204 -0
  29. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  30. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  32. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  36. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  38. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  39. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  40. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  42. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  44. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +2 -1
  47. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  50. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  51. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  53. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  55. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  56. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  57. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  58. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  59. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  61. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  62. package/payload/server/server.js +55 -35
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +0 -73
@@ -619,6 +619,83 @@ case_refuse_on_invalid_state_json() {
619
619
  return 0
620
620
  }
621
621
 
622
+ # Task 1706 — the rotator's lock must be held across the offset-capture →
623
+ # verification-poll window. logs-rotate.sh truncates cloudflared.log in place
624
+ # when it exceeds 64 MiB; a rotation landing inside this window makes
625
+ # `tail -c "+$LOG_OFFSET"` read past EOF and report a tunnel failure that did
626
+ # not happen.
627
+ case_holds_rotate_lock_during_verify() {
628
+ local root
629
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
630
+ setup "$root" ".maxy-code" "inactive" "yes"
631
+
632
+ local log_dir="$root/home/.maxy-code/logs"
633
+ local marker="$root/lock-was-held"
634
+
635
+ # Poll for the lock's existence while the script runs. The verify gate is the
636
+ # window under test, so a single observation of the lock proves it was held
637
+ # across it.
638
+ ( for _ in $(seq 1 60); do
639
+ [[ -d "$log_dir/.rotate.lock" ]] && { touch "$marker"; break; }
640
+ sleep 0.05
641
+ done ) &
642
+ local watcher=$!
643
+
644
+ HOME="$root/home" \
645
+ PATH="$root/bin:$PATH" \
646
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
647
+ bash "$RESUME" >/dev/null 2>&1
648
+ local rc=$?
649
+ wait "$watcher" 2>/dev/null
650
+
651
+ local held=0 leaked=0
652
+ [[ -e "$marker" ]] && held=1
653
+ [[ -d "$log_dir/.rotate.lock" ]] && leaked=1
654
+ cleanup "$root"
655
+
656
+ if [[ $rc -ne 0 ]]; then
657
+ echo " expected exit 0, got $rc"; return 1
658
+ fi
659
+ if [[ $held -ne 1 ]]; then
660
+ echo " rotate lock was never held during the verify window"; return 1
661
+ fi
662
+ if [[ $leaked -ne 0 ]]; then
663
+ echo " rotate lock not released after the verify window"; return 1
664
+ fi
665
+ return 0
666
+ }
667
+
668
+ # A lock already held (by a live rotator tick) must not stop the tunnel coming
669
+ # up. A false-alarm verify line is a far smaller failure than no tunnel.
670
+ case_busy_rotate_lock_does_not_block_spawn() {
671
+ local root
672
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
673
+ setup "$root" ".maxy-code" "inactive" "yes"
674
+
675
+ mkdir -p "$root/home/.maxy-code/logs/.rotate.lock"
676
+
677
+ HOME="$root/home" \
678
+ PATH="$root/bin:$PATH" \
679
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
680
+ bash "$RESUME" >/dev/null 2>&1
681
+ local rc=$?
682
+
683
+ local log_content
684
+ log_content=$(cat "$root/home/.maxy-code/logs/cloudflared.log" 2>/dev/null || echo "")
685
+ cleanup "$root"
686
+
687
+ if [[ $rc -ne 0 ]]; then
688
+ echo " expected exit 0, got $rc"; return 1
689
+ fi
690
+ if ! echo "$log_content" | grep -q "spawned service=cloudflared-maxy-code.service"; then
691
+ echo " a busy rotate lock blocked the tunnel spawn"; echo "$log_content"; return 1
692
+ fi
693
+ if ! echo "$log_content" | grep -q "rotate-lock busy"; then
694
+ echo " proceeding without the lock was not logged"; echo "$log_content"; return 1
695
+ fi
696
+ return 0
697
+ }
698
+
622
699
  run_case "skip when service active" case_skip_when_scope_active
623
700
  run_case "spawn succeeds and logs verified" case_spawn_succeeds
624
701
  run_case "spawn with no connection logs ERROR exits 0" case_spawn_no_connection
@@ -635,6 +712,8 @@ run_case "reap: skip when active unit's MainPID is unresolved" case_reap_skip_wh
635
712
  run_case "token tunnel spawns without --origincert" case_token_tunnel_spawns_without_origincert
636
713
  run_case "configured tunnel with no run credential refuses loudly" case_refuse_when_no_run_credential
637
714
  run_case "corrupt tunnel.state refuses loudly and exits 0" case_refuse_on_invalid_state_json
715
+ run_case "holds the rotate lock during verify" case_holds_rotate_lock_during_verify
716
+ run_case "busy rotate lock does not block spawn" case_busy_rotate_lock_does_not_block_spawn
638
717
 
639
718
  echo ""
640
719
  echo "Results: $PASS passed, $FAIL failed"
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # Brand log rotation — Task 1706.
3
+ #
4
+ # WHY THIS EXISTS AND NOT logrotate: server.log, edge.log and cloudflared.log
5
+ # are systemd stdout sinks (`StandardOutput=append:`, see
6
+ # packages/create-maxy-code/src/port-resolution.ts:233 and
7
+ # platform/scripts/resume-tunnel.sh:230). systemd opens the file O_APPEND ONCE
8
+ # at unit start and the process holds that fd for its whole life. logrotate's
9
+ # default rename-then-create would leave every service writing into the rotated
10
+ # inode forever — rotation would look like it worked while the live file stayed
11
+ # empty. So we gzip a COPY and truncate IN PLACE: because the fd is O_APPEND,
12
+ # each write seeks to end-of-file first, so writes resume at offset 0 with no
13
+ # service restart and no sparse hole. Verified against a real systemd unit on
14
+ # 2026-07-17 (laptop 192.168.88.10): NRestarts=0, file 71 -> 0 -> 80 bytes for
15
+ # 10 fresh lines. Never replace `: > "$F"` with a mv.
16
+ #
17
+ # Portability: this runs on Linux (cron) and darwin (launchd StartInterval), so
18
+ # no flock(1) (absent on darwin) and no `date +%N` (GNU-only). The lock is a
19
+ # mkdir, which is atomic on both. find's -mmin/-mtime/-delete and the timestamp
20
+ # format were confirmed identical on both platforms.
21
+ #
22
+ # Usage: logs-rotate.sh [LOGS_DIR]
23
+ set -uo pipefail
24
+
25
+ ROTATE_SIZE_BYTES=$((64 * 1024 * 1024))
26
+ KEEP=5
27
+ STALE_LOCK_MINUTES=30
28
+
29
+ # Resolve the logs dir the same way logs-read.sh:52-58 does: convention is
30
+ # configDir = ".${installDir}".
31
+ if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then
32
+ LOGS_DIR="$1"
33
+ else
34
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
36
+ INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"
37
+ LOGS_DIR="$HOME/.$(basename "$INSTALL_DIR")/logs"
38
+ fi
39
+
40
+ # Absent logs dir is not an error: the installer creates it, and a rotator tick
41
+ # that lands before the first install completes has nothing to do.
42
+ [ -d "$LOGS_DIR" ] || exit 0
43
+
44
+ LOCK="$LOGS_DIR/.rotate.lock"
45
+
46
+ stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }
47
+
48
+ # A mkdir lock is NOT released by the kernel when its holder dies, so a crashed
49
+ # rotator would wedge rotation permanently and silently — worse than the race
50
+ # the lock prevents. Reclaim on age: 30 min is two rotator ticks and 90x
51
+ # resume-tunnel.sh's 20s window, so no live holder is mistaken for a dead one.
52
+ #
53
+ # The reclaim moves the stale lock aside with `mv` before recreating it, rather
54
+ # than `rm -rf`-then-mkdir. `mv` is the atomic gate: if two ticks both see the
55
+ # same stale lock, only one can move that inode — the loser's `mv` fails because
56
+ # the source is already gone, and it backs off. rm-then-mkdir had no such gate:
57
+ # the loser would delete the winner's brand-new lock and both would rotate,
58
+ # shifting generations twice for one new .1.gz.
59
+ acquire_lock() {
60
+ if mkdir "$LOCK" 2>/dev/null; then return 0; fi
61
+ if [ -d "$LOCK" ] && [ -z "$(find "$LOCK" -maxdepth 0 -mmin -"$STALE_LOCK_MINUTES" 2>/dev/null)" ]; then
62
+ local stale="$LOCK.stale.$$"
63
+ mv "$LOCK" "$stale" 2>/dev/null || return 1 # lost the reclaim race
64
+ rm -rf "$stale" 2>/dev/null
65
+ echo "$(stamp) [logs-rotate] stale-lock-reclaimed lock=$LOCK age-min=>$STALE_LOCK_MINUTES"
66
+ mkdir "$LOCK" 2>/dev/null && return 0 # a third tick may have won; then back off
67
+ fi
68
+ return 1
69
+ }
70
+
71
+ if ! acquire_lock; then
72
+ # Another rotator tick, or a resume-tunnel.sh verification window, is live.
73
+ # Exit 0: the next tick is 15 min away and the bound is not tight enough for
74
+ # a skipped tick to matter.
75
+ echo "$(stamp) [logs-rotate] skipped reason=lock-held lock=$LOCK"
76
+ exit 0
77
+ fi
78
+ trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT
79
+
80
+ size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }
81
+
82
+ # Size-rotate one file: gzip a copy to a temp, shift older generations, move the
83
+ # temp into .1.gz, truncate in place, then mark the truncation so the file is
84
+ # never mistaken for one that simply starts there.
85
+ #
86
+ # ORDER IS LOAD-BEARING: gzip FIRST, shift only once it succeeded. The shift used
87
+ # to run first, so a gzip failure moved .4->.5 ... .1->.2 and wrote nothing to
88
+ # .1 — dropping the oldest generation and leaving a hole, on every tick. The
89
+ # trigger is ENOSPC, i.e. precisely the condition rotation exists to prevent: 75
90
+ # minutes of a full disk destroyed 4 of the 5 retained generations while
91
+ # correctly refusing to truncate. Never hoist the shift above the gzip.
92
+ rotate_file() {
93
+ local f="$1" base size i prev
94
+ [ -f "$f" ] || return 0
95
+ size="$(size_of "$f")"
96
+ [ "$size" -gt "$ROTATE_SIZE_BYTES" ] || return 0
97
+ base="$(basename "$f")"
98
+
99
+ # gzip to a temp first, so a failure (or a tick killed mid-compress) costs
100
+ # nothing: no generation has moved and the live file is untouched.
101
+ #
102
+ # gzip's stderr is CAPTURED, not discarded, and relayed verbatim on failure —
103
+ # it is the only thing that distinguishes ENOSPC from EACCES from a missing
104
+ # binary. `2>&1 >file` sends stderr to the command substitution and stdout to
105
+ # the temp; the order matters.
106
+ #
107
+ # A live log grows while gzip reads it, so gzip emits "file size changed while
108
+ # zipping" on nearly every real rotation. That is a WARNING and gzip still
109
+ # exits 0 (verified on the laptop against a file being appended to during the
110
+ # read), so it takes the success path and the noise is never logged. Only a
111
+ # genuine non-zero exit reaches the branch below.
112
+ local gzerr
113
+ gzerr="$(gzip -c "$f" 2>&1 >"$f.1.gz.tmp")"
114
+ if [ $? -ne 0 ]; then
115
+ rm -f "$f.1.gz.tmp"
116
+ echo "$(stamp) [logs-rotate] ERROR file=$base reason=gzip-failed bytes=$size err=$(printf '%s' "$gzerr" | tr '\n' ' ') — not truncated, generations intact"
117
+ return 1
118
+ fi
119
+
120
+ # Shift down from the oldest so nothing is overwritten before it moves. The
121
+ # KEEP-th generation is overwritten by the (KEEP-1)-th, which is how the
122
+ # oldest falls off.
123
+ i="$KEEP"
124
+ while [ "$i" -gt 1 ]; do
125
+ prev=$((i - 1))
126
+ [ -f "$f.$prev.gz" ] && mv -f "$f.$prev.gz" "$f.$i.gz"
127
+ i="$prev"
128
+ done
129
+ mv -f "$f.1.gz.tmp" "$f.1.gz"
130
+
131
+ # THE load-bearing line. Truncate in place; do not mv. See the header.
132
+ #
133
+ # Lines written between gzip reaching EOF and this truncate are LOST. gzip
134
+ # reads to the CURRENT end of file, so the window is the shift plus two mvs —
135
+ # milliseconds, not the seconds the gzip itself takes. Inherent to
136
+ # copy-truncate, which the O_APPEND fd leaves no way around.
137
+ #
138
+ # `size` was sampled BEFORE gzip ran, so on a live log the archive legitimately
139
+ # holds slightly MORE than prev-bytes reports. prev-bytes locates the seam
140
+ # approximately, not exactly. See .docs/brand-log-retention.md.
141
+ : > "$f"
142
+ echo "$(stamp) [logs-rotate] rotated file=$base prev-bytes=$size prev-file=$base.1.gz keep=$KEEP" >> "$f"
143
+ }
144
+
145
+ PURGE_DAYS=7
146
+
147
+ for f in server.log edge.log cloudflared.log check-due-events.log; do
148
+ rotate_file "$LOGS_DIR/$f"
149
+ done
150
+
151
+ # Globbed size targets. nullglob so a brand with none of these yet does not try
152
+ # to rotate a literal '*' path.
153
+ #
154
+ # mcp-*-stderr-*.log: the tee holds a live write stream, so this is the ONLY
155
+ # bound it gets here — see the purge note below for why it is never age-purged.
156
+ #
157
+ # rc-child-*.log: its 16 MiB writer cap does NOT hold. `written`
158
+ # (rc-child-log.ts:33) is in-memory per manager process, so a manager restart
159
+ # resumes appending to the same path with a fresh counter. Observed at 54 MB on
160
+ # sitedesk-code, 3.25x the cap. A long-lived session's file also has a fresh
161
+ # mtime forever, so the age purge below never reaches it — without size rotation
162
+ # it is genuinely unbounded.
163
+ shopt -s nullglob
164
+ for f in "$LOGS_DIR"/mcp-*-stderr-*.log "$LOGS_DIR"/rc-child-*.log; do
165
+ rotate_file "$f"
166
+ done
167
+ shopt -u nullglob
168
+
169
+ # Age purge — rc-child only.
170
+ #
171
+ # rc-child-*.log is one file per session and the set grows without bound (100 on
172
+ # sitedesk-code). Purging it is SAFE because rc-child-log.ts:51 writes with
173
+ # appendFileSync: no fd is held, so a purged file is simply recreated on the
174
+ # next write.
175
+ #
176
+ # mcp-*-stderr-*.log is deliberately NOT purged, though an earlier draft did.
177
+ # The tee (mcp-stderr-tee/src/index.ts:88) opens a createWriteStream at process
178
+ # start and holds it for the process's whole life, writing only when the server
179
+ # emits stderr — which a healthy server does not do for days. So a LIVE file's
180
+ # mtime DOES go stale, and deleting it would send every later stderr write to an
181
+ # unlinked inode: invisible exactly when the fault you are chasing finally
182
+ # appears, and its disk unreclaimed until the process exits. Bounding these by
183
+ # age needs live-vs-dead discrimination, which needs the writer to stop pinning
184
+ # its date at boot — that is Task 1721. Size rotation above is safe regardless,
185
+ # because truncate-in-place is precisely what a held fd tolerates.
186
+ #
187
+ # The pattern is `.log*` so a purged session's rotated .gz generations go too.
188
+ # `find` matches each path by its own mtime — there is no sibling relationship —
189
+ # so a LIVE rc-child's generations also age out at 7 days. That is intended:
190
+ # retention for rc-child is min(KEEP, 7 days), and 7 days of a session's spawn
191
+ # output is the stated bound.
192
+ # One traversal, and the count is what was actually deleted rather than what a
193
+ # separate counting pass saw a moment earlier. `-print` before `-delete` prints
194
+ # each path as it goes.
195
+ purge_old() {
196
+ local pattern="$1" n
197
+ n="$(find "$LOGS_DIR" -maxdepth 1 -name "$pattern" -type f -mtime +"$PURGE_DAYS" -print -delete 2>/dev/null | wc -l | awk '{print $1+0}')"
198
+ [ "$n" -gt 0 ] || return 0
199
+ echo "$(stamp) [logs-rotate] purged pattern=$pattern files=$n older-than-days=$PURGE_DAYS"
200
+ }
201
+
202
+ purge_old 'rc-child-*.log*'
203
+
204
+ exit 0
@@ -194,6 +194,24 @@ log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."
194
194
  # Capture log byte offset before spawning so the verification poll only
195
195
  # matches lines written by THIS invocation, not lines from a prior run.
196
196
  mkdir -p "$LOG_DIR"
197
+
198
+ # Task 1706 — hold the log rotator's lock across offset-capture → verify-poll.
199
+ # logs-rotate.sh truncates cloudflared.log in place when it exceeds 64 MiB; a
200
+ # rotation landing inside this window makes `tail -c "+$LOG_OFFSET"` read past
201
+ # EOF and report a tunnel failure that did not happen. Same mkdir primitive as
202
+ # the rotator (no flock(1) on darwin, and a lock is only a lock if both ends
203
+ # agree). Best-effort: if the lock cannot be taken we proceed anyway rather than
204
+ # refuse to bring the tunnel up — a false-alarm log line is a far smaller
205
+ # failure than a tunnel that never starts.
206
+ ROTATE_LOCK="$LOG_DIR/.rotate.lock"
207
+ ROTATE_LOCK_HELD=0
208
+ if mkdir "$ROTATE_LOCK" 2>/dev/null; then
209
+ ROTATE_LOCK_HELD=1
210
+ trap 'rmdir "$ROTATE_LOCK" 2>/dev/null || true' EXIT
211
+ else
212
+ log "rotate-lock busy — proceeding without it; a concurrent rotation may skew the verify gate"
213
+ fi
214
+
197
215
  LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)
198
216
 
199
217
  # Spawn into a SUPERVISED transient service under cloudflared.slice (Task 757).
@@ -264,6 +282,15 @@ else
264
282
  log "ERROR: tunnel service started but no edge connection within ${VERIFY_DEADLINE}s — service=$SERVICE_UNIT"
265
283
  fi
266
284
 
285
+ # Task 1706 — release the rotator's lock. The verify gate above is the only
286
+ # offset-sensitive window, so holding it past here would make a rotator tick
287
+ # skip for no reason.
288
+ if [ "$ROTATE_LOCK_HELD" -eq 1 ]; then
289
+ rmdir "$ROTATE_LOCK" 2>/dev/null || true
290
+ ROTATE_LOCK_HELD=0
291
+ trap - EXIT
292
+ fi
293
+
267
294
  # Update state file: keep pid/startedAt for observability, add serviceUnit as control signal.
268
295
  NEW_STATE=$(echo "$STATE" | jq \
269
296
  --arg unit "$SERVICE_UNIT" \
@@ -1 +1 @@
1
- {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"http-server.d.ts","sourceRoot":"","sources":["../src/http-server.ts"],"names":[],"mappings":"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"}
@@ -3306,7 +3306,9 @@ export function buildHttpApp(deps) {
3306
3306
  // non-allowlisted channel like whatsapp) prints an interactive prompt
3307
3307
  // ("Loading development channels … ❯ 1. I am using this for local
3308
3308
  // development … Enter to confirm") that a programmatic spawn cannot answer,
3309
- // hanging the boot until pid-bind-timeout. Detect it once and send Enter to
3309
+ // hanging the boot until the bind wait elapses (a blocked child writes no
3310
+ // pid file, so Task 1716 classifies it `outcome=failed`, and `blocker=` on
3311
+ // op=bind-signal-absent names the screen). Detect it once and send Enter to
3310
3312
  // select option 1. The prompt is laid out with cursor-position escapes and
3311
3313
  // no literal spaces, so match on the ANSI/space-stripped letter stream.
3312
3314
  // Bounded startup keystroke only — inbound message submission stays a
@@ -3359,7 +3361,12 @@ export function buildHttpApp(deps) {
3359
3361
  // by `registerRcSpawnPty`) handles the tracker side — fd release +
3360
3362
  // tracker delete + the `kill … reason=process-exited` log.
3361
3363
  let pidFileSeen = false;
3364
+ // Task 1716 — the second half of the bind verdict's discriminator. Set
3365
+ // first, so a child that has already gone can never be read as still
3366
+ // running by the decision below.
3367
+ let ptyExited = false;
3362
3368
  pty.onExit((e) => {
3369
+ ptyExited = true;
3363
3370
  const ranMs = Date.now() - start;
3364
3371
  if (!pidFileSeen) {
3365
3372
  deps.logger(`[rc-spawn] op=early-exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs} exitCode=${e.exitCode} signal=${e.signal ?? 'none'}`);
@@ -3490,8 +3497,33 @@ export function buildHttpApp(deps) {
3490
3497
  // through the proxy unchanged so the client decides modal-vs-navigate
3491
3498
  // from one server-supplied value.
3492
3499
  const waitedMs = Date.now() - start;
3493
- const outcome = bridgeSessionId ? 'bound' : 'timeout';
3494
- const reason = outcome === 'bound' ? null : 'pid-bind-timeout';
3500
+ // Task 1716 the bind signal certifies that a remote-control BRIDGE
3501
+ // exists, not that the session is usable. Device evidence 2026-07-17: a
3502
+ // child can attach its channel, drain and serve a full turn while
3503
+ // registering no bridge at all — no stdout banner, and a pid file carrying
3504
+ // every field except `bridgeSessionId`. Both signals then correctly report
3505
+ // "no bridge", so slug-absence alone cannot mean the spawn failed.
3506
+ //
3507
+ // The verdict is therefore derived from the child's real state at decision
3508
+ // time, which is why a "failed" verdict cannot coexist with a healthy
3509
+ // session by construction:
3510
+ // bound — a slug arrived on either signal.
3511
+ // unbound — no slug, but the child wrote its pid file and is still
3512
+ // running: usable, simply not navigable from claude.ai/code.
3513
+ // failed — no slug and the child never became a running registered
3514
+ // session (it died, or is stuck on a blocking screen; the
3515
+ // `blocker=` field on op=bind-signal-absent names which).
3516
+ const outcome = bridgeSessionId
3517
+ ? 'bound'
3518
+ : pidFileSeen && !ptyExited
3519
+ ? 'unbound'
3520
+ : 'failed';
3521
+ // `reason` is the string the client renders in its error modal
3522
+ // (Sidebar.tsx::resolveRcSpawnOutcome). Neither value names a pid bind:
3523
+ // the wait has been on the stdout banner since Task 662, and the old
3524
+ // `pid-bind-timeout` sent every reader to the wrong mechanism. Nor does
3525
+ // `spawn-not-ready` claim an exit — a blocked-but-alive child never exits.
3526
+ const reason = outcome === 'bound' ? null : outcome === 'unbound' ? 'bridge-unregistered' : 'spawn-not-ready';
3495
3527
  // Task 1406 — derive the child's first-turn auth verdict. A bound child
3496
3528
  // authenticated; an unbound child that printed `Not logged in` is the
3497
3529
  // file-healthy-but-child-unauthenticated signature this task closes. Emitted
@@ -3507,8 +3539,12 @@ export function buildHttpApp(deps) {
3507
3539
  // file produced a slug within the bound. This distinct marker names the
3508
3540
  // artifact the manager waited for and the CLI version that produced
3509
3541
  // neither, so a future CLI drift is read from one log line instead of
3510
- // re-derived by reproduction. `reason=pid-bind-timeout` (below) is kept
3511
- // unchanged so the client still renders the bind-timeout modal.
3542
+ // re-derived by reproduction.
3543
+ //
3544
+ // Task 1716 — this line fires for BOTH non-bound outcomes, which are not
3545
+ // the same event: `stdoutBytes` + `pidFileSeen` are what separate a dead
3546
+ // child (near-silent, no pid file) from a healthy unbound one (chatty,
3547
+ // pid file present, serving turns). Read them before concluding drift.
3512
3548
  //
3513
3549
  // Task 664 — scan the captured child stdout for the known blocking-screen
3514
3550
  // markers and name the matched one (`blocker=`). `bindBuf` holds the 8 KB