@venturewild/workspace 0.6.17 → 0.6.19

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.
@@ -85,6 +85,7 @@ import { getOperatorToken } from './operator.mjs';
85
85
  import { runDoctor } from './doctor.mjs';
86
86
  import { appendLine, tailFile, logFile, listLogs, TAILABLE, globalDir } from './logpaths.mjs';
87
87
  import { createBackgroundTasks } from './background-tasks.mjs';
88
+ import { createTickets } from './support/tickets.mjs';
88
89
  import { SessionReporter } from './session-reporter.mjs';
89
90
  import { TranscriptRecorder } from './transcript.mjs';
90
91
  import { loadObservabilityConsent, setObservabilityConsent } from './observability.mjs';
@@ -509,6 +510,32 @@ export async function createServer(overrides = {}) {
509
510
  ? { mcpConfigPath: turnMcpConfig, strictMcp: true, appendSystemPrompt: TURN_SYSTEM_PROMPT }
510
511
  : {};
511
512
 
513
+ // Per-session GLM (items 13/14): a GLM chat is the CLAUDE binary with GLM env
514
+ // injected at spawn (so --resume / --mcp-config / canvas+bazaar tools all survive) —
515
+ // NOT the bare `glm` wrapper agent. Mirrors ~/.local/bin/glm exactly. Returns null
516
+ // if no key is connected, so a GLM turn degrades to an honest "connect GLM" error
517
+ // rather than silently running as Claude. The key NEVER leaves this env.
518
+ function glmEnv() {
519
+ try {
520
+ const keyFile = process.env.GLM_KEY_FILE || path.join(config.home, '.glm-key');
521
+ const key = readFileSync(keyFile, 'utf8').replace(/\s+/g, '');
522
+ if (!key) return null;
523
+ return {
524
+ ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
525
+ ANTHROPIC_AUTH_TOKEN: key,
526
+ ANTHROPIC_MODEL: process.env.GLM_MODEL || 'glm-5.1',
527
+ ANTHROPIC_SMALL_FAST_MODEL: process.env.GLM_SMALL_MODEL || 'glm-4.5-air',
528
+ DISABLE_TELEMETRY: '1',
529
+ DISABLE_ERROR_REPORTING: '1',
530
+ };
531
+ } catch {
532
+ return null;
533
+ }
534
+ }
535
+ function glmConnected() {
536
+ return glmEnv() != null;
537
+ }
538
+
512
539
  // --- chat turn orchestration ----------------------------------------------
513
540
  // One conversation per workspace in v1 (single-user, single tab — PRD §5.5).
514
541
  // Both user sends and auto-wake turns thread through one turn-runner so they
@@ -595,6 +622,29 @@ export async function createServer(overrides = {}) {
595
622
  // The registry persists ACROSS turns (a background job outlives the `claude -p`
596
623
  // process that spawned it), so it lives here in server scope — never per-session.
597
624
  const bgTasks = createBackgroundTasks({ broadcast: broadcastTask });
625
+ // Feature/bug ticketing (item 7) — shared store with the support MCP (file_ticket),
626
+ // read by the Requests block + best-effort synced to the VentureWild team.
627
+ const tickets = createTickets({ env: { WILD_WORKSPACE_GLOBAL_DIR: path.dirname(bazaar.dir) } });
628
+ // Best-effort, degrade-never push of a ticket to bmo-sync (the team's tracker). If
629
+ // the route isn't deployed yet (or we're on localhost), this silently no-ops and the
630
+ // ticket still lives locally + shows in the Requests block (the locked degrade).
631
+ function syncTicket(rec) {
632
+ const baseUrl = config.bmoSyncUrl;
633
+ if (!baseUrl || baseUrl.startsWith('http://127') || baseUrl.startsWith('http://localhost')) return;
634
+ const ctrl = new AbortController();
635
+ const t = setTimeout(() => ctrl.abort(), 5000);
636
+ fetch(`${baseUrl.replace(/\/$/, '')}/api/tickets`, {
637
+ method: 'POST',
638
+ headers: { 'content-type': 'application/json' },
639
+ body: JSON.stringify({
640
+ account_token: config.accountToken || null,
641
+ slug: config.account?.slug || null,
642
+ ticket: rec,
643
+ }),
644
+ signal: ctrl.signal,
645
+ }).catch(() => { /* degrade-never — local store is the source of truth */ })
646
+ .finally(() => clearTimeout(t));
647
+ }
598
648
 
599
649
  /**
600
650
  * Run one chat turn: spawn the agent, stream every chunk to every chat
@@ -635,6 +685,22 @@ export async function createServer(overrides = {}) {
635
685
  // the stored level + the per-message toggle through resolveAutonomyMode, whose
636
686
  // guardrail guarantees an unbuilt/unknown level can NEVER become bypassPermissions.
637
687
  if (!auto) mode = resolveAutonomyMode(settings.getAutonomyLevel(), mode);
688
+ // Per-session provider (items 13/14): look up THIS session's provider (locked at
689
+ // creation). GLM = the claude binary + GLM env; Claude = no override. The provider
690
+ // also drives the context-window denominator (item 3) — Claude 1M, GLM 200k.
691
+ const sessionId = threadId || MAIN_SESSION_ID;
692
+ const sessionStore = createChatSessions({ dataDir: workspace.dataDir });
693
+ let sessProvider = 'claude';
694
+ try { sessProvider = sessionStore.get(sessionId)?.provider || 'claude'; } catch { /* default claude */ }
695
+ const providerEnv = sessProvider === 'glm' ? glmEnv() : null;
696
+ if (sessProvider === 'glm' && !providerEnv && !auto) {
697
+ broadcastChat({
698
+ type: 'error',
699
+ messageId: id,
700
+ message: "GLM isn't connected on this machine. Add your GLM key in Settings → Providers to use a GLM chat.",
701
+ }, workspace.id, threadId);
702
+ return false;
703
+ }
638
704
  // Per-thread conversation (C3 on top of lobby M1): resume THIS thread's claude
639
705
  // session from the workspace dataDir, and tag every frame with the workspace +
640
706
  // thread so it streams only to the matching chat block.
@@ -647,18 +713,20 @@ export async function createServer(overrides = {}) {
647
713
  });
648
714
 
649
715
  let retried = false;
716
+ let lastContext = null; // freshest per-session context gauge, persisted on turn end
650
717
  const startTurn = () => {
651
718
  const startedAt = Date.now();
652
- log('[chat]', `turn-begin id=${id} auto=${auto} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
719
+ log('[chat]', `turn-begin id=${id} auto=${auto} provider=${sessProvider} mode=${mode || 'build'} promptChars=${(prompt || '').length}`);
653
720
  const session = new AgentSession(activeAgent);
654
721
  currentTurns.set(key, { session, messageId: id, workspaceId: workspace.id, threadId });
655
722
  let sawError = false;
656
723
  session.on('chunk', (chunk) => {
657
724
  // The per-turn context signal (working-memory gauge, §8) feeds the usage
658
725
  // service, which pushes a `usage` WS frame. It's server-internal metadata —
659
- // keep it OFF the chat stream so it never renders as an empty bubble.
726
+ // keep it OFF the chat stream so it never renders as an empty bubble. The
727
+ // window is provider-aware (item 3) — Claude 1M / GLM 200k.
660
728
  if (chunk.type === 'context' && chunk.usage) {
661
- usage.setContext(chunk.usage, chunk.model);
729
+ lastContext = usage.setContext(chunk.usage, chunk.model, sessProvider);
662
730
  return;
663
731
  }
664
732
  if (chunk.type === 'error') sawError = true;
@@ -733,7 +801,10 @@ export async function createServer(overrides = {}) {
733
801
  // Keep the session list's recency honest even when the turn was driven from
734
802
  // another device (the message PUT is client-driven; this is server-side).
735
803
  try {
736
- createChatSessions({ dataDir: workspace.dataDir }).touch(threadId || MAIN_SESSION_ID);
804
+ sessionStore.touch(sessionId);
805
+ // Persist this session's context gauge so the sidebar chip shows it even
806
+ // without a live turn (item 3).
807
+ if (lastContext) sessionStore.setContext(sessionId, lastContext);
737
808
  } catch { /* best-effort — never break a turn on a registry hiccup */ }
738
809
  broadcastChat({ type: 'end', messageId: id, code }, workspace.id, threadId);
739
810
  activityBus.publish({ type: 'chat-end', messageId: id, code });
@@ -742,6 +813,9 @@ export async function createServer(overrides = {}) {
742
813
  cwd: workspace.dir,
743
814
  mode,
744
815
  resumeSessionId: resumeId,
816
+ // GLM session → inject the GLM env onto the claude binary (item 14). Claude
817
+ // session → no override (uses the machine's Claude OAuth).
818
+ ...(providerEnv ? { env: providerEnv } : {}),
745
819
  // Auto-wake (import integration, plan mode) stays bazaar-free; only the
746
820
  // user's own turns get the marketplace tools + disposition.
747
821
  ...(auto ? {} : turnCtx),
@@ -1067,6 +1141,27 @@ export async function createServer(overrides = {}) {
1067
1141
  c.json({ status: 'ok', version: APP_VERSION, ts: Date.now() }),
1068
1142
  );
1069
1143
 
1144
+ // Detect an EXISTING agent setup in a folder (item 15) — a CLAUDE.md, a .claude
1145
+ // dir, .claude/agents, or AGENTS.md. When present, onboarding offers "adopt" instead
1146
+ // of asking the user to create a parallel agent. We ONLY read these markers — the
1147
+ // onboarding/identity path never writes to them (the kept invariant; identity lives
1148
+ // in the sync-ignored <repo>/.wild-workspace scratch).
1149
+ function detectExistingSetup(dir) {
1150
+ const markers = [
1151
+ ['CLAUDE.md', 'CLAUDE.md'],
1152
+ ['.claude/agents', path.join('.claude', 'agents')],
1153
+ ['.claude', '.claude'],
1154
+ ['AGENTS.md', 'AGENTS.md'],
1155
+ ];
1156
+ const signals = [];
1157
+ for (const [label, rel] of markers) {
1158
+ try { if (existsSync(path.join(dir, rel))) signals.push(label); } catch { /* skip */ }
1159
+ }
1160
+ // .claude is implied by .claude/agents — don't list both.
1161
+ const deduped = signals.includes('.claude/agents') ? signals.filter((s) => s !== '.claude') : signals;
1162
+ return { hasConfig: deduped.length > 0, signals: deduped };
1163
+ }
1164
+
1070
1165
  app.get('/api/session', (c) => {
1071
1166
  const session = c.get('session');
1072
1167
  const role = c.get('role');
@@ -1098,6 +1193,9 @@ export async function createServer(overrides = {}) {
1098
1193
  : null,
1099
1194
  identity,
1100
1195
  onboarded: Boolean(identity?.onboardedAt),
1196
+ // item 15: an existing CLAUDE.md/.claude in this folder → onboarding offers
1197
+ // "adopt" (keep it) instead of asking the user to create a parallel agent.
1198
+ existingSetup: detectExistingSetup(ws.dir),
1101
1199
  shareBaseUrl: config.shareBaseUrl,
1102
1200
  // `account` is set after the user runs `wild-workspace login`. The UI
1103
1201
  // uses it to show "you are <slug>" and to seed step 4 of onboarding
@@ -1758,6 +1856,72 @@ export async function createServer(overrides = {}) {
1758
1856
  return c.json({ ok, ...(_loginSession ? _loginSession.snapshot() : emptyLoginSnap) });
1759
1857
  });
1760
1858
 
1859
+ // --- Providers (items 13/14) — machine-level credentials (Settings → Providers) --
1860
+ // The per-session picker only CHOOSES a provider; connecting GLM / signing out of
1861
+ // Claude are machine-level acts that live here. Owner-gated.
1862
+ const glmKeyFile = () => process.env.GLM_KEY_FILE || path.join(config.home, '.glm-key');
1863
+
1864
+ app.get('/api/providers', (c) => {
1865
+ const forbidden = require(c, 'chat');
1866
+ if (forbidden) return forbidden;
1867
+ return c.json({
1868
+ glm: { connected: glmConnected() },
1869
+ // Claude is the built-in default; `account` echoes who's signed in (if known).
1870
+ claude: { connected: true, account: c.get('session')?.account?.email || null },
1871
+ });
1872
+ });
1873
+
1874
+ // Connect GLM — store the key at ~/.glm-key (single-line, never logged/committed,
1875
+ // mode 600). The key is read straight off the body; it is NOT a financial/password
1876
+ // field (it's a 3rd-party API token the user pastes deliberately).
1877
+ app.post('/api/providers/glm', async (c) => {
1878
+ const forbidden = require(c, 'chatWrite');
1879
+ if (forbidden) return forbidden;
1880
+ const body = await c.req.json().catch(() => ({}));
1881
+ const key = typeof body.key === 'string' ? body.key.replace(/\s+/g, '') : '';
1882
+ if (!key || key.length < 8) return c.json({ error: 'invalid_key' }, 400);
1883
+ try {
1884
+ writeFileSync(glmKeyFile(), `${key}\n`, { mode: 0o600 });
1885
+ } catch (e) {
1886
+ return c.json({ error: 'write_failed', detail: String(e?.message || e) }, 500);
1887
+ }
1888
+ auditAction(c, 'providers.glm.connect', 'key stored');
1889
+ return c.json({ ok: true, connected: glmConnected() });
1890
+ });
1891
+
1892
+ // Disconnect GLM — remove the key file.
1893
+ app.delete('/api/providers/glm', (c) => {
1894
+ const forbidden = require(c, 'chatWrite');
1895
+ if (forbidden) return forbidden;
1896
+ try { unlinkSync(glmKeyFile()); } catch { /* already gone */ }
1897
+ auditAction(c, 'providers.glm.disconnect', 'key removed');
1898
+ return c.json({ ok: true, connected: glmConnected() });
1899
+ });
1900
+
1901
+ // Sign out of Claude — `claude auth logout` (no browser needed; clears the OS
1902
+ // credential). The user re-signs-in via the existing in-app PTY login. NOTE: this
1903
+ // logs out the machine's Claude account that the agent runs under.
1904
+ app.post('/api/auth/claude/logout', async (c) => {
1905
+ const forbidden = require(c, 'chatWrite');
1906
+ if (forbidden) return forbidden;
1907
+ const bin = activeAgent?.resolvedPath || 'claude';
1908
+ const result = await new Promise((resolve) => {
1909
+ try {
1910
+ const p = spawn(bin, ['auth', 'logout'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
1911
+ let out = '';
1912
+ p.stdout.on('data', (d) => { out += d; });
1913
+ p.stderr.on('data', (d) => { out += d; });
1914
+ p.on('error', (e) => resolve({ ok: false, error: String(e?.message || e) }));
1915
+ p.on('close', (code) => resolve({ ok: code === 0, code, out: out.slice(0, 400) }));
1916
+ } catch (e) {
1917
+ resolve({ ok: false, error: String(e?.message || e) });
1918
+ }
1919
+ });
1920
+ _readinessCache = null; // auth state changed
1921
+ auditAction(c, 'auth.claude.logout', `ok=${result.ok}`);
1922
+ return c.json(result);
1923
+ });
1924
+
1761
1925
  app.post('/api/agent/identity', async (c) => {
1762
1926
  const forbidden = require(c, 'chatWrite');
1763
1927
  if (forbidden) return forbidden;
@@ -1790,6 +1954,30 @@ export async function createServer(overrides = {}) {
1790
1954
  }
1791
1955
  });
1792
1956
 
1957
+ // Adopt an existing setup (item 15) — the user opened a folder that already has a
1958
+ // CLAUDE.md/.claude, so we SKIP "create an agent" and just adopt the existing
1959
+ // config: derive a display name from the folder, write ONLY the .wild-workspace
1960
+ // scratch identity, and mark onboarded. We NEVER touch the user's CLAUDE.md/.claude.
1961
+ app.post('/api/agent/adopt', (c) => {
1962
+ const forbidden = require(c, 'chatWrite');
1963
+ if (forbidden) return forbidden;
1964
+ const ws = workspaceFor(c);
1965
+ const setup = detectExistingSetup(ws.dir);
1966
+ if (!setup.hasConfig) return c.json({ error: 'no_existing_setup' }, 400);
1967
+ // A friendly display name from the folder (the project's own name), e.g. "tickup"
1968
+ // → "Tickup". The agent's behavior still comes from the existing CLAUDE.md/.claude.
1969
+ const base = path.basename(ws.dir) || 'Agent';
1970
+ const name = base.replace(/[-_]+/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()).slice(0, 40) || 'Agent';
1971
+ try {
1972
+ const saved = saveIdentity(ws.dataDir, { name, onboardedAt: Date.now() });
1973
+ log('[onboarding]', `adopted existing setup (${setup.signals.join(', ')}) name=${saved.name}`);
1974
+ activityBus.publish({ type: 'onboarded', at: saved.onboardedAt });
1975
+ return c.json({ identity: saved, adopted: setup.signals });
1976
+ } catch (e) {
1977
+ return c.json({ error: String(e.message || e) }, 400);
1978
+ }
1979
+ });
1980
+
1793
1981
  // Consent toggle for the proactive observability feed (default-on — see
1794
1982
  // observability.mjs). Owner-only; applied live to the reporter, no restart.
1795
1983
  app.post('/api/observability/consent', async (c) => {
@@ -2597,6 +2785,62 @@ export async function createServer(overrides = {}) {
2597
2785
  return c.json({ id, output });
2598
2786
  });
2599
2787
 
2788
+ // --- Feature/bug tickets (item 7) — the Requests block + the agent's file_ticket --
2789
+ // The agent files via the support MCP (mcp__tickets__file_ticket) into the same
2790
+ // store; the user files/updates here. Owner-gated. Tickets sync to the team's tracker
2791
+ // best-effort, once each (a runaway list-poll never re-POSTs the same ticket).
2792
+ const syncedTicketIds = new Set();
2793
+ app.get('/api/workspace/tickets', (c) => {
2794
+ const forbidden = require(c, 'chatWrite');
2795
+ if (forbidden) return forbidden;
2796
+ const list = tickets.list();
2797
+ for (const t of list) {
2798
+ if (!syncedTicketIds.has(t.id)) { syncedTicketIds.add(t.id); syncTicket(t); }
2799
+ }
2800
+ return c.json({ tickets: list });
2801
+ });
2802
+ app.post('/api/workspace/tickets', async (c) => {
2803
+ const forbidden = require(c, 'chatWrite');
2804
+ if (forbidden) return forbidden;
2805
+ const body = await c.req.json().catch(() => ({}));
2806
+ if (!body.title || typeof body.title !== 'string') return c.json({ error: 'title_required' }, 400);
2807
+ const rec = tickets.file({
2808
+ title: body.title, desc: body.desc, category: body.category, severity: body.severity,
2809
+ workspaceId: workspaceFor(c).id, source: 'user',
2810
+ });
2811
+ syncedTicketIds.add(rec.id); syncTicket(rec);
2812
+ auditAction(c, 'tickets.file', `id=${rec.id} cat=${rec.category}`);
2813
+ return c.json({ ticket: rec });
2814
+ });
2815
+ app.patch('/api/workspace/tickets/:id', async (c) => {
2816
+ const forbidden = require(c, 'chatWrite');
2817
+ if (forbidden) return forbidden;
2818
+ const body = await c.req.json().catch(() => ({}));
2819
+ const rec = tickets.update(c.req.param('id'), { status: body.status, note: body.note });
2820
+ if (!rec) return c.json({ error: 'not_found' }, 404);
2821
+ syncTicket(rec);
2822
+ auditAction(c, 'tickets.update', `id=${rec.id} status=${rec.status}`);
2823
+ return c.json({ ticket: rec });
2824
+ });
2825
+
2826
+ // Stop a running background task — resolve who holds its output file open and kill
2827
+ // that tree (Restart Manager on Windows, lsof on Unix). Owner-only. We pass every
2828
+ // LIVE claude pid to exclude so a Stop during an in-flight turn can't kill the
2829
+ // active agent (and `stop` always also excludes our own server pid — the guard that
2830
+ // matters, since RM/lsof report the file's readers too).
2831
+ app.post('/api/workspace/tasks/:id/stop', async (c) => {
2832
+ const forbidden = require(c, 'fileTree');
2833
+ if (forbidden) return forbidden;
2834
+ const id = c.req.param('id');
2835
+ const claudePids = [...currentTurns.values()]
2836
+ .map((t) => t?.session?.proc?.pid)
2837
+ .filter((p) => Number.isInteger(p));
2838
+ auditAction(c, 'task-stop', `id=${id}`);
2839
+ const res = await bgTasks.stop(workspaceFor(c).id, id, { excludePids: claudePids });
2840
+ if (!res.ok) return c.json(res, res.error === 'not-found' ? 404 : 400);
2841
+ return c.json(res);
2842
+ });
2843
+
2600
2844
  // --- component inbox ---
2601
2845
  app.get('/api/inbox', async (c) => {
2602
2846
  // Enforce the `inbox` capability (partner-only). It existed in the matrix
@@ -2876,6 +3120,9 @@ export async function createServer(overrides = {}) {
2876
3120
  const lid = fromRailsId(slug, rs.id);
2877
3121
  const existing = byId.get(lid);
2878
3122
  byId.set(lid, {
3123
+ // Preserve local-only fields (provider/context/titleSource) — they're
3124
+ // per-host metadata the rails don't carry.
3125
+ ...(existing || {}),
2879
3126
  id: lid,
2880
3127
  title: rs.title || existing?.title || (lid === MAIN_SESSION_ID ? 'Main' : null),
2881
3128
  account: rs.owner_email || existing?.account || 'local',
@@ -2895,18 +3142,33 @@ export async function createServer(overrides = {}) {
2895
3142
  const forbidden = require(c, 'chatWrite');
2896
3143
  if (forbidden) return forbidden;
2897
3144
  const body = await c.req.json().catch(() => ({}));
3145
+ // Per-session provider (items 13/14) — locked at creation. A GLM chat requires GLM
3146
+ // to be connected on this machine; reject otherwise so we never make a dead chat.
3147
+ const provider = body.provider === 'glm' ? 'glm' : 'claude';
3148
+ if (provider === 'glm' && !glmConnected()) {
3149
+ return c.json({ error: 'glm_not_connected' }, 400);
3150
+ }
2898
3151
  const rec = sessionsFor(c).create({
2899
3152
  title: typeof body.title === 'string' ? body.title : null,
2900
3153
  account: accountLabel(),
3154
+ provider,
2901
3155
  });
2902
3156
  const slug = sharedSlugFor(c);
2903
3157
  if (slug && sessionRails.capable) {
2904
3158
  sessionRails.createSession(slug, railsId(slug, rec.id), rec.title).catch(() => {});
2905
3159
  }
2906
- auditAction(c, 'sessions.create', `id=${rec.id}`);
3160
+ auditAction(c, 'sessions.create', `id=${rec.id} provider=${provider}`);
2907
3161
  return c.json({ session: rec });
2908
3162
  });
2909
3163
 
3164
+ // The living-summary handoff (item 9) — the carry-the-gist payload for a fresh chat.
3165
+ app.get('/api/sessions/:id/summary', (c) => {
3166
+ const forbidden = require(c, 'chat');
3167
+ if (forbidden) return forbidden;
3168
+ const summary = sessionsFor(c).getSummary(c.req.param('id'));
3169
+ return c.json({ summary: summary || null });
3170
+ });
3171
+
2910
3172
  app.get('/api/sessions/:id/messages', async (c) => {
2911
3173
  const forbidden = require(c, 'chat');
2912
3174
  if (forbidden) return forbidden;
@@ -3531,9 +3793,11 @@ export async function createServer(overrides = {}) {
3531
3793
  ws._sendTimes.push(now);
3532
3794
  // The turn-runner resumes THIS socket's workspace + thread conversation and
3533
3795
  // streams back only to the matching chat block (lobby M1 + C3).
3796
+ // No client `mode` (item 10, 2026-06-21): the Build/Plan toggle was removed.
3797
+ // runChatTurn resolves the permission mode from the stored autonomy dial
3798
+ // (resolveAutonomyMode) — Full/unset → build, Check with me → read-only plan.
3534
3799
  runChatTurn({
3535
3800
  prompt: msg.text,
3536
- mode: msg.mode,
3537
3801
  messageId: msg.messageId,
3538
3802
  userText: msg.text,
3539
3803
  workspace: ws._wsWorkspace,
@@ -22,6 +22,31 @@ function isDirSafe(p) {
22
22
  }
23
23
  }
24
24
 
25
+ // Markers that mean "this folder already has your work" (item 6) — we want the lobby
26
+ // to steer the user toward opening one of these over starting blank. A few cheap
27
+ // existsSync checks per entry; we surface a short label list + a hasWork flag.
28
+ const WORK_MARKERS = [
29
+ ['CLAUDE.md', 'CLAUDE.md'],
30
+ ['.claude', '.claude'],
31
+ ['.git', '.git'],
32
+ ['package.json', 'package.json'],
33
+ ['README.md', 'README.md'],
34
+ ['pyproject.toml', 'pyproject.toml'],
35
+ ['Cargo.toml', 'Cargo.toml'],
36
+ ['go.mod', 'go.mod'],
37
+ ['index.html', 'index.html'],
38
+ ];
39
+
40
+ // Up to 3 signal labels for a folder (CLAUDE.md/.git/package.json/…), and hasWork.
41
+ export function workSignals(dir) {
42
+ const signals = [];
43
+ for (const [label, rel] of WORK_MARKERS) {
44
+ try { if (fs.existsSync(path.join(dir, rel))) signals.push(label); } catch { /* skip */ }
45
+ if (signals.length >= 3) break;
46
+ }
47
+ return { hasWork: signals.length > 0, signals };
48
+ }
49
+
25
50
  // The filesystem roots ("This PC"): on Windows each drive (C:\, D:\, …) is a
26
51
  // SEPARATE root with no common parent — without this, the picker is trapped on
27
52
  // the home drive (you could never browse to D:). We probe C–Z (A/B are legacy
@@ -104,8 +129,13 @@ export function browseDir(target) {
104
129
  if (d.isSymbolicLink()) return isDirSafe(path.join(abs, d.name));
105
130
  return false;
106
131
  })
107
- .map((d) => ({ name: d.name, dir: path.join(abs, d.name) }))
108
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
132
+ .map((d) => {
133
+ const dir = path.join(abs, d.name);
134
+ return { name: d.name, dir, ...workSignals(dir) };
135
+ })
136
+ // Folders that already have your work float to the top (item 6) — then alpha.
137
+ .sort((a, b) => (Number(b.hasWork) - Number(a.hasWork))
138
+ || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
109
139
  const parent = path.dirname(abs);
110
140
  return { path: abs, parent: parent !== abs ? parent : null, entries };
111
141
  }
@@ -0,0 +1,122 @@
1
+ // Cross-platform "who is running this background task, and stop it" — without ever
2
+ // capturing a PID at launch (which fighting claude's opaque background mechanism
3
+ // proved unreliable). Instead we use the one thing we already know: the task's
4
+ // OUTPUT FILE path. The running process holds that file open, so:
5
+ //
6
+ // resolveHolders(file) -> the PIDs holding it open
7
+ // killTree(pids) -> stop them (whole tree)
8
+ //
9
+ // Windows: Restart Manager (rstrtmgr.dll) — the API installers use to find which
10
+ // process locks a file — driven by a tiny embedded PowerShell helper.
11
+ // macOS/Linux: `lsof -t` (terse, PIDs only).
12
+ //
13
+ // SAFETY (proven necessary in the spike): RM/lsof also report READERS — our own
14
+ // server tails the file, so a naive kill could `taskkill` the wild-workspace server
15
+ // itself. Callers MUST pass the pids to exclude (our pid + any live claude pids);
16
+ // this module never kills blindly.
17
+
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+
24
+ const execFileP = promisify(execFile);
25
+
26
+ // PowerShell Restart Manager locker-finder. Embedded (not a shipped .ps1) so it
27
+ // works regardless of how the package is published; written to a temp file on first
28
+ // use and invoked with -File. Prints the holding PIDs, one per line.
29
+ const RM_PS = [
30
+ 'param([string]$Path)',
31
+ "$ErrorActionPreference='SilentlyContinue'",
32
+ "$sig = @'",
33
+ 'using System; using System.Runtime.InteropServices; using System.Collections.Generic;',
34
+ 'public static class RmFile {',
35
+ ' [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; }',
36
+ ' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct RM_PROCESS_INFO {',
37
+ ' public RM_UNIQUE_PROCESS Process;',
38
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=256)] public string strAppName;',
39
+ ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string strServiceShortName;',
40
+ ' public int ApplicationType; public uint AppStatus; public uint TSSessionId; [MarshalAs(UnmanagedType.Bool)] public bool bRestartable; }',
41
+ ' [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmStartSession(out uint h, int flags, string key);',
42
+ ' [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint h);',
43
+ ' [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmRegisterResources(uint h, uint nf, string[] files, uint na, RM_UNIQUE_PROCESS[] apps, uint ns, string[] svc);',
44
+ ' [DllImport("rstrtmgr.dll")] static extern int RmGetList(uint h, out uint need, ref uint count, [In,Out] RM_PROCESS_INFO[] info, ref uint reasons);',
45
+ ' public static List<int> Lockers(string p){ var r=new List<int>(); uint h; if(RmStartSession(out h,0,Guid.NewGuid().ToString())!=0)return r;',
46
+ ' try{ if(RmRegisterResources(h,1,new[]{p},0,null,0,null)!=0)return r; uint need=0,count=0,reasons=0; int res=RmGetList(h,out need,ref count,null,ref reasons);',
47
+ ' if(res==234){ var info=new RM_PROCESS_INFO[need]; count=need; if(RmGetList(h,out need,ref count,info,ref reasons)==0){ for(int i=0;i<count;i++) r.Add(info[i].Process.dwProcessId);} } }',
48
+ ' finally{ RmEndSession(h); } return r; } }',
49
+ "'@",
50
+ 'Add-Type -TypeDefinition $sig',
51
+ '[RmFile]::Lockers($Path) | ForEach-Object { $_ }',
52
+ ].join('\r\n');
53
+
54
+ let _rmScriptPath = null;
55
+ function ensureRmScript() {
56
+ if (_rmScriptPath && fs.existsSync(_rmScriptPath)) return _rmScriptPath;
57
+ const p = path.join(os.tmpdir(), 'wild-workspace-rm-holders.ps1');
58
+ try { fs.writeFileSync(p, RM_PS, 'utf8'); _rmScriptPath = p; } catch { _rmScriptPath = null; }
59
+ return _rmScriptPath;
60
+ }
61
+
62
+ function parsePids(stdout) {
63
+ return String(stdout || '')
64
+ .split(/\r?\n/)
65
+ .map((l) => parseInt(l.trim(), 10))
66
+ .filter((n) => Number.isInteger(n) && n > 0);
67
+ }
68
+
69
+ /**
70
+ * PIDs currently holding `filePath` open (the running task's process tree + any
71
+ * readers). Returns [] on any failure or when nothing holds it (task finished).
72
+ */
73
+ export async function resolveHolders(filePath, { exec = execFileP, platform = process.platform } = {}) {
74
+ if (!filePath) return [];
75
+ try {
76
+ if (platform === 'win32') {
77
+ const script = ensureRmScript();
78
+ if (!script) return [];
79
+ const { stdout } = await exec(
80
+ 'powershell',
81
+ ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Path', filePath],
82
+ { timeout: 9000, windowsHide: true },
83
+ );
84
+ return uniq(parsePids(stdout));
85
+ }
86
+ const { stdout } = await exec('lsof', ['-t', '--', filePath], { timeout: 9000 });
87
+ return uniq(parsePids(stdout));
88
+ } catch (e) {
89
+ // lsof exits 1 with no output when nothing holds the file — that's "empty", not
90
+ // an error. Salvage any stdout the failed call captured, else treat as empty.
91
+ if (e && e.stdout) return uniq(parsePids(e.stdout));
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /** Force-kill each pid AND its child tree. Returns per-pid {pid, ok}. */
97
+ export async function killTree(pids, { exec = execFileP, platform = process.platform, kill = (p, s) => process.kill(p, s) } = {}) {
98
+ const out = [];
99
+ for (const pid of uniq(pids)) {
100
+ try {
101
+ if (platform === 'win32') {
102
+ await exec('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: 9000, windowsHide: true });
103
+ } else {
104
+ // SIGKILL the holder; lsof returned the whole fd-sharing chain, so killing
105
+ // each holder takes down the tree. A transient child (e.g. `sleep`) exits on
106
+ // its own once its parent is gone.
107
+ try { kill(pid, 'SIGKILL'); } catch { /* already gone */ }
108
+ }
109
+ out.push({ pid, ok: true });
110
+ } catch (e) {
111
+ // taskkill exits non-zero with "process not found" when /T already reaped it as
112
+ // part of an earlier pid's tree — that's success, not failure.
113
+ const msg = String((e && (e.stderr || e.message)) || '');
114
+ out.push({ pid, ok: /not found|not be terminated/i.test(msg) });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function uniq(arr) {
121
+ return [...new Set(arr)];
122
+ }
@@ -0,0 +1,30 @@
1
+ // Support runtime glue for the main server: the agent-facing system prompt and the
2
+ // path to the support (tickets + check_preview) MCP server, wired into the turn's
3
+ // --mcp-config by turn-mcp.mjs.
4
+
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ export const SUPPORT_MCP_SERVER_PATH = path.join(__dirname, 'mcp-server.mjs');
11
+
12
+ // Appended to the agent's system prompt on user chat turns. Frames wild-workspace as
13
+ // a tool the agent can drive "till a point": file/manage feature requests + bugs about
14
+ // the workspace itself, and check the live preview — but NOT touch auth, keys, deletion,
15
+ // or the user's files (those stay human-only). Plain language.
16
+ export const SUPPORT_SYSTEM_PROMPT = [
17
+ "You can treat wild-workspace itself like a tool you operate. If you (or the user) hit something the",
18
+ "workspace is MISSING or doing wrong — a feature you wish it had, a bug — file a ticket with",
19
+ "mcp__tickets__file_ticket (title + a short desc + category/severity). It lands on the user's Requests",
20
+ "block and is traced by the VentureWild team, so the product gets better. Use list_tickets / update_ticket",
21
+ "to manage one over time (e.g. mark it done). File tickets for feedback about wild-workspace itself —",
22
+ "NOT for the user's own project work (that's just normal building).",
23
+ "",
24
+ "After you start a dev server for the user (npm run dev, etc.), call mcp__tickets__check_preview to",
25
+ "confirm it's actually serving and get its URL before pointing them at the Live view.",
26
+ "",
27
+ "Boundary: these are the only workspace-control actions you take on your own. You never change sign-in /",
28
+ "API keys, delete a workspace, alter support-access consent, or destroy the user's files — if something",
29
+ "needs one of those, ask the user to do it.",
30
+ ].join('\n');