@venturewild/workspace 0.1.0
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/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +69 -0
- package/server/bin/wild-workspace.mjs +95 -0
- package/server/src/activity.mjs +71 -0
- package/server/src/agent.mjs +335 -0
- package/server/src/config.mjs +236 -0
- package/server/src/daemon-bin.mjs +66 -0
- package/server/src/daemon.mjs +178 -0
- package/server/src/fs.mjs +136 -0
- package/server/src/inbox.mjs +81 -0
- package/server/src/index.mjs +635 -0
- package/server/src/preview.mjs +31 -0
- package/server/src/share.mjs +80 -0
- package/server/src/sync.mjs +176 -0
- package/web/dist/assets/index-DOwej8U4.js +89 -0
- package/web/dist/assets/index-DZkyDo10.css +1 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Central config + role definitions.
|
|
2
|
+
// One UI permission-flagged per AR-19.
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
// Secrets that shipped as scaffold defaults. Forgeable — never allowed on a
|
|
13
|
+
// non-localhost bind. Kept only so the startup guard can recognise them.
|
|
14
|
+
export const WEAK_SECRETS = Object.freeze(
|
|
15
|
+
new Set(['dev-partner-token', 'dev-share-secret-change-me']),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const LOCAL_HOSTS = Object.freeze(new Set(['127.0.0.1', 'localhost', '::1']));
|
|
19
|
+
|
|
20
|
+
export function isLocalhost(host) {
|
|
21
|
+
return LOCAL_HOSTS.has(host);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The bmo-sync daemon's HTTP origin, derived from its WebSocket event-feed
|
|
25
|
+
// URL so the two stay consistent when WILD_WORKSPACE_DAEMON_URL is overridden.
|
|
26
|
+
function daemonHttpBase(wsUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(wsUrl);
|
|
29
|
+
return `${u.protocol === 'wss:' ? 'https:' : 'http:'}//${u.host}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return 'http://127.0.0.1:8320';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Per-install secrets. Generated once, persisted to <dataDir>/secrets.json
|
|
36
|
+
// (the .wild-workspace dir is gitignored). Replaces the weak scaffold defaults
|
|
37
|
+
// so share tokens can't be forged. (Concern C2.)
|
|
38
|
+
function loadOrCreateSecrets(dataDir) {
|
|
39
|
+
const secretsPath = path.join(dataDir, 'secrets.json');
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
|
|
42
|
+
if (parsed.partnerToken && parsed.shareSecret) return parsed;
|
|
43
|
+
} catch {
|
|
44
|
+
// missing / unreadable / malformed — fall through and regenerate
|
|
45
|
+
}
|
|
46
|
+
const generated = {
|
|
47
|
+
partnerToken: crypto.randomBytes(24).toString('base64url'),
|
|
48
|
+
shareSecret: crypto.randomBytes(32).toString('base64url'),
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
52
|
+
fs.writeFileSync(secretsPath, JSON.stringify(generated, null, 2), { mode: 0o600 });
|
|
53
|
+
} catch {
|
|
54
|
+
// can't persist (read-only fs?) — still use the generated pair for this run
|
|
55
|
+
}
|
|
56
|
+
return generated;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Refuse to start in public mode with a forgeable secret. (Concerns C1/C2.)
|
|
60
|
+
export function assertSecureBinding(config) {
|
|
61
|
+
if (!config.publicMode) return;
|
|
62
|
+
if (WEAK_SECRETS.has(config.partnerToken) || WEAK_SECRETS.has(config.shareSecret)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Refusing to run in public mode with a default secret. ` +
|
|
65
|
+
`Set WILD_WORKSPACE_PARTNER_TOKEN and WILD_WORKSPACE_SHARE_SECRET, ` +
|
|
66
|
+
`or remove them so per-install secrets are generated.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const ROLES = Object.freeze({
|
|
72
|
+
PARTNER: 'partner',
|
|
73
|
+
VIEWER: 'viewer',
|
|
74
|
+
CLIENT: 'client',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const ROLE_CAPABILITIES = Object.freeze({
|
|
78
|
+
partner: {
|
|
79
|
+
chat: true,
|
|
80
|
+
chatWrite: true,
|
|
81
|
+
preview: true,
|
|
82
|
+
fileTree: true,
|
|
83
|
+
terminal: true,
|
|
84
|
+
inbox: true,
|
|
85
|
+
share: true,
|
|
86
|
+
sync: true,
|
|
87
|
+
deploy: true,
|
|
88
|
+
requestChanges: false,
|
|
89
|
+
},
|
|
90
|
+
viewer: {
|
|
91
|
+
chat: true,
|
|
92
|
+
chatWrite: false,
|
|
93
|
+
preview: true,
|
|
94
|
+
fileTree: false,
|
|
95
|
+
terminal: false,
|
|
96
|
+
inbox: false,
|
|
97
|
+
share: false,
|
|
98
|
+
sync: false,
|
|
99
|
+
deploy: false,
|
|
100
|
+
requestChanges: false,
|
|
101
|
+
},
|
|
102
|
+
client: {
|
|
103
|
+
chat: true,
|
|
104
|
+
chatWrite: true,
|
|
105
|
+
preview: true,
|
|
106
|
+
fileTree: false,
|
|
107
|
+
terminal: false,
|
|
108
|
+
inbox: false,
|
|
109
|
+
share: false,
|
|
110
|
+
sync: false,
|
|
111
|
+
deploy: false,
|
|
112
|
+
requestChanges: true,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const DEFAULT_AGENTS = Object.freeze([
|
|
117
|
+
{
|
|
118
|
+
id: 'claude',
|
|
119
|
+
binary: 'claude',
|
|
120
|
+
label: 'Claude Code',
|
|
121
|
+
description: 'Anthropic Claude Code',
|
|
122
|
+
args: ['-p', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'],
|
|
123
|
+
streamFormat: 'claude-stream-json',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
id: 'gemini',
|
|
127
|
+
binary: 'gemini',
|
|
128
|
+
label: 'Gemini CLI',
|
|
129
|
+
description: 'Google Gemini',
|
|
130
|
+
args: ['-p'],
|
|
131
|
+
streamFormat: 'text',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'glm',
|
|
135
|
+
binary: 'glm',
|
|
136
|
+
label: 'GLM (Z.AI)',
|
|
137
|
+
description: 'GLM-4.6 via Z.AI',
|
|
138
|
+
args: ['-p', '--permission-mode', 'bypassPermissions'],
|
|
139
|
+
streamFormat: 'text',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'codex',
|
|
143
|
+
binary: 'codex',
|
|
144
|
+
label: 'Codex (OpenAI)',
|
|
145
|
+
description: 'GPT-5 / o3 via Codex CLI',
|
|
146
|
+
args: ['exec', '--skip-git-repo-check'],
|
|
147
|
+
streamFormat: 'text',
|
|
148
|
+
},
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
export function buildConfig(overrides = {}) {
|
|
152
|
+
const env = process.env;
|
|
153
|
+
const portOverride = overrides.port;
|
|
154
|
+
const resolvedPort =
|
|
155
|
+
typeof portOverride === 'number'
|
|
156
|
+
? portOverride
|
|
157
|
+
: Number(env.WILD_WORKSPACE_PORT || 5173);
|
|
158
|
+
const workspaceDir = path.resolve(
|
|
159
|
+
overrides.workspaceDir || env.WILD_WORKSPACE_DIR || process.cwd(),
|
|
160
|
+
);
|
|
161
|
+
const dataDir = path.resolve(
|
|
162
|
+
overrides.dataDir ||
|
|
163
|
+
env.WILD_WORKSPACE_DATA_DIR ||
|
|
164
|
+
path.join(workspaceDir, '.wild-workspace'),
|
|
165
|
+
);
|
|
166
|
+
// Lazy: only load/generate persisted secrets if neither an override nor an
|
|
167
|
+
// env var supplies one — keeps tests that pass both from touching the fs.
|
|
168
|
+
let _secrets = null;
|
|
169
|
+
const secrets = () => (_secrets ??= loadOrCreateSecrets(dataDir));
|
|
170
|
+
const host = overrides.host || env.WILD_WORKSPACE_HOST || '127.0.0.1';
|
|
171
|
+
// publicMode = "treat every request as untrusted". True for a non-localhost
|
|
172
|
+
// bind, OR when WILD_WORKSPACE_PUBLIC=1 — needed when a tunnel (Cloudflare
|
|
173
|
+
// etc.) forwards public traffic to a localhost-bound server, since the bind
|
|
174
|
+
// address alone would otherwise look local. Drives the C1 auth posture.
|
|
175
|
+
const publicMode =
|
|
176
|
+
overrides.publicMode ??
|
|
177
|
+
(env.WILD_WORKSPACE_PUBLIC === '1' || !isLocalhost(host));
|
|
178
|
+
// bmo-sync: the local daemon's event feed (a WebSocket URL).
|
|
179
|
+
const daemonUrl =
|
|
180
|
+
overrides.daemonUrl ||
|
|
181
|
+
env.WILD_WORKSPACE_DAEMON_URL ||
|
|
182
|
+
'ws://127.0.0.1:8320/api/events';
|
|
183
|
+
return {
|
|
184
|
+
port: resolvedPort,
|
|
185
|
+
host,
|
|
186
|
+
publicMode,
|
|
187
|
+
workspaceDir,
|
|
188
|
+
dataDir,
|
|
189
|
+
webDir:
|
|
190
|
+
overrides.webDir ||
|
|
191
|
+
env.WILD_WORKSPACE_WEB_DIR ||
|
|
192
|
+
path.resolve(__dirname, '..', '..', 'web', 'dist'),
|
|
193
|
+
openBrowser: overrides.openBrowser ?? env.WILD_WORKSPACE_NO_OPEN !== '1',
|
|
194
|
+
shareBaseUrl:
|
|
195
|
+
overrides.shareBaseUrl ||
|
|
196
|
+
env.WILD_WORKSPACE_SHARE_BASE_URL ||
|
|
197
|
+
`http://${host}:${resolvedPort}`,
|
|
198
|
+
partnerToken:
|
|
199
|
+
overrides.partnerToken ||
|
|
200
|
+
env.WILD_WORKSPACE_PARTNER_TOKEN ||
|
|
201
|
+
secrets().partnerToken,
|
|
202
|
+
shareSecret:
|
|
203
|
+
overrides.shareSecret ||
|
|
204
|
+
env.WILD_WORKSPACE_SHARE_SECRET ||
|
|
205
|
+
secrets().shareSecret,
|
|
206
|
+
workspaceId:
|
|
207
|
+
overrides.workspaceId ||
|
|
208
|
+
env.WILD_WORKSPACE_ID ||
|
|
209
|
+
path.basename(workspaceDir) ||
|
|
210
|
+
'workspace',
|
|
211
|
+
role: overrides.role || env.WILD_WORKSPACE_ROLE || ROLES.PARTNER,
|
|
212
|
+
// bmo-sync daemon — a separate local process; the bridge retries quietly
|
|
213
|
+
// when it is absent. daemonUrl is the WebSocket event feed; daemonHttpUrl
|
|
214
|
+
// is the same origin's HTTP API (pair / detach / list).
|
|
215
|
+
daemonUrl,
|
|
216
|
+
daemonHttpUrl: daemonHttpBase(daemonUrl),
|
|
217
|
+
// Central bmo-sync server (Fly.io). Used to redeem invites (via the
|
|
218
|
+
// daemon) and — only when an admin key is set — to mint them.
|
|
219
|
+
bmoSyncServerUrl:
|
|
220
|
+
overrides.bmoSyncServerUrl ||
|
|
221
|
+
env.WILD_WORKSPACE_BMO_SYNC_URL ||
|
|
222
|
+
env.BMO_SYNC_URL ||
|
|
223
|
+
'https://sync.venturewild.llc',
|
|
224
|
+
// Optional. Present only on an install that mints invites (the folder
|
|
225
|
+
// owner). Absent installs can still redeem. Never sent to the browser.
|
|
226
|
+
bmoSyncAdminKey:
|
|
227
|
+
overrides.bmoSyncAdminKey ||
|
|
228
|
+
env.BMO_SYNC_ADMIN_KEY ||
|
|
229
|
+
env.WILD_WORKSPACE_BMO_ADMIN_KEY ||
|
|
230
|
+
null,
|
|
231
|
+
home: os.homedir(),
|
|
232
|
+
nodeEnv: env.NODE_ENV || 'production',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const APP_VERSION = '0.1.0';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Locates the bmo-sync-daemon binary for the current platform.
|
|
2
|
+
//
|
|
3
|
+
// wild-workspace ships the daemon as a per-platform npm subpackage
|
|
4
|
+
// (@venturewild/workspace-daemon-<tag>, biome pattern). The resolver walks a
|
|
5
|
+
// lookup chain so it works both in development (a locally built binary in
|
|
6
|
+
// vendor/ or on PATH) and in a published install (the platform subpackage).
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import url from 'node:url';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/** `<platform>-<arch>` token, e.g. `win32-x64`, `darwin-arm64`, `linux-x64`. */
|
|
17
|
+
export function platformTag() {
|
|
18
|
+
return `${process.platform}-${process.arch}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Daemon binary file name for the current platform. */
|
|
22
|
+
export function daemonBinaryName() {
|
|
23
|
+
return process.platform === 'win32' ? 'bmo-sync-daemon.exe' : 'bmo-sync-daemon';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the daemon binary. Lookup order:
|
|
28
|
+
* 1. WILD_WORKSPACE_DAEMON_BIN — explicit override (must exist, or null).
|
|
29
|
+
* 2. the per-platform npm subpackage `@venturewild/workspace-daemon-<tag>`.
|
|
30
|
+
* 3. a local `vendor/bmo-sync-daemon-<tag>/<bin>` dir (dev builds land here).
|
|
31
|
+
* 4. the bare binary name — let the OS resolve it on PATH at spawn time.
|
|
32
|
+
*
|
|
33
|
+
* Returns `{ path, source }`. `source` is one of `env` | `subpackage` |
|
|
34
|
+
* `vendor` | `path`. Returns `null` only when an explicit override is set
|
|
35
|
+
* but missing — every other case falls through to the PATH last resort.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ env?: NodeJS.ProcessEnv, vendorRoot?: string }} [opts]
|
|
38
|
+
*/
|
|
39
|
+
export function resolveDaemonBinary({ env = process.env, vendorRoot } = {}) {
|
|
40
|
+
const binName = daemonBinaryName();
|
|
41
|
+
const tag = platformTag();
|
|
42
|
+
|
|
43
|
+
// 1. explicit override — if set but missing, that's an error, not a miss.
|
|
44
|
+
const override = env.WILD_WORKSPACE_DAEMON_BIN;
|
|
45
|
+
if (override) {
|
|
46
|
+
return existsSync(override) ? { path: override, source: 'env' } : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 2. per-platform npm subpackage — resolve via its package.json so the
|
|
50
|
+
// lookup doesn't depend on an `exports` map for the binary file.
|
|
51
|
+
try {
|
|
52
|
+
const pkgJson = require.resolve(`@venturewild/workspace-daemon-${tag}/package.json`);
|
|
53
|
+
const candidate = path.join(path.dirname(pkgJson), binName);
|
|
54
|
+
if (existsSync(candidate)) return { path: candidate, source: 'subpackage' };
|
|
55
|
+
} catch {
|
|
56
|
+
// subpackage not installed — fall through
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 3. local vendor dir (a dev build of the daemon drops the binary here).
|
|
60
|
+
const root = vendorRoot || path.resolve(__dirname, '..', '..', 'vendor');
|
|
61
|
+
const vendor = path.join(root, `bmo-sync-daemon-${tag}`, binName);
|
|
62
|
+
if (existsSync(vendor)) return { path: vendor, source: 'vendor' };
|
|
63
|
+
|
|
64
|
+
// 4. last resort — spawn by name and let PATH resolve it.
|
|
65
|
+
return { path: binName, source: 'path' };
|
|
66
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Bridge to the bmo-sync daemon's event feed.
|
|
2
|
+
//
|
|
3
|
+
// The daemon (a separate Rust process) broadcasts SyncEvents over a
|
|
4
|
+
// WebSocket. This bridge:
|
|
5
|
+
// - republishes daemon SyncEvents (conflict / fatal / presence) into the
|
|
6
|
+
// in-process ActivityBus, so they reach browsers over /ws/activity;
|
|
7
|
+
// - pushes this machine's local presence (focus changes on /ws/activity)
|
|
8
|
+
// up to the daemon, which relays it to peers via the central server.
|
|
9
|
+
//
|
|
10
|
+
// The daemon may not be running — the bridge retries with backoff, and every
|
|
11
|
+
// outbound call is best-effort and never throws into the server.
|
|
12
|
+
|
|
13
|
+
import WebSocket from 'ws';
|
|
14
|
+
|
|
15
|
+
const INITIAL_RECONNECT_MS = 1000;
|
|
16
|
+
const MAX_RECONNECT_MS = 30000;
|
|
17
|
+
|
|
18
|
+
export class DaemonBridge {
|
|
19
|
+
constructor(activityBus, { url } = {}) {
|
|
20
|
+
this.activityBus = activityBus;
|
|
21
|
+
this.url = url || 'ws://127.0.0.1:8320/api/events';
|
|
22
|
+
this.httpBase = httpBaseFrom(this.url);
|
|
23
|
+
this.ws = null;
|
|
24
|
+
this.stopped = false;
|
|
25
|
+
this.connected = false;
|
|
26
|
+
this.reconnectMs = INITIAL_RECONNECT_MS;
|
|
27
|
+
this.reconnectTimer = null;
|
|
28
|
+
// The daemon's id for the workspace — discovered on connect; needed to
|
|
29
|
+
// address presence reports.
|
|
30
|
+
this.workspaceId = null;
|
|
31
|
+
this._onBusEvent = (e) => this._forwardLocalPresence(e);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
start() {
|
|
35
|
+
this.stopped = false;
|
|
36
|
+
this.activityBus.on('event', this._onBusEvent);
|
|
37
|
+
this._connect();
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stop() {
|
|
42
|
+
this.stopped = true;
|
|
43
|
+
this.activityBus.off('event', this._onBusEvent);
|
|
44
|
+
if (this.reconnectTimer) {
|
|
45
|
+
clearTimeout(this.reconnectTimer);
|
|
46
|
+
this.reconnectTimer = null;
|
|
47
|
+
}
|
|
48
|
+
if (this.ws) {
|
|
49
|
+
try {
|
|
50
|
+
this.ws.terminate();
|
|
51
|
+
} catch {}
|
|
52
|
+
this.ws = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
_connect() {
|
|
57
|
+
if (this.stopped) return;
|
|
58
|
+
let ws;
|
|
59
|
+
try {
|
|
60
|
+
ws = new WebSocket(this.url);
|
|
61
|
+
} catch {
|
|
62
|
+
this._scheduleReconnect();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.ws = ws;
|
|
66
|
+
ws.on('open', () => {
|
|
67
|
+
this.connected = true;
|
|
68
|
+
this.reconnectMs = INITIAL_RECONNECT_MS;
|
|
69
|
+
this.activityBus.publish({ type: 'daemon-status', connected: true });
|
|
70
|
+
void this._discoverWorkspace();
|
|
71
|
+
});
|
|
72
|
+
ws.on('message', (raw) => this._onMessage(raw));
|
|
73
|
+
ws.on('close', () => this._onDown());
|
|
74
|
+
// 'error' (e.g. connection refused) is always followed by 'close'.
|
|
75
|
+
ws.on('error', () => {});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_onDown() {
|
|
79
|
+
if (this.connected) {
|
|
80
|
+
this.connected = false;
|
|
81
|
+
this.activityBus.publish({ type: 'daemon-status', connected: false });
|
|
82
|
+
}
|
|
83
|
+
this.workspaceId = null;
|
|
84
|
+
this.ws = null;
|
|
85
|
+
this._scheduleReconnect();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
_scheduleReconnect() {
|
|
89
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
90
|
+
this.reconnectTimer = setTimeout(() => {
|
|
91
|
+
this.reconnectTimer = null;
|
|
92
|
+
this._connect();
|
|
93
|
+
}, this.reconnectMs);
|
|
94
|
+
this.reconnectMs = Math.min(this.reconnectMs * 2, MAX_RECONNECT_MS);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Learn the daemon's workspace id (needed to address presence reports).
|
|
98
|
+
// v1: a daemon syncs one workspace for the trial — take the first.
|
|
99
|
+
async _discoverWorkspace() {
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch(`${this.httpBase}/api/workspaces`);
|
|
102
|
+
if (!res.ok) return;
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
const first = Array.isArray(data.workspaces) ? data.workspaces[0] : null;
|
|
105
|
+
if (first && first.workspaceId) this.workspaceId = first.workspaceId;
|
|
106
|
+
} catch {
|
|
107
|
+
// daemon unreachable — presence push stays disabled until reconnect
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Local presence (a /ws/activity session joined or changed focus) — relay
|
|
112
|
+
// it to the daemon. `remote-presence` events (our own republished output)
|
|
113
|
+
// are deliberately not matched here, so there is no echo loop.
|
|
114
|
+
_forwardLocalPresence(e) {
|
|
115
|
+
if (!e || (e.type !== 'presence-focus' && e.type !== 'presence-join')) return;
|
|
116
|
+
const focus = typeof e.focus === 'string' ? e.focus : null;
|
|
117
|
+
void this.reportPresence({ status: 'viewing', focus });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// POST a presence update to the daemon, which relays it to peers.
|
|
121
|
+
async reportPresence({ status, focus }) {
|
|
122
|
+
if (!this.connected || !this.workspaceId) return;
|
|
123
|
+
try {
|
|
124
|
+
await fetch(`${this.httpBase}/api/presence`, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'content-type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ workspaceId: this.workspaceId, status, focus }),
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// daemon unreachable — best-effort
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Map a daemon SyncEvent (tagged by `kind`) into an ActivityBus event.
|
|
135
|
+
_onMessage(raw) {
|
|
136
|
+
let msg;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(raw.toString());
|
|
139
|
+
} catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!msg || typeof msg.kind !== 'string') return;
|
|
143
|
+
if (msg.kind === 'conflict') {
|
|
144
|
+
this.activityBus.publish({
|
|
145
|
+
type: 'sync-conflict',
|
|
146
|
+
workspaceId: msg.workspaceId,
|
|
147
|
+
path: msg.path,
|
|
148
|
+
resolution: msg.resolution,
|
|
149
|
+
conflictingUser: msg.conflictingUser,
|
|
150
|
+
});
|
|
151
|
+
} else if (msg.kind === 'fatal') {
|
|
152
|
+
this.activityBus.publish({
|
|
153
|
+
type: 'sync-fatal',
|
|
154
|
+
workspaceId: msg.workspaceId,
|
|
155
|
+
message: msg.message,
|
|
156
|
+
});
|
|
157
|
+
} else if (msg.kind === 'presence') {
|
|
158
|
+
this.activityBus.publish({
|
|
159
|
+
type: 'remote-presence',
|
|
160
|
+
workspaceId: msg.workspaceId,
|
|
161
|
+
user: msg.user,
|
|
162
|
+
status: msg.status,
|
|
163
|
+
focus: msg.focus ?? null,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Derive the daemon's HTTP origin from its WebSocket event-feed URL.
|
|
170
|
+
function httpBaseFrom(wsUrl) {
|
|
171
|
+
try {
|
|
172
|
+
const u = new URL(wsUrl);
|
|
173
|
+
const proto = u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
174
|
+
return `${proto}//${u.host}`;
|
|
175
|
+
} catch {
|
|
176
|
+
return 'http://127.0.0.1:8320';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Workspace file tree + read endpoints. Read-only for v1.
|
|
2
|
+
// Collapsed-by-default surface per AR-16; the agent does the editing.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import { existsSync, statSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const IGNORED_DIRS = new Set([
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.git',
|
|
11
|
+
'.next',
|
|
12
|
+
'dist',
|
|
13
|
+
'build',
|
|
14
|
+
'target',
|
|
15
|
+
'.turbo',
|
|
16
|
+
'.parcel-cache',
|
|
17
|
+
'.vite',
|
|
18
|
+
'coverage',
|
|
19
|
+
'.cache',
|
|
20
|
+
'.pnpm-store',
|
|
21
|
+
'.yarn',
|
|
22
|
+
'.idea',
|
|
23
|
+
'.vscode',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const MAX_TREE_ENTRIES = 5000;
|
|
27
|
+
const MAX_FILE_BYTES = 512 * 1024; // 512 KB read cap
|
|
28
|
+
|
|
29
|
+
export function isInside(parent, child) {
|
|
30
|
+
const rel = path.relative(parent, child);
|
|
31
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function safeResolve(workspaceDir, requested) {
|
|
35
|
+
if (!requested || requested === '/' || requested === '') return workspaceDir;
|
|
36
|
+
const cleaned = requested.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
37
|
+
const resolved = path.resolve(workspaceDir, cleaned);
|
|
38
|
+
if (resolved === workspaceDir || isInside(workspaceDir, resolved)) {
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function listDir(workspaceDir, requested = '') {
|
|
45
|
+
const target = safeResolve(workspaceDir, requested);
|
|
46
|
+
if (!target) throw new Error('outside workspace');
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await fs.readdir(target, { withFileTypes: true });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code === 'ENOTDIR') return null;
|
|
52
|
+
if (e.code === 'ENOENT') return [];
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
return entries
|
|
56
|
+
.filter((entry) => !(entry.isDirectory() && IGNORED_DIRS.has(entry.name)))
|
|
57
|
+
.map((entry) => ({
|
|
58
|
+
name: entry.name,
|
|
59
|
+
type: entry.isDirectory() ? 'dir' : entry.isFile() ? 'file' : 'other',
|
|
60
|
+
path: path.relative(workspaceDir, path.join(target, entry.name)).replace(/\\/g, '/'),
|
|
61
|
+
}))
|
|
62
|
+
.sort((a, b) => {
|
|
63
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
64
|
+
return a.name.localeCompare(b.name);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readFile(workspaceDir, requested) {
|
|
69
|
+
const target = safeResolve(workspaceDir, requested);
|
|
70
|
+
if (!target) throw new Error('outside workspace');
|
|
71
|
+
const stat = await fs.stat(target);
|
|
72
|
+
if (stat.isDirectory()) throw new Error('is a directory');
|
|
73
|
+
if (stat.size > MAX_FILE_BYTES) {
|
|
74
|
+
const fd = await fs.open(target, 'r');
|
|
75
|
+
try {
|
|
76
|
+
const buf = Buffer.alloc(MAX_FILE_BYTES);
|
|
77
|
+
await fd.read(buf, 0, MAX_FILE_BYTES, 0);
|
|
78
|
+
return { content: buf.toString('utf8'), truncated: true, size: stat.size };
|
|
79
|
+
} finally {
|
|
80
|
+
await fd.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const content = await fs.readFile(target, 'utf8');
|
|
84
|
+
return { content, truncated: false, size: stat.size };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function fullTree(workspaceDir, maxDepth = 3) {
|
|
88
|
+
let count = 0;
|
|
89
|
+
async function walk(dir, depth) {
|
|
90
|
+
if (depth > maxDepth || count > MAX_TREE_ENTRIES) return [];
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue;
|
|
100
|
+
count += 1;
|
|
101
|
+
if (count > MAX_TREE_ENTRIES) break;
|
|
102
|
+
const full = path.join(dir, entry.name);
|
|
103
|
+
const rel = path.relative(workspaceDir, full).replace(/\\/g, '/');
|
|
104
|
+
const node = {
|
|
105
|
+
name: entry.name,
|
|
106
|
+
type: entry.isDirectory() ? 'dir' : 'file',
|
|
107
|
+
path: rel,
|
|
108
|
+
};
|
|
109
|
+
if (entry.isDirectory()) {
|
|
110
|
+
node.children = await walk(full, depth + 1);
|
|
111
|
+
}
|
|
112
|
+
out.push(node);
|
|
113
|
+
}
|
|
114
|
+
return out.sort((a, b) => {
|
|
115
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
116
|
+
return a.name.localeCompare(b.name);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return walk(workspaceDir, 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function workspaceSummary(workspaceDir) {
|
|
123
|
+
try {
|
|
124
|
+
const stat = statSync(workspaceDir);
|
|
125
|
+
if (!stat.isDirectory()) return null;
|
|
126
|
+
return {
|
|
127
|
+
path: workspaceDir,
|
|
128
|
+
name: path.basename(workspaceDir),
|
|
129
|
+
exists: true,
|
|
130
|
+
hasClaudeMd: existsSync(path.join(workspaceDir, 'CLAUDE.md')),
|
|
131
|
+
hasWildInbox: existsSync(path.join(workspaceDir, '.wild', 'inbox.md')),
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return { path: workspaceDir, name: path.basename(workspaceDir), exists: false };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Component System inbox surfacing.
|
|
2
|
+
// When `.wild/inbox.md` exists or changes, emit notifications.
|
|
3
|
+
// Drives the "I noticed you imported X — want me to walk integration?" cue in chat.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import chokidar from 'chokidar';
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
|
|
11
|
+
export class InboxWatcher extends EventEmitter {
|
|
12
|
+
constructor(workspaceDir) {
|
|
13
|
+
super();
|
|
14
|
+
this.workspaceDir = workspaceDir;
|
|
15
|
+
this.inboxPath = path.join(workspaceDir, '.wild', 'inbox.md');
|
|
16
|
+
this.installedPath = path.join(workspaceDir, '.wild', 'installed.json');
|
|
17
|
+
this.watcher = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
start() {
|
|
21
|
+
this.watcher = chokidar.watch(
|
|
22
|
+
[this.inboxPath, this.installedPath, path.join(this.workspaceDir, '.wild', 'imports')],
|
|
23
|
+
{
|
|
24
|
+
ignoreInitial: false,
|
|
25
|
+
persistent: true,
|
|
26
|
+
depth: 3,
|
|
27
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 100 },
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
this.watcher.on('add', () => this._refresh('add'));
|
|
31
|
+
this.watcher.on('change', () => this._refresh('change'));
|
|
32
|
+
this.watcher.on('unlink', () => this._refresh('unlink'));
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async _refresh(kind) {
|
|
37
|
+
const snapshot = await this.snapshot();
|
|
38
|
+
this.emit('change', { kind, snapshot });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async snapshot() {
|
|
42
|
+
const out = {
|
|
43
|
+
hasInbox: existsSync(this.inboxPath),
|
|
44
|
+
inboxContent: '',
|
|
45
|
+
installed: {},
|
|
46
|
+
imports: [],
|
|
47
|
+
};
|
|
48
|
+
if (out.hasInbox) {
|
|
49
|
+
try {
|
|
50
|
+
out.inboxContent = await fs.readFile(this.inboxPath, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
out.inboxContent = '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (existsSync(this.installedPath)) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(this.installedPath, 'utf8');
|
|
58
|
+
out.installed = JSON.parse(raw);
|
|
59
|
+
} catch {
|
|
60
|
+
out.installed = {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const importsDir = path.join(this.workspaceDir, '.wild', 'imports');
|
|
64
|
+
if (existsSync(importsDir)) {
|
|
65
|
+
try {
|
|
66
|
+
const entries = await fs.readdir(importsDir, { withFileTypes: true });
|
|
67
|
+
out.imports = entries
|
|
68
|
+
.filter((e) => e.isDirectory())
|
|
69
|
+
.map((e) => e.name);
|
|
70
|
+
} catch {
|
|
71
|
+
out.imports = [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.watcher) this.watcher.close();
|
|
79
|
+
this.watcher = null;
|
|
80
|
+
}
|
|
81
|
+
}
|