@venturewild/workspace 0.1.4 → 0.1.6
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.
|
|
3
|
+
"version": "0.1.6",
|
|
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",
|
|
@@ -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
|
+
}
|
package/server/src/index.mjs
CHANGED
|
@@ -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;
|
package/server/src/service.mjs
CHANGED
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
//
|
|
8
8
|
// macOS (code-complete + unit-tested 2026-06-01; real-Mac reboot proof pending):
|
|
9
9
|
// writes a `~/Library/LaunchAgents/<label>.plist` (RunAtLoad + KeepAlive +
|
|
10
|
-
// ThrottleInterval)
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// ThrottleInterval). Install does NOT start it now (no `launchctl bootstrap`) —
|
|
11
|
+
// launchd auto-loads the plist at the NEXT login, mirroring the Windows HKCU\Run
|
|
12
|
+
// model. (Starting it at install time would grab :5173 and collide with the
|
|
13
|
+
// `wild-workspace` the user runs this session.) Uninstall uses `launchctl bootout`
|
|
14
|
+
// to stop any instance loaded from a prior login. Runs as the user, NO admin —
|
|
15
|
+
// macOS 13+ shows a one-time "Background item added" toast + a Login Items toggle
|
|
16
|
+
// (consent, not admin). launchd provides crash-restart for free; the supervisor
|
|
17
|
+
// still owns the singleton lock + child watchdog.
|
|
15
18
|
//
|
|
16
19
|
// Linux (systemd --user) is designed but not yet implemented — it returns a clear
|
|
17
20
|
// "not yet" result so callers degrade gracefully (the user runs `wild-workspace`).
|
|
@@ -160,7 +163,7 @@ export function buildPlist({ node, cli, workspaceDir, outLog, errLog, label = LA
|
|
|
160
163
|
].join('\n');
|
|
161
164
|
}
|
|
162
165
|
|
|
163
|
-
async function macInstall({ node, cli, workspaceDir, port, version }, { dir, launchAgentsDir
|
|
166
|
+
async function macInstall({ node, cli, workspaceDir, port, version }, { dir, launchAgentsDir }) {
|
|
164
167
|
fs.mkdirSync(dir, { recursive: true });
|
|
165
168
|
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
166
169
|
const plist = plistPath(launchAgentsDir);
|
|
@@ -173,20 +176,15 @@ async function macInstall({ node, cli, workspaceDir, port, version }, { dir, lau
|
|
|
173
176
|
JSON.stringify({ node, cli, workspaceDir, port, version, installedAt: new Date().toISOString() }, null, 2),
|
|
174
177
|
'utf8',
|
|
175
178
|
);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
// the
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// Pre-Yosemite-style macOS without `bootstrap` — fall back to the legacy verb.
|
|
186
|
-
await execFileImpl('launchctl', ['load', '-w', plist]);
|
|
187
|
-
loadVerb = 'load';
|
|
188
|
-
}
|
|
189
|
-
return { installed: true, mechanism: 'LaunchAgent', launcher: plist, plist, label: LAUNCHD_LABEL, runValue: target, serviceJson, loadVerb };
|
|
179
|
+
// Deliberately do NOT `launchctl bootstrap` here. bootstrap + RunAtLoad would
|
|
180
|
+
// start the supervisor immediately, grabbing :5173 — then the `wild-workspace`
|
|
181
|
+
// the user runs *this session* collides on the port (createServer rejects on
|
|
182
|
+
// EADDRINUSE). Dropping the plist into ~/Library/LaunchAgents is enough:
|
|
183
|
+
// launchd auto-loads it at the NEXT login (RunAtLoad fires then). This mirrors
|
|
184
|
+
// the proven Windows HKCU\Run model, which also only fires at login — so the
|
|
185
|
+
// current session is the manual `wild-workspace`, and always-on takes over
|
|
186
|
+
// from the next login/reboot (which is also the cleanest B5 proof).
|
|
187
|
+
return { installed: true, mechanism: 'LaunchAgent', launcher: plist, plist, label: LAUNCHD_LABEL, runValue: plist, serviceJson, startsAtNextLogin: true };
|
|
190
188
|
}
|
|
191
189
|
|
|
192
190
|
async function macUninstall({ dir, launchAgentsDir, execFileImpl, killImpl, uid }) {
|
|
@@ -245,8 +243,6 @@ export async function installService(opts = {}, deps = {}) {
|
|
|
245
243
|
return macInstall(opts, {
|
|
246
244
|
dir: deps.dir || globalDir(),
|
|
247
245
|
launchAgentsDir: deps.launchAgentsDir || defaultLaunchAgentsDir(),
|
|
248
|
-
execFileImpl: deps.execFileImpl || execFileP,
|
|
249
|
-
uid: deps.uid ?? currentUid(),
|
|
250
246
|
});
|
|
251
247
|
}
|
|
252
248
|
return unsupported(platform, 'installed');
|