@venturewild/workspace 0.6.18 → 0.6.20

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.bmoSyncServerUrl;
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,44 @@ 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
+
2600
2826
  // Stop a running background task — resolve who holds its output file open and kill
2601
2827
  // that tree (Restart Manager on Windows, lsof on Unix). Owner-only. We pass every
2602
2828
  // LIVE claude pid to exclude so a Stop during an in-flight turn can't kill the
@@ -2894,6 +3120,9 @@ export async function createServer(overrides = {}) {
2894
3120
  const lid = fromRailsId(slug, rs.id);
2895
3121
  const existing = byId.get(lid);
2896
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 || {}),
2897
3126
  id: lid,
2898
3127
  title: rs.title || existing?.title || (lid === MAIN_SESSION_ID ? 'Main' : null),
2899
3128
  account: rs.owner_email || existing?.account || 'local',
@@ -2913,18 +3142,33 @@ export async function createServer(overrides = {}) {
2913
3142
  const forbidden = require(c, 'chatWrite');
2914
3143
  if (forbidden) return forbidden;
2915
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
+ }
2916
3151
  const rec = sessionsFor(c).create({
2917
3152
  title: typeof body.title === 'string' ? body.title : null,
2918
3153
  account: accountLabel(),
3154
+ provider,
2919
3155
  });
2920
3156
  const slug = sharedSlugFor(c);
2921
3157
  if (slug && sessionRails.capable) {
2922
3158
  sessionRails.createSession(slug, railsId(slug, rec.id), rec.title).catch(() => {});
2923
3159
  }
2924
- auditAction(c, 'sessions.create', `id=${rec.id}`);
3160
+ auditAction(c, 'sessions.create', `id=${rec.id} provider=${provider}`);
2925
3161
  return c.json({ session: rec });
2926
3162
  });
2927
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
+
2928
3172
  app.get('/api/sessions/:id/messages', async (c) => {
2929
3173
  const forbidden = require(c, 'chat');
2930
3174
  if (forbidden) return forbidden;
@@ -3549,9 +3793,11 @@ export async function createServer(overrides = {}) {
3549
3793
  ws._sendTimes.push(now);
3550
3794
  // The turn-runner resumes THIS socket's workspace + thread conversation and
3551
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.
3552
3799
  runChatTurn({
3553
3800
  prompt: msg.text,
3554
- mode: msg.mode,
3555
3801
  messageId: msg.messageId,
3556
3802
  userText: msg.text,
3557
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,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');
@@ -0,0 +1,156 @@
1
+ // Support MCP server — the agent-as-CLI surface for item 7 (ticketing) + a slice of
2
+ // item 8 (check_preview). Same hand-rolled stdio JSON-RPC shape as the bazaar/canvas
3
+ // MCP servers; `claude` spawns it per turn via --mcp-config and exposes the tools as
4
+ // mcp__tickets__<name>. It shares the global state dir (WILD_WORKSPACE_GLOBAL_DIR), so
5
+ // a filed ticket lands in the same store the main server's /api/workspace/tickets
6
+ // reads — the Requests block then shows it (no chat-result wiring needed).
7
+ //
8
+ // BOUNDARY (item 7 "till a point") — these tools are deliberately the SAFE surface:
9
+ // file/manage tickets, check the preview. They CANNOT touch auth/provider keys, delete
10
+ // the workspace, change support consent, or destroy user files. Those stay human-only.
11
+ //
12
+ // stdout carries ONLY JSON-RPC; diagnostics go to stderr.
13
+
14
+ import readline from 'node:readline';
15
+ import { createTickets, CATEGORIES, SEVERITIES, STATUSES } from './tickets.mjs';
16
+ import { detectPreviewPorts } from '../preview.mjs';
17
+
18
+ const tickets = createTickets(); // baseDir from WILD_WORKSPACE_GLOBAL_DIR (set by parent)
19
+ const PROTOCOL_VERSION = '2025-06-18';
20
+
21
+ function send(msg) { process.stdout.write(`${JSON.stringify(msg)}\n`); }
22
+ function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
23
+ function errorReply(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
24
+ function textContent(obj) { return { content: [{ type: 'text', text: JSON.stringify(obj) }] }; }
25
+
26
+ const TOOLS = [
27
+ {
28
+ name: 'file_ticket',
29
+ description:
30
+ "File a feature request, bug, or question against THIS workspace when you (or the user) want " +
31
+ "something the workspace itself is missing or doing wrong. The ticket is tracked on the user's " +
32
+ "Requests block and synced to the VentureWild team. Use it for product feedback about wild-workspace " +
33
+ "(not for the user's own project work). Keep the title short and specific.",
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ title: { type: 'string', description: 'Short, specific summary (the headline).' },
38
+ desc: { type: 'string', description: 'What you need + why, repro steps for a bug (optional).' },
39
+ category: { type: 'string', enum: CATEGORIES, description: 'feature | bug | question | chore | other.' },
40
+ severity: { type: 'string', enum: SEVERITIES, description: 'low | medium | high.' },
41
+ },
42
+ required: ['title'],
43
+ },
44
+ },
45
+ {
46
+ name: 'list_tickets',
47
+ description: "List the requests filed for this workspace (id, title, status) so you can refer to or update one.",
48
+ inputSchema: { type: 'object', properties: {} },
49
+ },
50
+ {
51
+ name: 'update_ticket',
52
+ description:
53
+ "Update a ticket you filed — change its status or add a note. Use this to manage a request over time " +
54
+ "(e.g. mark it done once you've worked around it, or note new detail).",
55
+ inputSchema: {
56
+ type: 'object',
57
+ properties: {
58
+ id: { type: 'string', description: 'The ticket id from file_ticket / list_tickets.' },
59
+ status: { type: 'string', enum: STATUSES, description: 'open | planned | in-progress | done | wont-do.' },
60
+ note: { type: 'string', description: 'A short note to append (optional).' },
61
+ },
62
+ required: ['id'],
63
+ },
64
+ },
65
+ {
66
+ name: 'check_preview',
67
+ description:
68
+ "Check whether the user's dev server / live preview is up, and get its URL — use after you start a " +
69
+ "dev server (npm run dev, etc.) to confirm it's serving before telling the user to look at the Live view.",
70
+ inputSchema: { type: 'object', properties: {} },
71
+ },
72
+ ];
73
+
74
+ async function callTool(name, args = {}) {
75
+ switch (name) {
76
+ case 'file_ticket': {
77
+ const rec = tickets.file({
78
+ title: args.title, desc: args.desc, category: args.category, severity: args.severity,
79
+ workspaceId: process.env.WILD_WORKSPACE_ID || null, source: 'agent',
80
+ });
81
+ return textContent({
82
+ kind: 'ticket', op: 'file', ticket: rec,
83
+ note: "Request filed. It's on the user's Requests block + synced to VentureWild. Tell them in one short line what you logged.",
84
+ });
85
+ }
86
+ case 'list_tickets': {
87
+ const list = tickets.list().map((t) => ({ id: t.id, title: t.title, status: t.status, category: t.category }));
88
+ return textContent({ kind: 'tickets', count: list.length, tickets: list });
89
+ }
90
+ case 'update_ticket': {
91
+ const rec = tickets.update(args.id, { status: args.status, note: args.note });
92
+ if (!rec) return { ...textContent({ kind: 'error', error: `no ticket "${args.id}"` }), isError: true };
93
+ return textContent({ kind: 'ticket', op: 'update', ticket: rec });
94
+ }
95
+ case 'check_preview': {
96
+ const found = await detectPreviewPorts();
97
+ const top = found[0] || null;
98
+ return textContent({
99
+ kind: 'preview',
100
+ rendered: Boolean(top),
101
+ url: top ? `http://localhost:${top.port}` : null,
102
+ ports: found.map((f) => f.port),
103
+ note: top
104
+ ? `A dev server is up on port ${top.port}. The user can open it in the Live view.`
105
+ : 'No dev server detected yet — start one (e.g. npm run dev), then check again.',
106
+ });
107
+ }
108
+ default:
109
+ return { ...textContent({ kind: 'error', error: `unknown tool ${name}` }), isError: true };
110
+ }
111
+ }
112
+
113
+ export function handleMessage(msg, reply = send) {
114
+ if (!msg || msg.jsonrpc !== '2.0') return undefined;
115
+ const { id, method, params } = msg;
116
+ const isNotification = id === undefined || id === null;
117
+ switch (method) {
118
+ case 'initialize':
119
+ return result(id, {
120
+ protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
121
+ capabilities: { tools: {} },
122
+ serverInfo: { name: 'tickets', version: '1.0.0' },
123
+ });
124
+ case 'notifications/initialized':
125
+ case 'initialized':
126
+ return undefined;
127
+ case 'ping':
128
+ return result(id, {});
129
+ case 'tools/list':
130
+ return result(id, { tools: TOOLS });
131
+ case 'tools/call':
132
+ // async — resolve then reply.
133
+ Promise.resolve(callTool(params?.name, params?.arguments || {}))
134
+ .then((out) => result(id, out))
135
+ .catch((e) => errorReply(id, -32603, `tool error: ${e?.message || e}`));
136
+ return undefined;
137
+ default:
138
+ if (!isNotification) errorReply(id, -32601, `method not found: ${method}`);
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ const isDirectRun = process.argv[1] && process.argv[1].endsWith('mcp-server.mjs') && process.argv[1].includes('support');
144
+ if (isDirectRun) {
145
+ const rl = readline.createInterface({ input: process.stdin });
146
+ rl.on('line', (line) => {
147
+ const trimmed = line.trim();
148
+ if (!trimmed) return;
149
+ let msg;
150
+ try { msg = JSON.parse(trimmed); } catch { return; }
151
+ try { handleMessage(msg); } catch (e) { process.stderr.write(`tickets mcp error: ${e?.message || e}\n`); }
152
+ });
153
+ process.stderr.write('tickets mcp server ready\n');
154
+ }
155
+
156
+ export { TOOLS, callTool };