@venturewild/workspace 0.6.17 → 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.17",
3
+ "version": "0.6.18",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -26,6 +26,9 @@
26
26
  // silent past a threshold — surfaced as "no output for Xm", never a false "done".
27
27
 
28
28
  import fs from 'node:fs';
29
+ import { resolveHolders as defaultResolveHolders, killTree as defaultKillTree } from './process-holders.mjs';
30
+
31
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
29
32
 
30
33
  const DEFAULT_POLL_MS = 3000; // how often we stat each live task's output file
31
34
  const DEFAULT_QUIET_MS = 90_000; // silence past this → flag `quiet` (maybe stuck / maybe just slow)
@@ -92,6 +95,9 @@ export function createBackgroundTasks(opts = {}) {
92
95
  const quietMs = opts.quietMs || DEFAULT_QUIET_MS;
93
96
  const maxPerWs = opts.maxPerWorkspace || DEFAULT_MAX_PER_WS;
94
97
  const fsi = opts.fsImpl || fs;
98
+ // The OS holder/kill engine (Restart Manager on Windows, lsof on Unix). Injectable
99
+ // so tests never spawn PowerShell or kill a real process.
100
+ const holders = opts.holders || { resolveHolders: defaultResolveHolders, killTree: defaultKillTree };
95
101
 
96
102
  // taskId (= the LAUNCH tool_use id) → task. Keying by the launch id (not the
97
103
  // shellId) means the chat card, which is created at launch time and only knows the
@@ -292,6 +298,44 @@ export function createBackgroundTasks(opts = {}) {
292
298
  .sort((a, b) => b.startedAt - a.startedAt)
293
299
  .map(publicView);
294
300
  },
301
+ /**
302
+ * Stop a running task: find who holds its output file open (Restart Manager /
303
+ * lsof) and kill that tree. No PID was captured at launch — the output-file path
304
+ * we already track IS the handle. Returns {ok, status, ...}.
305
+ *
306
+ * `excludePids` MUST include any live claude pids; this method always also
307
+ * excludes our own pid. That guard is load-bearing: RM/lsof report READERS too,
308
+ * and the server tails this very file — without it we could kill ourselves.
309
+ */
310
+ async stop(workspaceId, taskId, { excludePids = [], retries = 4, retryDelayMs = 400 } = {}) {
311
+ const t = tasks.get(taskId);
312
+ if (!t || t.workspaceId !== workspaceId) return { ok: false, error: 'not-found' };
313
+ if (TERMINAL.has(t.status)) return { ok: true, status: t.status, alreadyTerminal: true };
314
+ if (!t.outputFile) return { ok: false, error: 'no-output-file' };
315
+ const exclude = new Set([process.pid, ...excludePids].map(Number).filter(Boolean));
316
+ // Retry: a single RM/lsof shot occasionally misses a live task (measured ~10%).
317
+ // Three clean misses on a live task is ~0.1%; a genuine empty means it finished.
318
+ let pids = [];
319
+ for (let attempt = 0; attempt <= retries; attempt++) {
320
+ const found = await holders.resolveHolders(t.outputFile);
321
+ pids = (found || []).filter((p) => !exclude.has(Number(p)));
322
+ if (pids.length) break;
323
+ if (attempt < retries) await sleep(retryDelayMs);
324
+ }
325
+ if (!pids.length) {
326
+ // Nobody holds it after retries → the process already exited. It finished; we
327
+ // did NOT stop it — say so honestly (never claim a kill we didn't make).
328
+ if (!TERMINAL.has(t.status)) { t.status = 'completed'; t.endedAt = now(); t.quiet = false; emit(t); }
329
+ return { ok: true, status: 'completed', note: 'already-finished' };
330
+ }
331
+ const killed = await holders.killTree(pids);
332
+ t.status = 'killed';
333
+ t.endedAt = now();
334
+ t.exitCode = null;
335
+ t.quiet = false;
336
+ emit(t);
337
+ return { ok: true, status: 'killed', pids, killed };
338
+ },
295
339
  /** Tail the output file for one task (best-effort; null if unavailable). */
296
340
  tail(workspaceId, taskId, bytes = TAIL_BYTES) {
297
341
  const t = tasks.get(taskId);
@@ -2597,6 +2597,24 @@ export async function createServer(overrides = {}) {
2597
2597
  return c.json({ id, output });
2598
2598
  });
2599
2599
 
2600
+ // Stop a running background task — resolve who holds its output file open and kill
2601
+ // that tree (Restart Manager on Windows, lsof on Unix). Owner-only. We pass every
2602
+ // LIVE claude pid to exclude so a Stop during an in-flight turn can't kill the
2603
+ // active agent (and `stop` always also excludes our own server pid — the guard that
2604
+ // matters, since RM/lsof report the file's readers too).
2605
+ app.post('/api/workspace/tasks/:id/stop', async (c) => {
2606
+ const forbidden = require(c, 'fileTree');
2607
+ if (forbidden) return forbidden;
2608
+ const id = c.req.param('id');
2609
+ const claudePids = [...currentTurns.values()]
2610
+ .map((t) => t?.session?.proc?.pid)
2611
+ .filter((p) => Number.isInteger(p));
2612
+ auditAction(c, 'task-stop', `id=${id}`);
2613
+ const res = await bgTasks.stop(workspaceFor(c).id, id, { excludePids: claudePids });
2614
+ if (!res.ok) return c.json(res, res.error === 'not-found' ? 404 : 400);
2615
+ return c.json(res);
2616
+ });
2617
+
2600
2618
  // --- component inbox ---
2601
2619
  app.get('/api/inbox', async (c) => {
2602
2620
  // Enforce the `inbox` capability (partner-only). It existed in the matrix
@@ -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
+ }