osborn 0.8.6 → 0.8.8

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.
@@ -150,6 +150,13 @@ export async function checkClaudeAuthStatus() {
150
150
  * Strips ALL whitespace first (like vutran1710/claudebox) to handle
151
151
  * Ink UI wrapping the URL across multiple lines.
152
152
  * Also cleans trailing "Pastecodehereifprompted" that Ink appends.
153
+ *
154
+ * IMPORTANT: strips the `redirect_uri` query parameter (which points to a
155
+ * localhost callback server on the *sprite*, not the user's machine). With
156
+ * no redirect_uri, claude.com falls back to showing the auth code in-page,
157
+ * which the user pastes back into the modal. This is the only flow that
158
+ * works for cloud sandboxes — the localhost redirect breaks both on phones
159
+ * (no listener) AND on desktops (sprite's localhost is unreachable).
153
160
  */
154
161
  function extractOAuthUrl(text) {
155
162
  // Strip ANSI codes
@@ -168,6 +175,39 @@ function extractOAuthUrl(text) {
168
175
  if (idx > 0)
169
176
  url = url.substring(0, idx);
170
177
  }
178
+ // Strip the localhost redirect_uri so claude.com shows a pasteable code
179
+ // instead of trying to redirect. URL() can't be used here because it
180
+ // re-encodes the path, so we surgically delete the redirect_uri param.
181
+ url = stripRedirectUri(url);
182
+ return url;
183
+ }
184
+ /**
185
+ * Strip the `redirect_uri` query param from an OAuth URL.
186
+ *
187
+ * Background: `claude setup-token` spawns a one-shot localhost HTTP server on
188
+ * a random port and registers it as the redirect_uri. That works fine when the
189
+ * user is on the same machine as the CLI, but on a sprite the URL points to
190
+ * the *sprite's* localhost — unreachable from the user's browser regardless
191
+ * of whether they open the auth link on their PC or their phone. With no
192
+ * redirect_uri at all, claude.ai falls back to its in-page code display
193
+ * (the same flow that `claude setup-token`'s "Paste code here if prompted"
194
+ * Ink input is built to consume), and the user can paste the code back into
195
+ * our modal — which works whether they signed in on phone or desktop.
196
+ *
197
+ * Done with regex rather than `new URL()` because the URL constructor
198
+ * normalizes the path (which can break Claude's strict redirect check)
199
+ * and re-encodes spaces/special chars in other params.
200
+ */
201
+ function stripRedirectUri(url) {
202
+ const before = url;
203
+ // Three cases: leading param (?redirect_uri=...&), middle/trailing (&redirect_uri=...),
204
+ // and only param (?redirect_uri=...). Order matters so cleanup leaves the URL well-formed.
205
+ url = url.replace(/&redirect_uri=[^&]*/g, '');
206
+ url = url.replace(/\?redirect_uri=[^&]*&/g, '?');
207
+ url = url.replace(/\?redirect_uri=[^&]*$/g, '');
208
+ if (before !== url) {
209
+ console.log('🔑 Stripped localhost redirect_uri from OAuth URL — claude.ai will show a pasteable code instead of redirecting');
210
+ }
171
211
  return url;
172
212
  }
173
213
  // ─────────────────────────────────────────
package/dist/config.js CHANGED
@@ -37,8 +37,13 @@ export const MCP_CATALOG = [
37
37
  },
38
38
  ];
39
39
  // Default config template
40
+ // Note: workingDirectory is intentionally OMITTED here. Baking process.cwd() into the
41
+ // default config at module-load time freezes whatever directory osborn happened to be
42
+ // invoked from on first boot — which on cloud sandboxes can be the npm install dir
43
+ // (`/usr/local/nvm/.../osborn`) and gets persisted to ~/.osborn/config.yaml forever.
44
+ // Leaving it undefined lets the runtime self-heal in index.ts resolve it on every boot
45
+ // from OSBORN_CWD → process.cwd() at the actual time the agent starts.
40
46
  const DEFAULT_CONFIG = {
41
- workingDirectory: process.cwd(),
42
47
  defaultProvider: 'gemini',
43
48
  defaultCodingAgent: 'claude',
44
49
  // Voice mode: 'direct' (Claude Agent SDK) or 'realtime' (OpenAI/Gemini native)
@@ -504,36 +509,83 @@ export function sessionExists(sessionId, projectPath) {
504
509
  return existsSync(sessionFile);
505
510
  }
506
511
  /**
507
- * Reverse a project slug back to a path (best-effort replace leading dash, then dashes→slashes).
508
- * "-Users-foo-bar" → "/Users/foo/bar"
512
+ * Reverse a project slug back to a path — LAST-RESORT fallback only.
513
+ *
514
+ * Claude's slug encoding (`/` → `-`, `.` → `-`) is LOSSY: you can't tell from a
515
+ * slug whether a given `-` was originally `/`, `.`, or a literal `-` inside a
516
+ * directory name like `pensive-bohr`. So this function cannot reliably
517
+ * round-trip an arbitrary path.
518
+ *
519
+ * Strategy: produce the naive guess (with a small `--` → `/.` improvement for
520
+ * dot-directories like `.claude`), then VALIDATE it with `existsSync`. If the
521
+ * guess doesn't exist, return empty string — that way the caller knows the
522
+ * reverse failed and can fall back cleanly instead of passing a broken path
523
+ * to `child_process.spawn` and crashing with ENOENT.
524
+ *
525
+ * The primary source of cwd is `extractCwd()` which reads the actual cwd from
526
+ * the JSONL file. This function is only reached when that fails.
509
527
  */
510
528
  function slugToPath(slug) {
511
- return slug.replace(/^-/, '/').replace(/-/g, '/');
529
+ // Naive reverse: leading `-` → `/`, `--` → `/.`, remaining `-` → `/`.
530
+ // The `--` → `/.` pass handles dot-prefixed directories like `.claude`.
531
+ const guess = slug
532
+ .replace(/^-/, '/')
533
+ .replace(/--/g, '/.')
534
+ .replace(/-/g, '/');
535
+ // Validate — lossy encoding means we cannot trust the guess.
536
+ return existsSync(guess) ? guess : '';
512
537
  }
513
538
  const UUID_JSONL_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i;
514
539
  /**
515
- * Extract cwd from first user message in a JSONL file.
516
- * Reuses the existing readline-based parsing pattern.
540
+ * Extract cwd from the first JSONL entry that carries a `cwd` field.
541
+ *
542
+ * Previously: read only the first 8KB and only accepted `type === 'user'`. That
543
+ * broke for sessions whose first JSONL entry was larger than 8KB — e.g. a
544
+ * `queue-operation` containing pasted email/page text. readline never emits the
545
+ * `line` event for an incomplete final chunk, so the scan finds nothing,
546
+ * `listAllClaudeSessions` falls through to the lossy `slugToPath` reverse, and
547
+ * the mangled path ends up as a `cwd` passed to `child_process.spawn`, producing
548
+ * the misleading "Claude Code executable not found" error (see MEMORY bug #11).
549
+ *
550
+ * Now: stream line-by-line with no byte cap, short-circuit on the first entry
551
+ * with a `cwd` field regardless of `type` (every `user` / `attachment` /
552
+ * `assistant` / `system` entry in a Claude JSONL session carries `cwd`, so the
553
+ * scan finishes in the first few KB of any normal session).
517
554
  */
518
555
  async function extractCwd(filePath) {
519
556
  return new Promise((resolve) => {
520
- const fileStream = createReadStream(filePath, { end: 8192 }); // first 8KB
557
+ const fileStream = createReadStream(filePath);
521
558
  const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
559
+ let resolved = false;
560
+ const done = (value) => {
561
+ if (resolved)
562
+ return;
563
+ resolved = true;
564
+ try {
565
+ rl.close();
566
+ }
567
+ catch { }
568
+ try {
569
+ fileStream.destroy();
570
+ }
571
+ catch { }
572
+ resolve(value);
573
+ };
522
574
  rl.on('line', (line) => {
575
+ if (resolved)
576
+ return;
523
577
  if (!line.trim())
524
578
  return;
525
579
  try {
526
580
  const obj = JSON.parse(line);
527
- if (obj.type === 'user' && obj.cwd) {
528
- rl.close();
529
- fileStream.destroy();
530
- resolve(obj.cwd);
581
+ if (typeof obj?.cwd === 'string' && obj.cwd.length > 0) {
582
+ done(obj.cwd);
531
583
  }
532
584
  }
533
585
  catch { }
534
586
  });
535
- rl.on('close', () => resolve(''));
536
- rl.on('error', () => resolve(''));
587
+ rl.on('close', () => done(''));
588
+ rl.on('error', () => done(''));
537
589
  });
538
590
  }
539
591
  /**
package/dist/index.js CHANGED
@@ -1680,10 +1680,26 @@ async function main() {
1680
1680
  preSelectedSessionId = metadata.sessionId;
1681
1681
  console.log(`📂 Pre-selected session from frontend: ${preSelectedSessionId}`);
1682
1682
  }
1683
- // Read working directory override from frontend
1683
+ // Read working directory override from frontend.
1684
+ //
1685
+ // Must validate with existsSync before accepting: a broken reverse-slug in
1686
+ // the frontend's session list (see `slugToPath` in config.ts — the encoding
1687
+ // is lossy), a deleted project, or a bad legacy client can all produce a
1688
+ // non-existent path here. Passing a non-existent cwd to
1689
+ // `child_process.spawn` in the Claude SDK errors with ENOENT, which the
1690
+ // SDK then reports as the misleading "Claude Code executable not found at
1691
+ // .../cli.js" error (see MEMORY bug fix #11). Fall back to defaultWorkingDir
1692
+ // (which is itself existsSync-verified at startup).
1684
1693
  if (metadata.workingDirectory && typeof metadata.workingDirectory === 'string' && metadata.workingDirectory.length > 0) {
1685
- workingDir = metadata.workingDirectory;
1686
- console.log(`📂 Working directory from frontend: ${workingDir}`);
1694
+ if (existsSync(metadata.workingDirectory)) {
1695
+ workingDir = metadata.workingDirectory;
1696
+ console.log(`📂 Working directory from frontend: ${workingDir}`);
1697
+ }
1698
+ else {
1699
+ console.log(`⚠️ Frontend sent workingDirectory that does not exist: ${metadata.workingDirectory}`);
1700
+ console.log(` Falling back to default: ${defaultWorkingDir}`);
1701
+ workingDir = defaultWorkingDir;
1702
+ }
1687
1703
  }
1688
1704
  else {
1689
1705
  // Reset to default for new connections (in case previous session changed it)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osborn",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Voice AI coding assistant - local agent that connects to Osborn frontend",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "dev": "tsx src/index.ts",
11
+ "dev:logged": "tsx scripts/dev-logged.ts",
12
+ "review": "tsx scripts/review.ts",
11
13
  "start": "tsx src/index.ts",
12
14
  "build": "tsc",
13
15
  "room": "tsx src/index.ts --room",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Dev-logger wrapper: spawns `tsx src/index.ts` as a child, tees its stdout /
3
+ * stderr to BOTH the user's terminal AND a timestamped log file under
4
+ * `.osborn/dev-logs/`. Forwards SIGINT / SIGTERM so Ctrl-C cleanly shuts down
5
+ * the agent child before the wrapper exits.
6
+ *
7
+ * This is an OUT-OF-LOOP process — the agent itself (src/index.ts) is
8
+ * unmodified and unaware of this wrapper. Removing the dev-logger means
9
+ * deleting this file and the `"dev:logged"` script in package.json — zero
10
+ * impact on the agent's runtime behavior.
11
+ *
12
+ * Usage:
13
+ * npm run dev:logged # capture to .osborn/dev-logs/<ts>.log
14
+ *
15
+ * After shutdown, review with:
16
+ * npm run review
17
+ */
18
+
19
+ import { spawn } from 'node:child_process'
20
+ import { createWriteStream, mkdirSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+
23
+ // Invoked via `npm run dev:logged` from `agent/`, so process.cwd() === agent/.
24
+ // Log dir is co-located with the agent install — follows the existing
25
+ // `.osborn/` convention (already matched by the root .gitignore).
26
+ const logDir = join(process.cwd(), '.osborn', 'dev-logs')
27
+ mkdirSync(logDir, { recursive: true })
28
+
29
+ // YYYYMMDDHHMMSS timestamp — sortable, filesystem-safe on every OS.
30
+ const ts = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14)
31
+ const logPath = join(logDir, `${ts}.log`)
32
+ const logStream = createWriteStream(logPath, { flags: 'a' })
33
+
34
+ console.log(`📝 [dev-logger] Capturing to ${logPath}`)
35
+ console.log(`📝 [dev-logger] Review later with: npm run review\n`)
36
+ logStream.write(`=== dev-logged session started at ${new Date().toISOString()} ===\n`)
37
+
38
+ // `tsx` resolves to `node_modules/.bin/tsx` because npm run <script> prepends
39
+ // the local node_modules/.bin to PATH. No need to hardcode the path.
40
+ const child = spawn('tsx', ['src/index.ts'], {
41
+ stdio: ['inherit', 'pipe', 'pipe'],
42
+ env: process.env,
43
+ })
44
+
45
+ child.stdout.on('data', (chunk: Buffer) => {
46
+ process.stdout.write(chunk)
47
+ logStream.write(chunk)
48
+ })
49
+
50
+ child.stderr.on('data', (chunk: Buffer) => {
51
+ process.stderr.write(chunk)
52
+ logStream.write(chunk)
53
+ })
54
+
55
+ // Forward termination signals exactly once — if the user hits Ctrl-C multiple
56
+ // times, only the first SIGINT goes to the child; subsequent ones are ignored
57
+ // to avoid racing the graceful shutdown.
58
+ let forwarded = false
59
+ const forward = (sig: NodeJS.Signals) => {
60
+ if (forwarded) return
61
+ forwarded = true
62
+ try { child.kill(sig) } catch {}
63
+ }
64
+ process.on('SIGINT', () => forward('SIGINT'))
65
+ process.on('SIGTERM', () => forward('SIGTERM'))
66
+
67
+ child.on('exit', (code) => {
68
+ logStream.write(`\n=== dev-logged session ended at ${new Date().toISOString()} (exit ${code}) ===\n`)
69
+ // Wait for the log file to finish flushing BEFORE process.exit — otherwise
70
+ // the final marker may be lost on disk.
71
+ logStream.end(() => {
72
+ console.log(`\n📝 [dev-logger] Log saved: ${logPath}`)
73
+ console.log(`📝 [dev-logger] Review: npm run review`)
74
+ process.exit(code ?? 0)
75
+ })
76
+ })
77
+
78
+ child.on('error', (err) => {
79
+ console.error(`❌ [dev-logger] Failed to spawn agent: ${err.message}`)
80
+ logStream.end(() => process.exit(1))
81
+ })