@awak-app/simy-cli 0.5.76 → 0.5.78

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/README.md CHANGED
@@ -113,8 +113,11 @@ interrupting active tasks. The normal `simy` command remains available.
113
113
  Signed patch/minor updates wait for idle and verify the new runtime before
114
114
  handoff; failed startup restores the previous installed version. After a runtime
115
115
  update, choose **Restart to update** to load the updated native interface. This
116
- action is available only when tasks are idle; the service checks again before
117
- quitting. The window reports App and runtime versions separately while they
116
+ action asks to stop running tasks before restarting. **Stop task…** in a task
117
+ detail asks for confirmation and terminates its local Provider process, keeping
118
+ existing output and history. An explicit update can likewise stop active work
119
+ after confirmation; automatic updates still wait for idle. **⌘Q** exits SIMY and
120
+ its background services directly, including when work is running. The window reports App and runtime versions separately while they
118
121
  differ, and normal app launches select the newer installed interface.
119
122
 
120
123
  Older installers that predate native app relaunch support need a one-time
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.76",
3
+ "version": "0.5.78",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agent.js CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  createConnectionStatusTracker,
76
76
  desktopAuthStatus,
77
77
  desktopTaskStatus,
78
+ canStopDesktopTask,
78
79
  desktopTaskDetail,
79
80
  } from "./desktop-status.js";
80
81
  import { recoveryContractForPreflight } from "./orchestrator/recovery.js";
@@ -108,6 +109,7 @@ import {
108
109
  directTaskSnapshot,
109
110
  DirectTaskRegistry,
110
111
  startDirectTask,
112
+ stopDirectTask,
111
113
  } from "./direct-executor.js";
112
114
  import {
113
115
  classifyExecutionRequirement,
@@ -291,6 +293,7 @@ export async function startAgent({
291
293
  let codingRunMonitorTimer = null;
292
294
  let codingRunMonitorInFlight = null;
293
295
  let shuttingDown = false;
296
+ let desktopWorkSuspended = false;
294
297
  const notifyDesktopStatusChange = () => {
295
298
  if (shuttingDown) return;
296
299
  try { onDesktopStatusChange?.(); } catch { /* Desktop status polling remains available. */ }
@@ -312,10 +315,12 @@ export async function startAgent({
312
315
  session?.account_id ?? null, session?.organization_id ?? null, session?.device_id ?? null, session?.token ?? null,
313
316
  pendingOrganization?.id ?? null, isSessionValid(session, Date.now(), apiOrigin),
314
317
  ])).digest('hex');
315
- const desktopWorkAllowed = () => !enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot());
318
+ const desktopWorkAllowed = () => !desktopWorkSuspended && (!enforceDesktopAccess || desktopAccessReady(desktopAccess.snapshot()));
316
319
  const authorizeDesktopWork = async expectedScope => {
320
+ if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
317
321
  if (enforceDesktopAccess) {
318
322
  requireDesktopAccess(await desktopAccess.refresh());
323
+ if (desktopWorkSuspended || shuttingDown) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
319
324
  const scope = desktopScopeRevision();
320
325
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
321
326
  return scope;
@@ -2429,6 +2434,31 @@ export async function startAgent({
2429
2434
  startHeartbeatTimer();
2430
2435
  }
2431
2436
 
2437
+ const stopDesktopTask = async key => {
2438
+ if (typeof key !== "string" || key.length > 256) throw new Error("Select a running task.");
2439
+ if (key.startsWith("sqm:")) {
2440
+ if (!sqmLocalReviewWorker) throw new Error("This SQM task is no longer running.");
2441
+ return sqmLocalReviewWorker.stop(key);
2442
+ }
2443
+ const match = /^(0|1|2):(.+)$/.exec(key);
2444
+ if (!match) throw new Error("Select a running task.");
2445
+ const index = Number(match[1]);
2446
+ const task = [registry, directRegistry, localTaskRegistry][index].get(match[2]);
2447
+ if (!task) throw new Error("This task is no longer available.");
2448
+ if (!canStopDesktopTask(task, index)) return;
2449
+ await [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task);
2450
+ notifyDesktopStatusChange();
2451
+ };
2452
+ const stopDesktopTasks = async () => {
2453
+ desktopWorkSuspended = true;
2454
+ const stops = [sqmLocalReviewWorker?.close?.(), durableLocalTaskWorker?.close?.(),
2455
+ ...[registry, directRegistry, localTaskRegistry].flatMap((source, index) => source.list()
2456
+ .filter(task => canStopDesktopTask(task, index))
2457
+ .map(task => [stopLocalCodingRun, stopDirectTask, stopLocalTask][index](task)))];
2458
+ const results = await Promise.allSettled(stops);
2459
+ const failure = results.find(result => result.status === "rejected");
2460
+ if (failure) throw failure.reason;
2461
+ };
2432
2462
  let shutdownPromise = null;
2433
2463
  const shutdown = () => {
2434
2464
  if (shutdownPromise) return shutdownPromise;
@@ -2437,12 +2467,9 @@ export async function startAgent({
2437
2467
  shutdownPromise = (async () => {
2438
2468
  if (heartbeatTimer) clearInterval(heartbeatTimer);
2439
2469
  if (codingRunMonitorTimer) clearInterval(codingRunMonitorTimer);
2440
- if (codingRunMonitorInFlight) await codingRunMonitorInFlight;
2441
2470
  updateManager?.stop?.();
2442
- await codexWorkPatternSync?.close?.();
2443
- await codexWorkPatternSyncInFlight;
2444
- await sqmLocalReviewWorker?.close?.();
2445
- await durableLocalTaskWorker?.close?.();
2471
+ await Promise.all([stopDesktopTasks(), codexWorkPatternSync?.close?.(),
2472
+ codexWorkPatternSyncInFlight, codingRunMonitorInFlight]);
2446
2473
  try {
2447
2474
  await (dependencies.removeConnectionDiscovery || removeConnectionDiscovery)(
2448
2475
  connectionDiscovery,
@@ -2577,6 +2604,8 @@ export async function startAgent({
2577
2604
  return { code: ticket.code, expiresAt: ticket.expiresAt, userId: ticket.userId, orgSlug: ticket.orgSlug };
2578
2605
  },
2579
2606
  getDesktopStatus,
2607
+ stopDesktopTask,
2608
+ stopDesktopTasks,
2580
2609
  getDesktopTask: async (key) => {
2581
2610
  if (key?.startsWith("sqm:") || key?.startsWith("sqm-check:")) {
2582
2611
  const scope = currentHistoryScope();
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.76";
16
+ export const CLI_VERSION = "0.5.78";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -8,7 +8,7 @@ export function validDesktopAccessPath(path, slug) {
8
8
  }
9
9
  export const desktopAccessReady = value => value?.state === 'ready';
10
10
  const recoveryActions = new Set(['refresh', 'reconnect', 'login', 'authorization_open', 'organization_select', 'organization_cancel', 'organizations_refresh',
11
- 'check_update', 'update', 'quit', 'delivery_stop', 'delivery_pause', 'delivery_reject',
11
+ 'check_update', 'update', 'quit', 'task_stop', 'delivery_stop', 'delivery_pause', 'delivery_reject',
12
12
  'desktop_access_refresh', 'desktop_access_settings', 'desktop_access_members']);
13
13
  export function desktopActionAllowed(access, command) {
14
14
  return desktopAccessReady(access) || recoveryActions.has(command?.action) ||
@@ -0,0 +1,81 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { windowsUserProcesses } from './windows-processes.js';
5
+ import { homedir } from 'node:os';
6
+ import { parseLegacyAgentArguments } from './desktop-process-lock.js';
7
+ import { dirname, join } from 'node:path';
8
+ const run = promisify(execFile);
9
+
10
+ // Fallback for an unresponsive host. The requesting native app must still own
11
+ // the exact host; snapshot/start-time checks exclude PID reuse and unrelated apps.
12
+ export async function stopOwnedDesktopHost(pid, { parentPid = process.ppid,
13
+ snapshot = processSnapshot, kill = process.kill.bind(process), includeRoot = true } = {}) {
14
+ if (!Number.isSafeInteger(pid) || pid <= 1) throw new Error('Invalid host');
15
+ const rows = await snapshot();
16
+ const host = rows.find(row => row.pid === pid);
17
+ if (!host) return;
18
+ if (host.eligible === false || host.ppid !== parentPid || !await isHostCommand(host.command)) throw new Error('Host ownership changed');
19
+ await stopProcessTree(host, rows, { snapshot, kill, includeRoot });
20
+ }
21
+ async function stopProcessTree(host, rows, { snapshot, kill, includeRoot = true }) {
22
+ const pid = host.pid;
23
+ const owned = new Set([pid]);
24
+ const depths = new Map([[pid, 0]]);
25
+ for (let changed = true; changed;) {
26
+ changed = false;
27
+ for (const row of rows) if (owned.has(row.ppid) && !owned.has(row.pid)) { owned.add(row.pid); depths.set(row.pid, depths.get(row.ppid) + 1); changed = true; }
28
+ }
29
+ // Descendants first, while the root still anchors ownership.
30
+ for (const row of rows.filter(row => owned.has(row.pid) && (includeRoot || row.pid !== pid)).sort((a,b) => depths.get(b.pid) - depths.get(a.pid))) {
31
+ const live = (await snapshot()).find(item => item.pid === row.pid);
32
+ if (!live || live.startedAt !== row.startedAt || live.ppid !== row.ppid || live.command !== row.command) continue;
33
+ try { kill(row.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
34
+ }
35
+ }
36
+ async function isHostCommand(command) {
37
+ const match = String(command).replaceAll("\\", "/").match(/\s"?((?:\/|[A-Za-z]:\/).*?\/src\/desktop-host\.js)"?(?:\s|$)/);
38
+ if (!match) return false;
39
+ try { return JSON.parse(await readFile(join(dirname(dirname(match[1])), 'package.json'), 'utf8')).name === '@awak-app/simy-cli'; }
40
+ catch { return false; }
41
+ }
42
+ async function processSnapshot() {
43
+ if (process.platform === 'win32') {
44
+ const userPids = new Set((await windowsUserProcesses()).map(row => row.pid));
45
+ const { stdout } = await run(join(process.env.SystemRoot || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe'),
46
+ ['-NoProfile','-NonInteractive','-Command', 'Get-CimInstance Win32_Process | Where-Object { $_.CreationDate } | Select-Object @{n="pid";e={$_.ProcessId}},@{n="ppid";e={$_.ParentProcessId}},@{n="startedAt";e={$_.CreationDate.ToUniversalTime().ToString("o")}},@{n="command";e={$_.CommandLine}} | ConvertTo-Json -Compress'],
47
+ {timeout:15000,windowsHide:true,maxBuffer:4*1024*1024});
48
+ const parsed = JSON.parse(stdout || '[]');
49
+ return (Array.isArray(parsed) ? parsed : [parsed]).map(row => ({...row, eligible:userPids.has(row.pid)}));
50
+ }
51
+
52
+ const { stdout } = await run('/bin/ps', ['-ww', '-axo', 'pid=,ppid=,uid=,lstart=,command='], { timeout: 3000, maxBuffer: 4*1024*1024 });
53
+ return stdout.split('\n').flatMap(line => {
54
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+((?:\S+\s+){4}\S+)\s+(.+)$/);
55
+ return m && Number(m[3]) === process.getuid() ? [{ pid:Number(m[1]), ppid:Number(m[2]), startedAt:m[4], command:m[5] }] : [];
56
+ });
57
+ }
58
+
59
+ export async function stopSimyServices({ snapshot = processSnapshot, kill = process.kill.bind(process), currentPid = process.pid, home = process.env.SIMY_HOME } = {}) {
60
+ const rows = await snapshot();
61
+ // Custom SIMY_HOME is an isolated installation/test scope, never global quit.
62
+ let scopedOwner = null;
63
+ if (home && home !== join(homedir(), '.simy')) {
64
+ try { scopedOwner = JSON.parse(await readFile(join(home, 'desktop-app.lock/owner.json'), 'utf8')); }
65
+ catch { return; }
66
+ }
67
+ for (const row of rows) {
68
+ if (row.pid === currentPid || row.eligible === false || scopedOwner && (row.pid !== scopedOwner.pid || row.startedAt.replace(/\s+/g, " ") !== String(scopedOwner.startedAt).replace(/\s+/g, " "))) continue;
69
+ const host = await isHostCommand(row.command);
70
+ const legacy = parseLegacyAgentArguments(row.command);
71
+ let daemon = false;
72
+ if (legacy) {
73
+ try { daemon = JSON.parse(await readFile(join(dirname(dirname(legacy.entryPath)), 'package.json'), 'utf8')).name === '@awak-app/simy-cli'; } catch { }
74
+ }
75
+ if (host || daemon) await stopProcessTree(row, rows, { snapshot, kill });
76
+ }
77
+ }
78
+ if (process.argv[1]?.replaceAll('\\', '/')?.endsWith('/desktop-force-quit.js')) {
79
+ const stop = process.argv[2] === '--all-services' ? stopSimyServices() : stopOwnedDesktopHost(Number(process.argv[2]));
80
+ stop.then(() => process.exit(0)).catch(() => process.exit(1));
81
+ }
@@ -1,3 +1,4 @@
1
+ import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
1
2
  import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
2
3
  import { createActionDeadlineService } from './session-supervision/action-deadline.js';
3
4
  import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
@@ -55,11 +56,11 @@ function desktopActionError(action, error) {
55
56
  if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
56
57
  return "Could not save SQM settings. Check your SIMY connection and try again.";
57
58
  }
58
- return "Action could not finish. Refresh the status and try again; active tasks must finish before quitting.";
59
+ return "Action could not finish. Refresh the status and try again.";
59
60
  }
60
61
 
61
62
  export async function startDesktopHost({ input = process.stdin, output = process.stdout, env = process.env,
62
- dependencies = {}, exit = (code) => { process.exitCode = code; } } = {}) {
63
+ dependencies = {}, exit = (code) => process.exit(code) } = {}) {
63
64
  const emit = (event) => output.write(`${JSON.stringify(event)}\n`);
64
65
  // A heartbeat remains a complete native frame. Only its observation time is
65
66
  // volatile; reuse the encoded body after comparing freshly authorized data.
@@ -103,9 +104,12 @@ export async function startDesktopHost({ input = process.stdin, output = process
103
104
  return currentAccess;
104
105
  };
105
106
  const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
107
+ let stoppingForUpdate = false;
106
108
  const authorizeWork = async expectedScope => {
109
+ if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
107
110
  if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
108
111
  const access = await accessSnapshot();
112
+ if (closed || stoppingForUpdate) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
109
113
  requireDesktopAccess(access);
110
114
  const scope = accessScope(access);
111
115
  if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
@@ -259,17 +263,16 @@ export async function startDesktopHost({ input = process.stdin, output = process
259
263
  const stopAgent = async () => {
260
264
  clearInterval(timer);
261
265
  discoveryStopped = true;
262
- await stopDiscovery({ drain: true });
263
266
  encodedStatus = null; lastStatus = null;
264
267
  if (scheduledPublish) { clearTimeout(scheduledPublish.timer); scheduledPublish.resolve(); scheduledPublish = null; }
265
- await publishing?.catch(() => {});
266
- await actionAssociations.close();
267
- await automaticContinuation?.close();
268
- await analysisManager?.close();
269
- await managedSupervisor.close();
270
- await recordReader.close();
271
- await stopWorkflows();
272
- if (agent) await createAgentShutdown(agent)();
268
+ // Start every cancellation before draining discovery/network work. A hung
269
+ // reader must never postpone cancellation of an owned Provider process.
270
+ const results = await Promise.allSettled([stopDiscovery({ drain: true }), publishing,
271
+ actionAssociations.close(), automaticContinuation?.close(), analysisManager?.close(),
272
+ managedSupervisor.close(), recordReader.close(), stopWorkflows(),
273
+ agent ? createAgentShutdown(agent)() : undefined]);
274
+ const failure = results.find(result => result.status === "rejected");
275
+ if (failure) throw failure.reason;
273
276
  };
274
277
  const close = () => shuttingDown ||= (async () => {
275
278
  closed = true;
@@ -404,9 +407,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
404
407
  // Keep these commands ordered with each other; the agent still owns all
405
408
  // membership, active-work and nonce checks.
406
409
  let authorizationQueue = Promise.resolve();
410
+ // The initial My AI handoff must not wait for provider refresh/detail work,
411
+ // or hold up sign-out/account changes. Serialize tickets with each other:
412
+ // issuing another Supabase link can invalidate the previous one.
413
+ let myAiSessionQueue = Promise.resolve();
407
414
  // Saving/revoking opt-in must not queue behind provider or remote work.
408
415
  let preferenceQueue = Promise.resolve();
409
416
  let activeQueuedAction = null;
417
+ let controlQueue = Promise.resolve();
410
418
  commands = createInterface({ input, crlfDelay: Infinity });
411
419
  commands.on("line", (line) => {
412
420
  if (closed) return;
@@ -421,6 +429,29 @@ export async function startDesktopHost({ input = process.stdin, output = process
421
429
  if (closed) return;
422
430
  try {
423
431
  const action = command.action;
432
+ if (action === "quit") {
433
+ if (command.force !== true) {
434
+ const status = await agent.getDesktopStatus();
435
+ if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0) throw new Error("Tasks are still running. Confirm before quitting.");
436
+ }
437
+ // Cancel first, then acknowledge. The native app waits for the host
438
+ // exit; this deadline also covers a stuck remote flush/discovery.
439
+ const deadline = setTimeout(async () => {
440
+ emit({ type: "result", id: command.id, ok: true });
441
+ try { await (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid, { parentPid: process.ppid, includeRoot: false }); }
442
+ catch { /* Native ownership-checked fallback remains available. */ }
443
+ finally { exit(0); }
444
+ }, dependencies.quitTimeoutMs ?? 8000);
445
+ try { await close(); emit({ type: "result", id: command.id, ok: true }); }
446
+ finally { clearTimeout(deadline); exit(0); }
447
+ return;
448
+ }
449
+ if (action === "task_stop") {
450
+ if (command.force !== true) throw new Error("Confirm before stopping this task.");
451
+ await agent.stopDesktopTask(command.taskKey);
452
+ emit({ type: "result", id: command.id, ok: true });
453
+ void publish(); return;
454
+ }
424
455
  const access = await accessSnapshot();
425
456
  const commandScope = accessScope(access);
426
457
  if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
@@ -483,8 +514,17 @@ export async function startDesktopHost({ input = process.stdin, output = process
483
514
  }
484
515
  } else if (action === "check_update") await updater.checkNow();
485
516
  else if (action === "update") {
517
+ if (command.force === true) {
518
+ stoppingForUpdate = true; discoveryStopped = true;
519
+ void stopDiscovery();
520
+ // Abort dispatch immediately; existing Delivery Loop records retain
521
+ // their cancelled checkpoint so another host cannot silently resume.
522
+ await Promise.all([agent.stopDesktopTasks(), managedSupervisor.close(),
523
+ automaticContinuation?.close(), analysisManager?.close()]);
524
+ }
486
525
  if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
487
526
  else await updater.performAction();
527
+ if (command.force === true && ["failed", "error"].includes(updater.snapshot().state)) throw new Error("Update failed after stopping tasks.");
488
528
  } else if (action === "codex_login" || action === "claude_login") {
489
529
  const provider = action === "claude_login" ? "claude" : "codex";
490
530
  const login = dependencies[`${provider}Login`];
@@ -584,22 +624,23 @@ export async function startDesktopHost({ input = process.stdin, output = process
584
624
  session: command.session, taskId: command.taskId, questionId: command.questionId, answer: command.answer });
585
625
  await refreshSessions(true);
586
626
  }
587
- else if (action === "quit") {
588
- const status = await agent.getDesktopStatus();
589
- if ((status.tasks.activeCount + (status.sqmTasks?.activeCount || 0)) > 0 && command.force !== true) throw new Error("Tasks are still running. Confirm before quitting.");
590
- emit({ type: "result", id: command.id, ok: true });
591
- await close(); exit(0); return;
592
- } else throw new Error("Unsupported desktop action.");
627
+ else throw new Error("Unsupported desktop action.");
593
628
  emit({ type: "result", id: command.id, ok: true });
594
629
  await publish();
595
630
  } catch (error) {
596
- emit({ type: "result", id: command.id, ok: false, message: desktopActionError(command.action, error) });
631
+ emit({ type: "result", id: command.id, ok: false, message: command.action === "update" && stoppingForUpdate ? "Could not finish the update after stopping tasks. Reopen SIMY and retry the update." : desktopActionError(command.action, error) });
597
632
  }
598
633
  };
599
- if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
634
+ if (command.action === "quit") {
635
+ void execute();
636
+ } else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
600
637
  authorizationQueue = authorizationQueue.then(execute).catch(() => {});
638
+ } else if (command.action === "my_ai_session") {
639
+ myAiSessionQueue = myAiSessionQueue.then(execute).catch(() => {});
601
640
  } else if (command.action === "session_set_auto_continue") {
602
641
  preferenceQueue = preferenceQueue.then(execute).catch(() => {});
642
+ } else if (["task_stop", "delivery_stop"].includes(command.action) || command.action === "update" && command.force === true) {
643
+ controlQueue = controlQueue.then(execute).catch(() => {});
603
644
  } else {
604
645
  queue = queue.then(async () => {
605
646
  activeQueuedAction = command.action;
@@ -217,6 +217,11 @@ export function isUnauthorizedError(error) {
217
217
  );
218
218
  }
219
219
 
220
+ export function canStopDesktopTask(task, registryIndex) {
221
+ const state = task?.status || task?.snapshot?.state || task?.state || task?.control_state || task?.controlState || "unknown";
222
+ return registryIndex === 0 ? !INACTIVE_STATES.has(state) : ["queued", "running", "stopping"].includes(state);
223
+ }
224
+
220
225
  function taskStatusItem(task, registryIndex) {
221
226
  const state = bounded(
222
227
  String(
@@ -237,6 +242,7 @@ function taskStatusItem(task, registryIndex) {
237
242
  id: bounded(String(task?.id || `unknown-${registryIndex}`), MAX_LABEL_LENGTH),
238
243
  label: bounded(String(label), MAX_LABEL_LENGTH),
239
244
  state,
245
+ canStop: canStopDesktopTask(task, registryIndex),
240
246
  updatedAt:
241
247
  Date.parse(task?.updated_at || task?.snapshot?.updated_at || task?.startedAt || 0) || 0,
242
248
  };
@@ -1,3 +1,4 @@
1
+ import { signalExecutorProcess } from "./runner.js";
1
2
  import { randomUUID } from "node:crypto";
2
3
 
3
4
  import { resolveDesktopExecutorCommand } from "./desktop-executor.js";
@@ -68,6 +69,7 @@ export async function startDirectTask(
68
69
  permissionMode: task.permission_mode,
69
70
  environment,
70
71
  });
72
+ if (task.stop_requested || task.status !== "queued") return task;
71
73
  task.status = "running";
72
74
  task.invocation_count = 1;
73
75
  task.updated_at = new Date().toISOString();
@@ -120,8 +122,8 @@ export async function startDirectTask(
120
122
  error ||= terminalError;
121
123
  task.child = null;
122
124
  task.token_usage = { records: tokenRecords };
123
- task.status = error || exitCode !== 0 ? "failed" : "succeeded";
124
- task.error = error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
125
+ task.status = task.stop_requested ? "stopped" : error || exitCode !== 0 ? "failed" : "succeeded";
126
+ task.error = task.stop_requested ? null : error || (exitCode !== 0 ? `Provider exited with code ${exitCode}.` : null);
125
127
  task.result = task.output.at(-1) || rawStdout.join("").trim() || null;
126
128
  task.updated_at = new Date().toISOString();
127
129
  resolve(task);
@@ -149,3 +151,27 @@ export function directTaskSnapshot(task) {
149
151
  updated_at: task.updated_at,
150
152
  };
151
153
  }
154
+
155
+ // Only the child captured by this task may be signalled, including its owned tools.
156
+ export function stopDirectTask(task) {
157
+ if (!task) return Promise.resolve(task);
158
+ return task.stopPromise ||= stopOwnedDirectTask(task);
159
+ }
160
+ async function stopOwnedDirectTask(task) {
161
+ if (!task || !["queued", "running", "stopping"].includes(task.status)) return task;
162
+ task.stop_requested = true;
163
+ task.status = "stopping";
164
+ const child = task.child;
165
+ if (!child) { task.status = "stopped"; task.updated_at = new Date().toISOString(); return task; }
166
+ for (const signal of ["SIGINT", "SIGTERM", "SIGKILL"]) {
167
+ if (task.child !== child) return task;
168
+ await new Promise(resolve => {
169
+ const done = () => { clearTimeout(timer); child.off("close", done); resolve(); };
170
+ const timer = setTimeout(done, signal === "SIGKILL" ? 750 : 1500);
171
+ child.once("close", done);
172
+ signalExecutorProcess(child, signal);
173
+ });
174
+ }
175
+ if (task.child === child) throw new Error("The local task did not stop. Try quitting SIMY.");
176
+ return task;
177
+ }
@@ -1415,6 +1415,7 @@ export function createDurableLocalTaskWorker({
1415
1415
  workerId,
1416
1416
  leaseSeconds,
1417
1417
  });
1418
+ if (closed) { releaseWork(); return false; }
1418
1419
  const parentClaim = normalizeDurableLocalTaskExecutionClaimV2(
1419
1420
  parentEnvelope,
1420
1421
  { workerId, leaseSeconds },
@@ -1470,6 +1471,7 @@ export function createDurableLocalTaskWorker({
1470
1471
  leaseSeconds,
1471
1472
  turnId: requestedTurnId,
1472
1473
  });
1474
+ if (closed) { releaseWork(); return false; }
1473
1475
  if (!claim) {
1474
1476
  if (requestedTurnId) {
1475
1477
  throw workerError(
@@ -1539,6 +1541,7 @@ export function createDurableLocalTaskWorker({
1539
1541
  }
1540
1542
  lastStepDisposition = null;
1541
1543
  }
1544
+ if (closed) { releaseWork(); return false; }
1542
1545
  active = { claim, task: null };
1543
1546
  const executionPromise = executeClaim(claim, releaseWork)
1544
1547
  .catch((error) => {
@@ -1580,9 +1583,10 @@ export function createDurableLocalTaskWorker({
1580
1583
  },
1581
1584
  async close() {
1582
1585
  closed = true;
1586
+ // Cancel owned Provider work before waiting for any pending network operation.
1587
+ await Promise.all([active?.subtaskPool?.close?.().catch(() => undefined),
1588
+ active?.task ? stopTask(active.task).catch(() => undefined) : undefined]);
1583
1589
  await pollPromise;
1584
- await active?.subtaskPool?.close?.().catch(() => undefined);
1585
- if (active?.task) await stopTask(active.task).catch(() => undefined);
1586
1590
  await activeExecutionPromise;
1587
1591
  },
1588
1592
  };
@@ -1,3 +1,4 @@
1
+ import { signalExecutorProcess } from "../runner.js";
1
2
  import spawn from "cross-spawn";
2
3
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
4
  import { tmpdir } from "node:os";
@@ -44,7 +45,7 @@ export function createSqmLocalReviewApi({
44
45
  if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl is required");
45
46
 
46
47
  return {
47
- async request(body) {
48
+ async request(body, { signal, timeoutMs = requestTimeoutMs } = {}) {
48
49
  const connection = getConnection();
49
50
  if (!connection?.apiOrigin || !connection?.token) {
50
51
  throw new SqmLocalReviewError("The SIMY CLI session is not connected.", {
@@ -65,10 +66,11 @@ export function createSqmLocalReviewApi({
65
66
  { "Content-Type": "application/json" },
66
67
  ),
67
68
  body: JSON.stringify(body),
68
- signal: AbortSignal.timeout(requestTimeoutMs),
69
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
69
70
  },
70
71
  );
71
72
  } catch (error) {
73
+ if (signal?.aborted) throw providerFailure("analysis_cancelled");
72
74
  throw new SqmLocalReviewError(`SIMY Web could not be reached: ${errorMessage(error)}`, {
73
75
  code: "sqm_local_review_gateway_unavailable",
74
76
  });
@@ -111,6 +113,7 @@ export function createSqmLocalReviewWorker({
111
113
  let activeJobId = null;
112
114
  let pollPromise = null;
113
115
  let closed = false;
116
+ let reviewController = null;
114
117
  let issue = null, pausedEnvironment = null, pauseReason = null;
115
118
  let issueContext = null;
116
119
  const failureDetails = new WeakMap();
@@ -159,7 +162,9 @@ export function createSqmLocalReviewWorker({
159
162
  let nextRequest = request;
160
163
  for (let attempt = 0; ; attempt += 1) {
161
164
  setStage(false);
165
+ if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
162
166
  const review = await runReview(nextRequest, provider, reviewOptions);
167
+ if (reviewOptions.signal?.aborted) throw providerFailure("analysis_cancelled");
163
168
  setStage(true);
164
169
  try {
165
170
  return { review, response: await submit(review) };
@@ -187,12 +192,14 @@ export function createSqmLocalReviewWorker({
187
192
  heartbeatTimer = setInterval(heartbeat, heartbeatIntervalMs);
188
193
  heartbeatTimer.unref?.();
189
194
  let stage = "analysis";
190
- const reviewOptions = { providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
191
- beforeLeaseDispatch: () => api.request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
195
+ const signal = reviewController?.signal;
196
+ const request = body => api.request(body, { signal });
197
+ const reviewOptions = { signal, providerReportingSupported: claim.capabilities?.reported_executor_provider === true,
198
+ beforeLeaseDispatch: () => request({ action: "heartbeat", ...leaseBody(claim, provider) }) };
192
199
  const providerReport = (review, selectedProvider = provider) => claim.capabilities?.reported_executor_provider === true
193
200
  ? { reported_executor_provider: review.provider || selectedProvider } : {};
194
201
  try {
195
- const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => api.request({
202
+ const { review: analysis, response: advanced } = await validatedReview(claim.request, provider, "invalid_sqm_analysis_output", analysis => request({
196
203
  action: "advance",
197
204
  ...leaseBody(claim, provider),
198
205
  analysis_output: analysis.output,
@@ -204,7 +211,7 @@ export function createSqmLocalReviewWorker({
204
211
  if (item) item.provider = analysisProvider;
205
212
  if (advanced.completed) { await updateTask(claim.job_id, "completed", historyScope); return advanced; }
206
213
  stage = "adjudication";
207
- const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => api.request({
214
+ const { review: adjudication, response: completed } = await validatedReview(advanced.request, analysisProvider, "invalid_sqm_adjudication_output", adjudication => request({
208
215
  action: "complete",
209
216
  ...leaseBody(claim, provider),
210
217
  analysis_output: analysis.output,
@@ -223,11 +230,11 @@ export function createSqmLocalReviewWorker({
223
230
  if (!failureReported) {
224
231
  try {
225
232
  const report = await api.request({ action: "fail", ...leaseBody(claim, provider),
226
- error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) });
233
+ error_stage: failure.stage, error_code: failure.code, ...(failure.status ? { error_status: failure.status } : {}) }, failure.code === "analysis_cancelled" ? { timeoutMs: 3000 } : undefined);
227
234
  failureReported = report?.recorded === true;
228
235
  } catch { /* Keep the original failure even when reporting fails. */ }
229
236
  }
230
- await updateTask(claim.job_id, "failed", historyScope, failure, failureReported);
237
+ await updateTask(claim.job_id, failure.code === "analysis_cancelled" ? "cancelled" : "failed", historyScope, failure, failureReported);
231
238
  throw error;
232
239
  } finally {
233
240
  if (heartbeatTimer) clearInterval(heartbeatTimer);
@@ -242,7 +249,7 @@ export function createSqmLocalReviewWorker({
242
249
  return { active_job_id: activeJobId, polling: Boolean(pollPromise), closed, paused: pausedEnvironment !== null, pause_reason: pauseReason, issue: issueContext === contextKey(getProvider(), getHistoryScope()) ? issue : null,
243
250
  ...(pause ? { provider_pause: { code: pause.code, status: pause.status, retry_at: new Date(pause.retryAt).toISOString(), message: pause.message } } : {}) };
244
251
  },
245
- desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item })) }; },
252
+ desktopTasks() { return { activeCount: activeJobId ? 1 : 0, items: visibleHistory().map(item => ({ ...item, canStop: item.id === activeJobId })) }; },
246
253
  desktopTaskDetail(key) {
247
254
  const item = visibleHistory().find(item => item.key === key);
248
255
  return item ? { key, instruction: `SQM local review · ${item.provider}`, result: item.state === "completed" ? "SQM review submitted." : "", error: item.error || "", links: item.webUrl ? [{ label: "Analysis details", url: item.webUrl }] : [] } : null;
@@ -266,13 +273,14 @@ export function createSqmLocalReviewWorker({
266
273
  const releaseWork = beginWork?.();
267
274
  if (!releaseWork) return Promise.resolve(null);
268
275
  const workspaceUrl = getWorkspaceUrl();
276
+ reviewController = new AbortController();
269
277
  pollPromise = (async () => {
270
278
  const claim = await api.request({
271
279
  action: "claim",
272
280
  executor_provider: provider,
273
281
  worker_id: workerId,
274
282
  lease_seconds: leaseSeconds,
275
- });
283
+ }, { signal: reviewController.signal });
276
284
  if (issue) { issue = null; onChange(); }
277
285
  if (!claim?.claimed) {
278
286
  // A successful empty claim proves SIMY access recovered, but it says
@@ -285,6 +293,7 @@ export function createSqmLocalReviewWorker({
285
293
  return result;
286
294
  })()
287
295
  .catch(async (error) => {
296
+ if (error.code === "analysis_cancelled") return null;
288
297
  pauseForFailure(error, provider, historyScope, recoveryKey);
289
298
  if (error.jobId && /^[a-f0-9-]{36}$/i.test(error.jobId) && !history.some(item => item.id === error.jobId)) {
290
299
  const scope = historyScope;
@@ -305,13 +314,20 @@ export function createSqmLocalReviewWorker({
305
314
  })
306
315
  .finally(() => {
307
316
  pollPromise = null;
317
+ reviewController = null;
308
318
  releaseWork();
309
319
  onChange();
310
320
  });
311
321
  return pollPromise;
312
322
  },
323
+ async stop(key) {
324
+ if (key !== `sqm:${activeJobId}` || !activeJobId) throw new Error("This SQM task is no longer running.");
325
+ reviewController?.abort();
326
+ await pollPromise;
327
+ },
313
328
  async close() {
314
329
  closed = true;
330
+ reviewController?.abort();
315
331
  await pollPromise;
316
332
  },
317
333
  };
@@ -494,7 +510,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
494
510
  let child;
495
511
  try {
496
512
  child = spawnImpl(command, args, {
497
- env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
513
+ env: safeReviewEnvironment(environment, provider), cwd, windowsHide: true, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"],
498
514
  });
499
515
  } catch { reject(providerFailure("provider_spawn_failed", { model })); return; }
500
516
  let stdout = "", stderrPending = "", outputBytes = 0;
@@ -503,9 +519,9 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, cwd
503
519
  const halt = error => {
504
520
  if (settled || terminalError) return;
505
521
  terminalError = error;
506
- child.kill?.("SIGTERM");
522
+ signalExecutorProcess(child, "SIGTERM");
507
523
  if (!settled) killTimer = setTimeout(() => {
508
- child.kill?.("SIGKILL");
524
+ signalExecutorProcess(child, "SIGKILL");
509
525
  // Keep the original timeout/cancellation if the process API never
510
526
  // acknowledges termination; do not replace it with an opaque error.
511
527
  if (!settled) killTimer = setTimeout(() => finish(terminalError), terminationGraceMs);