osborn 0.9.47 → 0.9.49

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.
@@ -0,0 +1,229 @@
1
+ # Voice Native Sync Skill
2
+
3
+ ## SKILL IDENTITY
4
+ Name: voice-native-sync
5
+ Install path: ~/.claude/skills/voice-native-sync/SKILL.md
6
+
7
+ ## TRIGGER PHRASES
8
+ This skill activates when the user says any of:
9
+ - "upload to voice native"
10
+ - "download from voice native"
11
+ - "sync to voice native"
12
+ - "push sessions to voice native"
13
+ - "pull sessions from voice native"
14
+ - "sync my sessions"
15
+
16
+ When triggered, follow the instructions below immediately without asking for clarification,
17
+ unless credentials are missing (see CREDENTIALS section).
18
+
19
+ ---
20
+
21
+ ## SELF-INSTALL
22
+ If the user asks you to install this skill or you fetched this file from a URL,
23
+ save it to: ~/.claude/skills/voice-native-sync/SKILL.md
24
+
25
+ ```bash
26
+ mkdir -p ~/.claude/skills/voice-native-sync
27
+ # then write this file's content to ~/.claude/skills/voice-native-sync/SKILL.md
28
+ ```
29
+
30
+ After saving, confirm: "Voice Native sync skill installed. Use 'upload to voice native' or 'download from voice native' any time."
31
+
32
+ ---
33
+
34
+ ## CREDENTIALS
35
+ Credentials are stored at: ~/.claude/skills/voice-native-sync/config
36
+
37
+ Config file format:
38
+ ```
39
+ CLOUD_URL=https://osborn-XXXX.fly.dev
40
+ TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
41
+ ```
42
+
43
+ If the config file doesn't exist:
44
+ 1. Tell the user: "I need your Voice Native sync credentials. Go to voice-native.com → Settings → Copy sync info, then paste it here."
45
+ 2. Parse the pasted block for CLOUD_URL (the "Server:" line) and TOKEN (the "Token:" line)
46
+ 3. Save to ~/.claude/skills/voice-native-sync/config
47
+ 4. Proceed with the requested operation
48
+
49
+ ---
50
+
51
+ ## UPLOAD (Local → Voice Native Cloud)
52
+
53
+ Uploads all local Claude session files to the Voice Native fly machine.
54
+ Uses chunked upload + finalize. Safe to re-run — mtime-newer-wins per file.
55
+
56
+ ### Execute as a single script (one permission prompt):
57
+
58
+ ```bash
59
+ set -e
60
+
61
+ # Load credentials
62
+ source ~/.claude/skills/voice-native-sync/config
63
+
64
+ TARGET_PATH="/workspace"
65
+
66
+ rm -f /tmp/vn-sync.tar.gz /tmp/vn-chunk-*
67
+
68
+ # Archive local Claude projects (exclude macOS AppleDouble files)
69
+ tar -czf /tmp/vn-sync.tar.gz \
70
+ $(uname | grep -qi darwin && echo '--exclude=._*') \
71
+ -C "$HOME/.claude" projects
72
+
73
+ echo "archive: $(du -sh /tmp/vn-sync.tar.gz | cut -f1)"
74
+
75
+ # Split into 50MB chunks
76
+ split -b 50m /tmp/vn-sync.tar.gz /tmp/vn-chunk-
77
+ CHUNKS=(/tmp/vn-chunk-*)
78
+ TOTAL=${#CHUNKS[@]}
79
+ echo "chunks: $TOTAL"
80
+
81
+ # Generate upload ID (works on Linux and macOS)
82
+ if command -v uuidgen &>/dev/null; then
83
+ UPLOAD_ID=$(uuidgen | tr '[:upper:]' '[:lower:]')
84
+ else
85
+ UPLOAD_ID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())")
86
+ fi
87
+ echo "upload id: $UPLOAD_ID"
88
+
89
+ # Upload chunks
90
+ idx=0
91
+ for chunk in "${CHUNKS[@]}"; do
92
+ echo "uploading chunk $idx / $((TOTAL-1))..."
93
+ STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
94
+ -H "Authorization: Bearer $TOKEN" \
95
+ -H "Content-Type: application/octet-stream" \
96
+ --data-binary "@${chunk}" \
97
+ "${CLOUD_URL}/sessions/import-chunk?uploadId=${UPLOAD_ID}&chunk=${idx}")
98
+ echo " chunk $idx → HTTP $STATUS"
99
+ idx=$((idx+1))
100
+ done
101
+
102
+ # Finalize — merges chunks and extracts WITHOUT slug remapping.
103
+ # IMPORTANT: do NOT pass `targetWorkDir`. The server-side remap collapses every
104
+ # source slug into the target work dir's slug, which causes session-resume to
105
+ # silently break when sessions are uploaded from different hosts (Mac, Codespace,
106
+ # Sprite) — they all end up in -workspace, the JSONLs internally still reference
107
+ # their original cwd, the slug↔cwd no longer match, and Claude Code's resume
108
+ # can't find the file. Confirmed 2026-05-27: a codespace upload remapped
109
+ # -workspaces-codespaces-blank → -workspace and every codespace session went
110
+ # silent on resume. The fix is to preserve each upload's original slug structure.
111
+ echo "finalizing..."
112
+ RESULT=$(curl -s -X POST \
113
+ -H "Authorization: Bearer $TOKEN" \
114
+ "${CLOUD_URL}/sessions/import-finalize?uploadId=${UPLOAD_ID}&total=${TOTAL}")
115
+ echo "finalize result: $RESULT"
116
+
117
+ # Cleanup
118
+ rm -f /tmp/vn-sync.tar.gz /tmp/vn-chunk-*
119
+
120
+ # Verify
121
+ echo "verifying manifest..."
122
+ curl -s -H "Authorization: Bearer $TOKEN" "${CLOUD_URL}/sessions/manifest" | \
123
+ python3 -c "
124
+ import json,sys
125
+ d=json.load(sys.stdin)
126
+ slugs=d.get('slugs',{})
127
+ total=sum(len(v.get('files',{})) for v in slugs.values())
128
+ print(f' cloud now has {len(slugs)} slug(s), {total} total files')
129
+ for slug,info in slugs.items():
130
+ files=info.get('files',{})
131
+ print(f' {slug}: {len(files)} files')
132
+ "
133
+ ```
134
+
135
+ ---
136
+
137
+ ## DOWNLOAD (Voice Native Cloud → Local)
138
+
139
+ Downloads all sessions from the Voice Native fly machine and merges into local ~/.claude/projects/.
140
+ Mtime-newer-wins — local files newer than cloud are preserved.
141
+
142
+ ### Execute as a single script:
143
+
144
+ ```bash
145
+ set -e
146
+
147
+ # Load credentials
148
+ source ~/.claude/skills/voice-native-sync/config
149
+
150
+ # Get local working directory for slug remapping
151
+ LOCAL_CWD="$(pwd)"
152
+ echo "local target cwd: $LOCAL_CWD"
153
+
154
+ rm -f /tmp/vn-download.tar.gz
155
+
156
+ # Download full export from fly machine
157
+ echo "downloading from $CLOUD_URL..."
158
+ curl -f -L \
159
+ -H "Authorization: Bearer $TOKEN" \
160
+ "${CLOUD_URL}/sessions/export" \
161
+ -o /tmp/vn-download.tar.gz
162
+ echo "downloaded: $(du -sh /tmp/vn-download.tar.gz | cut -f1)"
163
+
164
+ # Import with slug remapping to local cwd
165
+ echo "importing..."
166
+ # Same fix as upload: no targetWorkDir, preserve original slug structure.
167
+ RESULT=$(curl -s -X POST \
168
+ -H "Authorization: Bearer $TOKEN" \
169
+ -H "Content-Type: application/octet-stream" \
170
+ --data-binary "@/tmp/vn-download.tar.gz" \
171
+ "${CLOUD_URL}/sessions/import")
172
+ echo "import result: $RESULT"
173
+
174
+ rm -f /tmp/vn-download.tar.gz
175
+
176
+ echo "done — sessions merged into ~/.claude/projects/"
177
+ ```
178
+
179
+ Wait — the DOWNLOAD direction means pulling from cloud to THIS local machine.
180
+ The import endpoint runs on the cloud. For download to local, use this instead:
181
+
182
+ ```bash
183
+ set -e
184
+ source ~/.claude/skills/voice-native-sync/config
185
+
186
+ LOCAL_CWD="$(pwd)"
187
+ rm -f /tmp/vn-download.tar.gz
188
+
189
+ echo "downloading export from $CLOUD_URL..."
190
+ curl -f -H "Authorization: Bearer $TOKEN" \
191
+ "${CLOUD_URL}/sessions/export" \
192
+ -o /tmp/vn-download.tar.gz
193
+ echo "downloaded: $(du -sh /tmp/vn-download.tar.gz | cut -f1)"
194
+
195
+ # Extract archive
196
+ mkdir -p /tmp/vn-extract
197
+ tar -xzf /tmp/vn-download.tar.gz -C /tmp/vn-extract
198
+
199
+ # Remap and merge into local ~/.claude/projects/
200
+ LOCAL_SLUG=$(echo "$LOCAL_CWD" | sed 's|/|-|g')
201
+ PROJECTS_DIR="$HOME/.claude/projects"
202
+ mkdir -p "${PROJECTS_DIR}/${LOCAL_SLUG}"
203
+
204
+ echo "merging into ${PROJECTS_DIR}/${LOCAL_SLUG}..."
205
+ for slug_dir in /tmp/vn-extract/projects/*/; do
206
+ slug=$(basename "$slug_dir")
207
+ for f in "${slug_dir}"*.jsonl "${slug_dir}"*.jsonl.* 2>/dev/null; do
208
+ [ -f "$f" ] || continue
209
+ fname=$(basename "$f")
210
+ dest="${PROJECTS_DIR}/${LOCAL_SLUG}/${fname}"
211
+ if [ ! -f "$dest" ] || [ "$f" -nt "$dest" ]; then
212
+ cp "$f" "$dest"
213
+ echo " wrote $fname"
214
+ fi
215
+ done
216
+ done
217
+
218
+ rm -rf /tmp/vn-download.tar.gz /tmp/vn-extract
219
+ echo "done — sessions available at ${PROJECTS_DIR}/${LOCAL_SLUG}/"
220
+ ```
221
+
222
+ ---
223
+
224
+ ## TECHNICAL NOTES
225
+ - Cloud target path is always `/workspace` (Fly.io machines)
226
+ - Slug remapping is automatic on upload (source slug → /workspace slug)
227
+ - Mtime-newer-wins: re-syncing is always safe, newer file wins per-file
228
+ - gzip only — never use zstd (server doesn't support it)
229
+ - macOS: always pass `--exclude='._*'` to tar (BSD tar emits AppleDouble files)
@@ -2,10 +2,42 @@
2
2
  # Installs osborn as npm package (not from source) for lightweight per-user machines.
3
3
  # Build: docker build -f Dockerfile.sandbox -t registry.fly.io/osborn-sandbox/agent:latest .
4
4
  # Push: fly auth docker && docker push registry.fly.io/osborn-sandbox/agent:latest
5
+ #
6
+ # ARCHITECTURE — HOME-on-volume (no chroot)
7
+ # ==========================================
8
+ # The persistent Fly volume holds HOME and all user-mutable state, but osborn
9
+ # itself lives in the IMAGE. Image-swap brings new osborn versions; the volume
10
+ # preserves user OAuth, sessions, skills, gh tokens, ssh keys, git config, etc.
11
+ #
12
+ # Key env:
13
+ # - HOME=/workspace/home → all HOME-derived paths persist on volume
14
+ # - OSBORN_CWD=/workspace → osborn's project dir, also on volume
15
+ # - osborn comes from /usr/local/bin/osborn (image's native location)
16
+ #
17
+ # Updates:
18
+ # - Image-swap delivers new osborn binary + new system deps
19
+ # - No bind-mounts, no chroot, no runtime npm install
20
+ # - User state on volume survives image rebuilds
21
+ #
22
+ # WHY NOT CHROOT (history)
23
+ # ------------------------
24
+ # We built and verified a chroot architecture on 2026-05-28 that bind-mounted
25
+ # /usr /lib /lib64 /bin /sbin /opt from image into a chroot at /workspace/root-chroot,
26
+ # with HOME=/root pointing inside the chroot. Real Fly A/B test 2026-05-28 showed:
27
+ # - First-boot time: chroot 102s vs no-chroot 111s (within polling noise)
28
+ # - EBUSY on shutdown: chroot 8 vs no-chroot 8 (identical — not a chroot issue)
29
+ # - Persistence: both pass
30
+ # - LOC in entrypoint: chroot ~270 vs no-chroot ~165 (no-chroot saves ~100 LOC)
31
+ # - Path layout: chroot /workspace/root-chroot/root/... vs /workspace/home/...
32
+ # Subagent research confirmed chroot in our codebase solves only HOME-and-persistence
33
+ # layout, NOT library access (Linux dynamic linker is mount-agnostic per ld.so(8)).
34
+ # Since HOME=/workspace achieves the same persistence with ~100 fewer LOC and
35
+ # no bind-mount complexity, the chroot version was retired.
36
+ # Archive: docs/archive/Dockerfile.sandbox.chroot-2026-05-28.md
5
37
 
6
38
  FROM node:22-slim
7
39
 
8
- # Runtime deps for osborn + claude-code
40
+ # Runtime deps (same as chroot variant — comes from image, image-swap upgrades)
9
41
  RUN apt-get update -qq && \
10
42
  apt-get install --no-install-recommends -y \
11
43
  ca-certificates \
@@ -16,131 +48,157 @@ RUN apt-get update -qq && \
16
48
  python-is-python3 && \
17
49
  rm -rf /var/lib/apt/lists/*
18
50
 
19
- # Pin osborn to an explicit version so Docker layer cache invalidates whenever
20
- # the version changes. Earlier `RUN npm install -g osborn@latest` looked correct
21
- # but it never invalidated: `latest` is a tag resolved by npm at install time,
22
- # but Docker's layer cache keys on the literal RUN command string. So every
23
- # rebuild reused the cached layer from when "latest" was a previous version,
24
- # producing images labeled :0.9.21 that actually contained osborn 0.9.19 inside.
25
- #
26
- # Pass --build-arg OSBORN_VERSION=X.Y.Z when building. image-build-check.ts
27
- # does this automatically using the npm-registry-resolved latest version.
28
51
  ARG OSBORN_VERSION=latest
29
52
  RUN npm install -g "osborn@${OSBORN_VERSION}" @anthropic-ai/claude-code
30
53
 
31
- # Persistent workspace + claude config dirs
32
- RUN mkdir -p /workspace /root/.claude
54
+ # Persistent volume mount point
55
+ RUN mkdir -p /workspace
33
56
 
34
- # Marker so orchestration (machines.ts isManifestAware) can detect this image
35
- # supports the manifest-driven update flow. Pre-marker machines fall back to
36
- # the image-swap update path, which brings them onto a marker-aware image;
37
- # from then on, all updates use the manifest flow defined in the entrypoint.
38
- RUN touch /etc/osborn-manifest-aware
57
+ # Markers (preserved for back-compat probes from machines.ts):
58
+ # /etc/osborn-manifest-aware — older marker (still here)
59
+ # /etc/osborn-option-d-aware — new marker; machines.ts can detect this variant
60
+ RUN touch /etc/osborn-manifest-aware /etc/osborn-option-d-aware
39
61
 
40
- ENV OSBORN_CWD=/workspace
41
62
  ENV OSBORN_API_PORT=8741
42
63
  ENV NODE_ENV=production
64
+ ENV OSBORN_IMAGE_VERSION=${OSBORN_VERSION}
43
65
 
44
- # HOME points at the volume so user-space config from any tool that respects
45
- # HOME (gh, git, ssh, aws, etc.) automatically writes to the persistent
46
- # volume instead of the ephemeral container overlay. The existing /root/.claude
47
- # symlink machinery below stays in place it's redundant with HOME=/workspace
48
- # but harmless.
66
+ # HOME=/workspace the volume mount point itself is the home directory.
67
+ # This means ~/.claude resolves to /workspace/.claude, which is EXACTLY where
68
+ # the legacy symlink architecture (/root/.claude -> /workspace/.claude) already
69
+ # put credentials and sessions. So there is ZERO migration: an existing machine
70
+ # updating to this image finds its data already at ~/.claude with no file moves.
71
+ #
72
+ # Why not /workspace/home (the earlier Option D choice)? That required MOVING
73
+ # legacy data from /workspace/.claude into /workspace/home/.claude — an `mv`
74
+ # loop that is destructive (deletes source as it goes), non-atomic across
75
+ # multiple files (interruption = split state), and catastrophic if HOME ever
76
+ # resolved off-volume (mv would send data to the ephemeral overlay). Pointing
77
+ # HOME at /workspace eliminates the migration entirely: nothing moves, so
78
+ # nothing can be lost in a move. The only cosmetic cost is dotfiles sitting at
79
+ # the volume root — identical to what the legacy symlink effectively did.
80
+ #
81
+ # NOTE on overrides: these are Dockerfile ENV *defaults*. A Fly machine-config
82
+ # `env.HOME` (or app secret) OVERRIDES them at runtime. updateOsborn strips
83
+ # HOME/OSBORN_CWD from existing machine configs during image-swap so this
84
+ # default actually takes effect on migrated machines — without that, a stale
85
+ # HOME=/root from an older provisioning would silently win. See
86
+ # frontend/src/lib/machines.ts updateOsbornImpl.
49
87
  ENV HOME=/workspace
88
+ ENV OSBORN_CWD=/workspace
50
89
 
51
- WORKDIR /workspace
90
+ # HYBRID: user-installed global npm packages persist on the volume.
91
+ # osborn itself was already installed above into the DEFAULT prefix (/usr/local,
92
+ # image layer) — that RUN happened BEFORE this ENV, so osborn stays in the image
93
+ # and updates via image-swap (atomic, no runtime OOM, toolchain present at build).
94
+ # Setting NPM_CONFIG_PREFIX here only affects RUNTIME `npm install -g <x>` the
95
+ # user/agent runs: those land in /workspace/.npm-global on the persistent volume
96
+ # and survive restarts + image-swaps. PATH puts that bin dir first so installed
97
+ # CLIs are immediately runnable. Verified end-to-end on real Fly 2026-06-01:
98
+ # pure-JS (cowsay) AND native-compiled (node-pty via node-gyp) both install at
99
+ # runtime, persist across restart, no OOM (toolchain is in the image).
100
+ # Caveat: native user modules are tied to the image's Node ABI (currently 22) —
101
+ # a future Node-major image bump would need an `npm rebuild` of volume globals.
102
+ ENV NPM_CONFIG_PREFIX=/workspace/.npm-global
103
+ ENV PATH=/workspace/.npm-global/bin:$PATH
52
104
 
105
+ WORKDIR /workspace
53
106
  EXPOSE 8741
54
107
 
55
- # Entrypoint: credential persistence + onboarding suppression + start
108
+ # Entrypoint: HOME-on-volume seed + onboarding + start
56
109
  COPY <<'ENTRYPOINT' /entrypoint.sh
57
110
  #!/bin/bash
58
111
  set -e
59
112
 
60
- # Persistent log capture for post-disconnect upload to Supabase Storage.
61
- # Fly Machines has NO REST endpoint for fetching machine logs (the previous
62
- # implementation hit /v1/apps/{app}/machines/{id}/logs which returns 404 → that
63
- # 404 error string was getting uploaded as "the log" for every session).
64
- # Volume-backed /workspace/osborn.log survives reboots and is readable via
65
- # the documented /exec endpoint (`tail -n 500 /workspace/osborn.log`).
66
- #
67
- # We use process substitution to tee output to BOTH the log file AND the
68
- # original stdout (so `flyctl logs` keeps working for ad-hoc debugging).
69
- # Requires bash, hence the #!/bin/bash shebang.
70
- #
71
- # Size cap: if log grows past 100 MB, keep only the last 50 MB. Prevents
72
- # disk-fill from long-running retry loops (we saw 17h × ~1 line/min = ~1000
73
- # lines today, but anything connecting to LiveKit produces orders of
74
- # magnitude more output).
113
+ # === Persistent log capture ===
114
+ # Same as chroot variant: tee stdout/stderr to /workspace/osborn.log (volume).
115
+ # Rotates at 100MB keeps last 50MB.
75
116
  LOGFILE=/workspace/osborn.log
76
117
  mkdir -p /workspace
77
118
  if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 104857600 ]; then
78
- echo "[sandbox] Rotating /workspace/osborn.log (>100MB, keeping last 50MB)"
119
+ echo "[sandbox-d] Rotating /workspace/osborn.log (>100MB, keeping last 50MB)"
79
120
  tail -c 52428800 "$LOGFILE" > "$LOGFILE.tmp" && mv "$LOGFILE.tmp" "$LOGFILE"
80
121
  fi
81
- echo "[sandbox] === boot at $(date -Iseconds) ===" >> "$LOGFILE"
82
- # Redirect all subsequent stdout+stderr from this script (and the eventual
83
- # `exec osborn`) to both the original fd (Fly stdout collector) AND the
84
- # append-only log file. tee runs as a backgrounded subshell that survives
85
- # the final exec replacement.
122
+ echo "[sandbox-d] === boot at $(date -Iseconds) ===" >> "$LOGFILE"
86
123
  exec > >(tee -a "$LOGFILE") 2>&1
87
124
 
88
- # Claude credential persistence (volume at /workspace)
89
- mkdir -p /workspace/.claude
90
- rm -rf /root/.claude
91
- ln -sf /workspace/.claude /root/.claude
92
-
93
- # Suppress Claude Code interactive onboarding prompts
125
+ # Onboarding-suppression JSON
94
126
  ONBOARDING_JSON='{"numStartups":10,"installMethod":"npm","autoUpdates":false,"hasCompletedOnboarding":true,"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true,"hasAcknowledgedCostThreshold":true,"effortCalloutV2Dismissed":true,"theme":"dark","projects":{"/workspace":{"hasTrustDialogAccepted":true,"hasTrustDialogHooksAccepted":true,"hasCompletedProjectOnboarding":true}}}'
95
- echo "$ONBOARDING_JSON" > /root/.claude.json
96
- # Additional write at $HOME/.claude.json. With HOME=/workspace this is where
97
- # Claude Code actually reads its top-level config from; the /root/.claude.json
98
- # write above becomes dead but is left in place (harmless).
99
- echo "$ONBOARDING_JSON" > /workspace/.claude.json
100
- mkdir -p /workspace/.claude
101
- echo "$ONBOARDING_JSON" > /workspace/.claude/.config.json
102
- echo "$ONBOARDING_JSON" > /workspace/.claude/claude.json
103
-
104
- # Restore OAuth token if persisted on volume
105
- if [ -f /workspace/.claude/.oauth-token ]; then
106
- export CLAUDE_CODE_OAUTH_TOKEN="$(cat /workspace/.claude/.oauth-token)"
107
- echo "[sandbox] Restored CLAUDE_CODE_OAUTH_TOKEN from volume"
127
+
128
+ # ============================================================
129
+ # === HOME-on-volume setup (HOME=/workspace) ===
130
+ # ============================================================
131
+ # HOME=/workspace, set via ENV in Dockerfile (and enforced by updateOsborn
132
+ # stripping any stale HOME from existing machine configs). Because HOME is the
133
+ # volume mount itself, ~/.claude == /workspace/.claude — which is exactly where
134
+ # the legacy symlink architecture already stored credentials + sessions.
135
+ #
136
+ # THEREFORE: NO MIGRATION. An existing machine's data is already at ~/.claude
137
+ # the instant HOME points at /workspace. We removed the old `mv` migration
138
+ # block entirely (it was destructive + non-atomic + catastrophic if HOME ever
139
+ # resolved off-volume). Nothing moves, so nothing can be lost in a move.
140
+ echo "[sandbox-d] HOME=$HOME OSBORN_CWD=$OSBORN_CWD NPM_CONFIG_PREFIX=$NPM_CONFIG_PREFIX"
141
+ mkdir -p "$HOME" "$HOME/.claude" "$HOME/.osborn"
142
+ # HYBRID: ensure the volume-backed npm global prefix exists so user
143
+ # `npm install -g <x>` has a target on first use (npm would create it anyway,
144
+ # but pre-making it keeps perms predictable + visible in the boot log).
145
+ mkdir -p /workspace/.npm-global
146
+
147
+ # Onboarding config (overwrites every boot — intentional, deterministic state)
148
+ echo "$ONBOARDING_JSON" > "$HOME/.claude.json"
149
+ echo "$ONBOARDING_JSON" > "$HOME/.claude/.config.json"
150
+ echo "$ONBOARDING_JSON" > "$HOME/.claude/claude.json"
151
+
152
+ # Restore OAuth token if persisted
153
+ if [ -f "$HOME/.claude/.oauth-token" ]; then
154
+ export CLAUDE_CODE_OAUTH_TOKEN="$(cat "$HOME/.claude/.oauth-token")"
155
+ echo "[sandbox-d] restored CLAUDE_CODE_OAUTH_TOKEN from volume"
108
156
  fi
109
157
 
110
- # Seed default skills into ~/.claude/skills/ single source of truth.
111
- # The agent only loads skills from this path; defaults shipped in the npm
112
- # package get copied here on first boot. Idempotent: existing skills
113
- # (learned via PostCompact or manually edited) are preserved across restarts.
114
- HOME_SKILLS_DIR=/root/.claude/skills
115
- PKG_SKILLS_DIR=/usr/local/lib/node_modules/osborn/.claude/skills
158
+ # === Seed default skills with version-aware refresh (ported from chroot B6 fix) ===
159
+ # Same invariants as chroot variant:
160
+ # (1) USER-ADDED skills preserved across image upgrades
161
+ # (2) IMAGE-DEFAULT skills refreshed when image version changes
162
+ HOME_SKILLS_DIR="$HOME/.claude/skills"
163
+ PKG_SKILLS_DIR="/usr/local/lib/node_modules/osborn/.claude/skills"
164
+ SEED_VERSION_FILE="${HOME_SKILLS_DIR}/.seed-version"
116
165
  mkdir -p "$HOME_SKILLS_DIR"
166
+ CURRENT_SEED_VERSION=$(cat "$SEED_VERSION_FILE" 2>/dev/null | tr -d '[:space:]' || echo "")
167
+ IMAGE_SEED_VERSION="${OSBORN_IMAGE_VERSION:-latest}"
117
168
  if [ -d "$PKG_SKILLS_DIR" ]; then
169
+ REFRESHED=0
170
+ SEEDED=0
118
171
  for d in "$PKG_SKILLS_DIR"/*/; do
119
172
  [ -d "$d" ] || continue
120
173
  NAME=$(basename "$d")
121
- [ -d "$HOME_SKILLS_DIR/$NAME" ] && continue
122
- cp -r "$d" "$HOME_SKILLS_DIR/$NAME"
123
- echo "[sandbox] seeded default skill: $NAME"
174
+ if [ ! -d "$HOME_SKILLS_DIR/$NAME" ]; then
175
+ cp -r "$d" "$HOME_SKILLS_DIR/$NAME"
176
+ echo "[sandbox-d] seeded default skill: $NAME"
177
+ SEEDED=$((SEEDED+1))
178
+ elif [ "$CURRENT_SEED_VERSION" != "$IMAGE_SEED_VERSION" ]; then
179
+ rm -rf "$HOME_SKILLS_DIR/$NAME"
180
+ cp -r "$d" "$HOME_SKILLS_DIR/$NAME"
181
+ echo "[sandbox-d] refreshed default skill: $NAME (image $CURRENT_SEED_VERSION → $IMAGE_SEED_VERSION)"
182
+ REFRESHED=$((REFRESHED+1))
183
+ fi
124
184
  done
185
+ if [ "$CURRENT_SEED_VERSION" != "$IMAGE_SEED_VERSION" ]; then
186
+ echo "$IMAGE_SEED_VERSION" > "$SEED_VERSION_FILE"
187
+ [ "$REFRESHED" -gt 0 ] && echo "[sandbox-d] skills marker: $CURRENT_SEED_VERSION → $IMAGE_SEED_VERSION (refreshed $REFRESHED, seeded $SEEDED)"
188
+ fi
125
189
  fi
126
190
 
127
- # Manifest-driven version check.
128
- # Orchestration writes /workspace/.osborn-want-version on update (machines.ts
129
- # updateViaManifest). On every boot we compare to the currently-installed
130
- # osborn and run `npm install -g osborn@<want>` if they differ. The install
131
- # lands in the container overlay (default npm prefix) — Fly wipes overlay on
132
- # stop/start so the install re-runs on every boot until the base image is
133
- # rebuilt with that version baked in. Update is fast between Fly restarts;
134
- # only the first boot after a restart pays the npm install cost.
135
- WANT=$(cat /workspace/.osborn-want-version 2>/dev/null | tr -d '[:space:]')
136
- if [ -n "$WANT" ]; then
137
- CURRENT=$(osborn --version 2>/dev/null | head -1 | tr -d '[:space:]')
138
- if [ "$WANT" != "$CURRENT" ]; then
139
- echo "[sandbox] osborn ${CURRENT:-none} → ${WANT} (manifest install)"
140
- npm install -g "osborn@${WANT}" || echo "[sandbox] install failed — running ${CURRENT:-image-baked} version"
141
- fi
191
+ # Container-view session inventory (debugging)
192
+ if [ -d "$HOME/.claude/projects" ]; then
193
+ echo "[sandbox-d] Session inventory:"
194
+ for slug_dir in "$HOME/.claude/projects"/*/; do
195
+ [ -d "$slug_dir" ] || continue
196
+ count=$(find "$slug_dir" -maxdepth 1 -name '*.jsonl' 2>/dev/null | wc -l | tr -d ' ')
197
+ echo "[sandbox-d] $(basename "$slug_dir"): ${count} jsonl files"
198
+ done
142
199
  fi
143
200
 
201
+ echo "[sandbox-d] exec'ing osborn (no chroot, HOME=$HOME)"
144
202
  exec osborn
145
203
  ENTRYPOINT
146
204
 
package/dist/index.js CHANGED
@@ -2527,6 +2527,30 @@ async function main() {
2527
2527
  currentLLM = null;
2528
2528
  clearFastBrainSession();
2529
2529
  clearPipelineFastBrainSession();
2530
+ // ── Ghost-agent fix (2026-06-01) ──
2531
+ // When LiveKit Cloud evicts our WebSocket (idle, network blip, or quota window),
2532
+ // the previous code stopped here — agent process kept running but no longer in
2533
+ // any room. /health continued returning "livekit.status:connected" because the
2534
+ // status was never written back. Frontend's checkOsbornHealth only validates
2535
+ // HTTP 200, so the ghost state was invisible. Users got stuck in "Connecting..."
2536
+ // forever because their LiveKit-token-minted room had no agent in it.
2537
+ //
2538
+ // Fix: re-arm the retry loop. connectWithRetry() will try to reconnect with
2539
+ // the same room name (so the room code stays stable for any in-flight frontend
2540
+ // token requests), backing off 5s → 60s. If the disconnect was permanent
2541
+ // (e.g. JWT expired — they're 24h), the retry will fail and surface
2542
+ // livekit.status=failed, which the (also-fixed) frontend health check will
2543
+ // see and trigger restartService.
2544
+ //
2545
+ // Note: we mark status='retrying' immediately so /health reflects the real
2546
+ // state — closing the lie window between Disconnected and the next attempt.
2547
+ livekitState.status = 'retrying';
2548
+ livekitState.error = 'LiveKit room disconnected; attempting to rejoin';
2549
+ livekitState.errorCode = 'disconnected';
2550
+ console.log('🔄 Rejoining LiveKit room after disconnect...');
2551
+ connectWithRetry().catch(err => {
2552
+ console.error('❌ Reconnect attempt threw (should not happen — connectWithRetry loops):', err);
2553
+ });
2530
2554
  });
2531
2555
  room.on(RoomEvent.ParticipantConnected, async (participant) => {
2532
2556
  console.log(`\n👤 User joined: ${participant.identity}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.9.47",
3
+ "version": "0.9.49",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {