@venturewild/workspace 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -42,7 +42,8 @@
42
42
  "@venturewild/workspace-daemon-win32-x64": "0.1.0",
43
43
  "@venturewild/workspace-daemon-darwin-x64": "0.1.0",
44
44
  "@venturewild/workspace-daemon-darwin-arm64": "0.1.0",
45
- "@venturewild/workspace-daemon-linux-x64": "0.1.0"
45
+ "@venturewild/workspace-daemon-linux-x64": "0.1.0",
46
+ "@homebridge/node-pty-prebuilt-multiarch": "0.13.1"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@vitejs/plugin-react": "^4.3.0",
@@ -710,12 +710,16 @@ async function main() {
710
710
  } catch {}
711
711
  }
712
712
 
713
+ // Hard safety net: even if stop() ever wedges, never leave the user staring at
714
+ // "shutting down…". Unref'd so the timer itself doesn't keep us alive.
715
+ const forceExitSoon = () => { setTimeout(() => process.exit(0), 3000).unref(); };
713
716
  process.on('SIGINT', async () => {
714
717
  console.log('\nshutting down…');
715
- await server.stop();
718
+ forceExitSoon();
719
+ try { await server.stop(); } catch {}
716
720
  process.exit(0);
717
721
  });
718
- process.on('SIGTERM', async () => { await server.stop(); process.exit(0); });
722
+ process.on('SIGTERM', async () => { forceExitSoon(); try { await server.stop(); } catch {} process.exit(0); });
719
723
  }
720
724
 
721
725
  main().catch((err) => {
@@ -0,0 +1,146 @@
1
+ // agent-login.mjs — drives an in-app "Sign in to Claude" so a non-technical user
2
+ // never has to touch a terminal. See docs/user-experience.md (the cold-start gate)
3
+ // + the 2026-06-01 CHANGELOG.
4
+ //
5
+ // WHY A PTY: `claude auth login` is an OAuth-via-browser flow. When spawned with
6
+ // plain piped stdio (no TTY), Claude can't run its localhost callback server, so
7
+ // the browser shows a code to paste back into the terminal — useless to us. A real
8
+ // pseudo-terminal (node-pty) makes Claude behave exactly as it does in the user's
9
+ // own terminal: it opens claude.ai, and the localhost callback auto-completes. The
10
+ // user only ever interacts with the browser; the PTY runs server-side, headless.
11
+ //
12
+ // On success the credential lands in the OS store (macOS Keychain / Windows
13
+ // ~/.claude/.credentials.json) — nothing for us to persist — and the existing
14
+ // `claude auth status` readiness probe flips to `ready` (or `subscribe` for a
15
+ // signed-in-but-no-plan account, which the gate then routes correctly).
16
+ //
17
+ // node-pty is loaded LAZILY + OPTIONALLY. If the prebuilt binary isn't available
18
+ // on this platform, start() returns `{ status: 'unsupported' }` and the UI falls
19
+ // back to the "run `claude auth login` in a new terminal" instruction — so adding
20
+ // this never breaks the (no-sudo, no-native-dep) install path we proved on macOS.
21
+
22
+ import { probeAgentReadiness } from './agent-readiness.mjs';
23
+
24
+ // First http(s) URL in a chunk (Claude prints the OAuth URL it's opening).
25
+ const URL_RE = /(https?:\/\/[^\s'"]+)/;
26
+ // Claude asks for a pasted code only when the browser callback couldn't be reached
27
+ // (shouldn't happen under a real PTY, but we handle it as a fallback).
28
+ const CODE_HINT_RE = /paste.*code|enter the code|authoriz(at)?ion code|code:\s*$/i;
29
+
30
+ let _ptyModPromise;
31
+ async function defaultPtyLoader() {
32
+ if (!_ptyModPromise) {
33
+ _ptyModPromise = import('@homebridge/node-pty-prebuilt-multiarch')
34
+ .then((m) => m.default || m)
35
+ .catch(() => null); // not installed / no prebuilt for this platform
36
+ }
37
+ return _ptyModPromise;
38
+ }
39
+
40
+ // status: idle | starting | awaiting-browser | awaiting-code | success | error | unsupported
41
+ export class ClaudeLoginSession {
42
+ constructor({ agent, openImpl, reprobeImpl, ptyLoader, env } = {}) {
43
+ this.agent = agent;
44
+ this.openImpl = openImpl || null; // (url) => void — open the OAuth URL
45
+ this.ptyLoader = ptyLoader || defaultPtyLoader;
46
+ this.env = env || process.env;
47
+ this.reprobeImpl = reprobeImpl || (() => probeAgentReadiness(this.agent, undefined, this.env));
48
+ this.status = 'idle';
49
+ this.url = null;
50
+ this.error = null;
51
+ this.verdict = null;
52
+ this.proc = null;
53
+ this._buf = '';
54
+ }
55
+
56
+ async start() {
57
+ if (this.status === 'starting' || this.status === 'awaiting-browser' || this.status === 'awaiting-code') {
58
+ return this.snapshot(); // already in flight — idempotent
59
+ }
60
+ if (!this.agent || this.agent.id !== 'claude' || !this.agent.available) {
61
+ return this._fail('unsupported', 'Claude is not installed on this machine.');
62
+ }
63
+ const pty = await this.ptyLoader();
64
+ if (!pty || typeof pty.spawn !== 'function') {
65
+ return this._fail('unsupported', 'In-app sign-in is not available on this platform.');
66
+ }
67
+ const command = this.agent.resolvedPath || this.agent.binary;
68
+ this._reset();
69
+ this.status = 'starting';
70
+ try {
71
+ this.proc = pty.spawn(command, ['auth', 'login'], {
72
+ name: 'xterm-color',
73
+ cols: 100,
74
+ rows: 30,
75
+ cwd: process.cwd(),
76
+ env: { ...this.env },
77
+ });
78
+ } catch (e) {
79
+ return this._fail('error', `couldn't start sign-in: ${e?.message || e}`);
80
+ }
81
+ this.status = 'awaiting-browser';
82
+ this.proc.onData((d) => this._onData(String(d)));
83
+ this.proc.onExit((ev) => this._onExit(ev && typeof ev === 'object' ? ev.exitCode : ev));
84
+ return this.snapshot();
85
+ }
86
+
87
+ _onData(chunk) {
88
+ this._buf += chunk;
89
+ if (!this.url) {
90
+ const m = chunk.match(URL_RE) || this._buf.match(URL_RE);
91
+ if (m) {
92
+ this.url = m[1].replace(/[).,*'"]+$/, '');
93
+ if (this.openImpl) { try { this.openImpl(this.url); } catch { /* best-effort */ } }
94
+ }
95
+ }
96
+ if (CODE_HINT_RE.test(chunk) && this.status === 'awaiting-browser') {
97
+ this.status = 'awaiting-code';
98
+ }
99
+ }
100
+
101
+ async _onExit(exitCode) {
102
+ this.proc = null;
103
+ // `claude auth login` exits once the browser flow completes. The credential
104
+ // store is the source of truth — re-probe to confirm (and to learn whether
105
+ // they also have an active plan vs. need to subscribe).
106
+ let verdict;
107
+ try { verdict = await this.reprobeImpl(); } catch { verdict = { status: 'unknown' }; }
108
+ this.verdict = verdict;
109
+ if (verdict.status === 'ready' || verdict.status === 'subscribe') {
110
+ this.status = 'success';
111
+ } else if (exitCode === 0 && verdict.status !== 'login' && verdict.status !== 'missing') {
112
+ this.status = 'success';
113
+ } else {
114
+ this.status = 'error';
115
+ this.error = this.error || (exitCode === 0 ? 'sign-in did not complete' : `sign-in exited with code ${exitCode}`);
116
+ }
117
+ }
118
+
119
+ // Fallback only: relay a pasted code into the PTY if Claude ever asks for one.
120
+ submitCode(code) {
121
+ if (this.proc && this.status === 'awaiting-code' && code != null) {
122
+ try { this.proc.write(`${String(code).trim()}\r`); } catch { /* dead pty */ return false; }
123
+ this.status = 'awaiting-browser';
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+
129
+ cancel() {
130
+ if (this.proc) { try { this.proc.kill(); } catch { /* already gone */ } this.proc = null; }
131
+ if (this.status !== 'success') this.status = 'idle';
132
+ return this.snapshot();
133
+ }
134
+
135
+ _reset() { this.url = null; this.error = null; this.verdict = null; this._buf = ''; }
136
+
137
+ _fail(status, message) {
138
+ this.status = status;
139
+ this.error = message;
140
+ return this.snapshot();
141
+ }
142
+
143
+ snapshot() {
144
+ return { status: this.status, url: this.url, error: this.error, verdict: this.verdict };
145
+ }
146
+ }
@@ -26,6 +26,7 @@ import { InboxWatcher } from './inbox.mjs';
26
26
  import { ActivityBus } from './activity.mjs';
27
27
  import { loadIdentity, saveIdentity, markOnboarded, TONES } from './agent-identity.mjs';
28
28
  import { probeAgentReadiness } from './agent-readiness.mjs';
29
+ import { ClaudeLoginSession } from './agent-login.mjs';
29
30
  import { ErrorReporter } from './error-reporter.mjs';
30
31
  import { DaemonBridge } from './daemon.mjs';
31
32
  import { DaemonSupervisor } from './daemon-supervisor.mjs';
@@ -578,6 +579,39 @@ export async function createServer(overrides = {}) {
578
579
  return c.json({ agent: agentTag(activeAgent), ...verdict });
579
580
  });
580
581
 
582
+ // In-app "Sign in to Claude" — drives `claude auth login` in a real PTY so the
583
+ // browser OAuth callback auto-completes and the user never touches a terminal.
584
+ // (See agent-login.mjs.) Degrades to `{status:'unsupported'}` if node-pty is
585
+ // absent, and the gate falls back to the terminal instruction. The OAuth URL is
586
+ // opened on this machine — the server runs locally, so it's the user's browser.
587
+ let _loginSession = null;
588
+ const openUrl = async (u) => {
589
+ try { const open = (await import('open')).default; await open(u); } catch { /* best-effort */ }
590
+ };
591
+ const emptyLoginSnap = { status: 'idle', url: null, error: null, verdict: null };
592
+ app.post('/api/agent/login/start', async (c) => {
593
+ const forbidden = require(c, 'chatWrite');
594
+ if (forbidden) return forbidden;
595
+ if (!_loginSession) _loginSession = new ClaudeLoginSession({ agent: activeAgent, openImpl: openUrl });
596
+ _loginSession.agent = activeAgent; // track the active agent if it changed
597
+ const snap = await _loginSession.start();
598
+ _readinessCache = null; // a sign-in is about to change auth state — don't serve stale
599
+ log('[onboarding]', `login start status=${snap.status}`);
600
+ return c.json(snap);
601
+ });
602
+ app.get('/api/agent/login/status', async (c) => {
603
+ const forbidden = require(c, 'chat');
604
+ if (forbidden) return forbidden;
605
+ return c.json(_loginSession ? _loginSession.snapshot() : emptyLoginSnap);
606
+ });
607
+ app.post('/api/agent/login/code', async (c) => {
608
+ const forbidden = require(c, 'chatWrite');
609
+ if (forbidden) return forbidden;
610
+ const body = await c.req.json().catch(() => ({}));
611
+ const ok = _loginSession ? _loginSession.submitCode(body.code) : false;
612
+ return c.json({ ok, ...(_loginSession ? _loginSession.snapshot() : emptyLoginSnap) });
613
+ });
614
+
581
615
  app.post('/api/agent/identity', async (c) => {
582
616
  const forbidden = require(c, 'chatWrite');
583
617
  if (forbidden) return forbidden;
@@ -1294,7 +1328,14 @@ export async function createServer(overrides = {}) {
1294
1328
  // The daemon is deliberately NOT stopped here — it is detached so sync
1295
1329
  // keeps running after wild-workspace closes. `wild-workspace daemon
1296
1330
  // stop` is the explicit off-switch.
1331
+ // Terminate live WebSockets first — wss.close() stops the server accepting
1332
+ // new connections but leaves existing client sockets open, and those keep
1333
+ // httpServer.close() hanging forever ("stuck shutting down" on Ctrl+C).
1334
+ try { wss.clients.forEach((c) => { try { c.terminate(); } catch {} }); } catch {}
1297
1335
  try { wss.close(); } catch {}
1336
+ // Drop lingering keep-alive HTTP sockets too (Node 18.2+) so close resolves
1337
+ // promptly instead of waiting on idle browser connections.
1338
+ try { httpServer.closeAllConnections?.(); } catch {}
1298
1339
  await new Promise((resolve) => httpServer.close(resolve));
1299
1340
  },
1300
1341
  };