@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.
- package/package.json +1 -1
- package/server/src/background-tasks.mjs +44 -0
- package/server/src/bazaar/core.mjs +59 -0
- package/server/src/canvas/core.mjs +37 -0
- package/server/src/chat-sessions.mjs +97 -8
- package/server/src/config.mjs +1 -1
- package/server/src/index.mjs +270 -6
- package/server/src/lobby-browse.mjs +32 -2
- package/server/src/process-holders.mjs +122 -0
- package/server/src/support/index.mjs +30 -0
- package/server/src/support/mcp-server.mjs +156 -0
- package/server/src/support/tickets.mjs +111 -0
- package/server/src/turn-mcp.mjs +3 -1
- package/server/src/usage.mjs +16 -5
- package/web/dist/assets/index-ABxdF5Rs.css +32 -0
- package/web/dist/assets/index-CrHQ6EW8.js +131 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CZ0RGU6J.css +0 -32
- package/web/dist/assets/index-Faj3O8f8.js +0 -131
package/server/src/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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) =>
|
|
108
|
-
|
|
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');
|