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.
- package/.claude/skills/voice-native-sync/SKILL.md +229 -0
- package/Dockerfile.sandbox +147 -89
- package/dist/index.js +24 -0
- package/package.json +1 -1
|
@@ -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)
|
package/Dockerfile.sandbox
CHANGED
|
@@ -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
|
|
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
|
|
32
|
-
RUN mkdir -p /workspace
|
|
54
|
+
# Persistent volume mount point
|
|
55
|
+
RUN mkdir -p /workspace
|
|
33
56
|
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
|
|
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
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
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
|
-
|
|
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:
|
|
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
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
HOME_SKILLS_DIR
|
|
115
|
-
PKG_SKILLS_DIR
|
|
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" ]
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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}`);
|