programmatic subagents
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(pi-mill): monitor canonical runs and harden detached worker launch

+235 -209
+23 -12
packages/cli/src/public/index.api.ts
··· 116 116 const workerPidPath = (runsDirectory: string, runId: string): string => 117 117 joinPath(joinPath(runsDirectory, runId), "worker.pid"); 118 118 119 - const launchDetachedWorker = async (input: LaunchWorkerInput): Promise<void> => { 120 - const workerCommand = PlatformCommand.make( 121 - process.execPath, 122 - "run", 123 - millBinPath, 119 + const buildWorkerCommandArguments = ( 120 + hasSourceEntrypoint: boolean, 121 + input: LaunchWorkerInput, 122 + ): ReadonlyArray<string> => { 123 + const workerArguments = [ 124 124 "_worker", 125 125 "--run-id", 126 126 input.runId, ··· 132 132 input.driverName, 133 133 "--executor", 134 134 input.executorName, 135 - ).pipe( 136 - PlatformCommand.workingDirectory(input.cwd), 137 - PlatformCommand.stdin("ignore"), 138 - PlatformCommand.stdout("ignore"), 139 - PlatformCommand.stderr("ignore"), 140 - ); 135 + ]; 136 + 137 + return hasSourceEntrypoint ? ["run", millBinPath, ...workerArguments] : workerArguments; 138 + }; 141 139 140 + const launchDetachedWorker = async (input: LaunchWorkerInput): Promise<void> => { 142 141 await runWithBunContext( 143 142 Effect.gen(function* () { 143 + const fileSystem = yield* FileSystem.FileSystem; 144 + const hasSourceEntrypoint = yield* fileSystem.exists(millBinPath); 145 + 146 + const workerCommand = PlatformCommand.make( 147 + process.execPath, 148 + ...buildWorkerCommandArguments(hasSourceEntrypoint, input), 149 + ).pipe( 150 + PlatformCommand.workingDirectory(input.cwd), 151 + PlatformCommand.stdin("ignore"), 152 + PlatformCommand.stdout("ignore"), 153 + PlatformCommand.stderr("ignore"), 154 + ); 155 + 144 156 const detachedScope = yield* Scope.make(); 145 157 const processHandle = yield* Scope.extend( 146 158 PlatformCommand.start(workerCommand), 147 159 detachedScope, 148 160 ); 149 - const fileSystem = yield* FileSystem.FileSystem; 150 161 const pidPath = workerPidPath(input.runsDirectory, input.runId); 151 162 const runDirectory = pidPath.slice(0, pidPath.lastIndexOf("/")); 152 163
+4
packages/pi-mill/README.md
··· 64 64 65 65 Each `mill.spawn()` submits an async mill run (`mill run --json`) and then follows completion via mill APIs (`wait` + `inspect`). Model selection, driver routing, and execution behavior all come from your mill configuration. 66 66 67 + By default, mill run storage uses mill's global default (`~/.mill/runs`) unless you explicitly pass `--runs-dir` (or set `millRunsDir`). 68 + 69 + pi-mill monitor views are built from canonical mill runs in `~/.mill/runs` (filtered to runs tagged with `metadata.source = "pi-mill"`). 70 + 67 71 Runs are **async by default** — the tool returns a `runId` immediately and delivers results via notification when complete. 68 72 69 73 ## Monitoring
+2
packages/pi-mill/executors/program-executor.ts
··· 173 173 onUpdate?: (summary: RunSummary) => void; 174 174 signal?: AbortSignal; 175 175 parentSessionPath?: string; 176 + piSessionKey?: string; 176 177 sessionDir?: string; 177 178 skipConfirmation?: boolean; 178 179 millCommand?: string; ··· 234 235 emit("running"); 235 236 }, 236 237 parentSessionPath: input.parentSessionPath, 238 + piSessionKey: input.piSessionKey, 237 239 sessionDir: input.sessionDir, 238 240 millCommand: input.millCommand, 239 241 millArgs: input.millArgs,
+13 -6
packages/pi-mill/index.ts
··· 222 222 millCommand: "mill", 223 223 /** Optional static args prepended to every mill invocation. */ 224 224 millArgs: [], 225 - /** Optional runs-dir override passed to mill commands (discovery + child runs). */ 225 + /** Optional runs-dir override passed to mill commands. */ 226 226 millRunsDir: undefined, 227 227 /** Extra text appended to the tool description. Use for model selection hints, project conventions, etc. */ 228 228 prompt: ··· 244 244 const observability = new ObservabilityStore(); 245 245 const registry = new RunRegistry(); 246 246 const widget = new FactoryWidget(); 247 - // Model discovery is deferred to avoid a boot cycle: 248 - // pi → mill discovery → pi --list-models → pi (with extensions) → mill discovery → … 247 + // Model enumeration is read from pi settings to avoid boot-time recursion between 248 + // pi extension initialization and subagent runtime startup. 249 249 const enabledModels = readEnabledModelsFallback(); 250 250 const modelsText = 251 - enabledModels.length > 0 ? enabledModels.join(", ") : "(use mill discovery to list)"; 251 + enabledModels.length > 0 252 + ? enabledModels.join(", ") 253 + : "(set enabledModels in ~/.pi/agent/settings.json)"; 252 254 253 255 // Keep a reference to the current context for widget/notification updates 254 256 let currentCtx: ExtensionContext | undefined; ··· 382 384 currentCtx = ctx; 383 385 const params = validateParams(rawParams); 384 386 const runId = generateRunId(); 385 - const piSessionDir = ctx.sessionManager.getSessionDir() ?? undefined; 386 - observability.createRun(runId, true, piSessionDir); 387 + const rawSessionDir = ctx.sessionManager.getSessionDir() ?? ctx.cwd; 388 + const piSessionKey = cwdToSessionDir(rawSessionDir); 389 + 390 + // Keep local observability in-memory/tmp only. Canonical persisted runs live under 391 + // mill's own global run store (~/.mill/runs). 392 + observability.createRun(runId, false); 387 393 observability.setStatus(runId, "running", "run:start"); 388 394 389 395 const parentSessionPath = ctx.sessionManager.getSessionFile() ?? undefined; ··· 433 439 onUpdate: emitUpdate, 434 440 signal: abort.signal, 435 441 parentSessionPath, 442 + piSessionKey, 436 443 sessionDir, 437 444 skipConfirmation: true, 438 445 millCommand: config.millCommand,
+4 -4
packages/pi-mill/observability.ts
··· 24 24 export class ObservabilityStore { 25 25 private readonly runs = new Map<string, RunRecord>(); 26 26 27 - createRun(runId: string, withArtifacts: boolean, sessionDir?: string): RunRecord { 27 + createRun(runId: string, withArtifacts: boolean, _sessionDir?: string): RunRecord { 28 28 const record: RunRecord = { 29 29 runId, 30 30 status: "queued", ··· 32 32 events: [], 33 33 artifacts: [], 34 34 }; 35 + 35 36 if (withArtifacts) { 36 - const base = sessionDir 37 - ? path.join(sessionDir, ".mill", runId) 38 - : fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-observe-")); 37 + const base = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-observe-")); 39 38 fs.mkdirSync(base, { recursive: true }); 40 39 record.artifactsDir = base; 41 40 } 41 + 42 42 this.runs.set(runId, record); 43 43 return record; 44 44 }
+5
packages/pi-mill/runtime.ts
··· 114 114 obs: ObservabilityStore; 115 115 onProgress?: (result: ExecutionResult) => void; 116 116 parentSessionPath?: string; 117 + piSessionKey?: string; 117 118 sessionDir?: string; 118 119 millCommand: string; 119 120 millArgs: string[]; ··· 502 503 source: "pi-mill", 503 504 parentRunId: input.runId, 504 505 parentTaskId: input.taskId, 506 + parentTask: input.prompt, 505 507 parentAgent: input.agent, 508 + piSessionKey: input.piSessionKey, 506 509 }); 507 510 508 511 const submitArgs = [ ··· 736 739 onTaskUpdate?: (result: ExecutionResult) => void; 737 740 defaultSignal?: AbortSignal; 738 741 parentSessionPath?: string; 742 + piSessionKey?: string; 739 743 sessionDir?: string; 740 744 millCommand?: string; 741 745 millArgs?: string[]; ··· 801 805 obs, 802 806 onProgress: (partial) => options?.onTaskUpdate?.(partial), 803 807 parentSessionPath: options?.parentSessionPath, 808 + piSessionKey: options?.piSessionKey, 804 809 sessionDir: options?.sessionDir, 805 810 millCommand, 806 811 millArgs,
+184 -187
packages/pi-mill/scanner.ts
··· 1 1 import { spawnSync } from "node:child_process"; 2 2 import * as fs from "node:fs"; 3 - import * as path from "node:path"; 4 3 import * as os from "node:os"; 4 + import * as path from "node:path"; 5 5 import type { RunRecord, RunStatus } from "./registry.js"; 6 - import type { RunSummary, ExecutionResult, UsageStats } from "./types.js"; 6 + import type { ExecutionResult, RunSummary, UsageStats } from "./types.js"; 7 7 8 8 /** 9 9 * Filesystem scanner for standalone --mill mode. 10 - * Reads run.json files from ~/.pi/agent/sessions/<session-dir>/.mill/<run-id>/run.json 10 + * 11 + * Source of truth: canonical mill run store (~/.mill/runs). 12 + * We only surface runs created by pi-mill (metadata.source === "pi-mill"). 11 13 */ 12 14 13 - /** Convert a cwd path to the session directory name pi uses. */ 15 + /** Convert a cwd (or session path) to the stable session directory key. */ 14 16 export function cwdToSessionDir(cwd: string): string { 15 - // /Users/foo/Code/project → --Users-foo-Code-project-- 16 - return "--" + cwd.slice(1).replace(/\//g, "-") + "--"; 17 + const baseName = path.basename(cwd); 18 + if (baseName.startsWith("--") && baseName.endsWith("--")) { 19 + return baseName; 20 + } 21 + 22 + const normalized = cwd.startsWith("/") ? cwd.slice(1) : cwd; 23 + return `--${normalized.replace(/\//g, "-")}--`; 24 + } 25 + 26 + interface CanonicalRunPaths { 27 + runDir?: string; 28 + runFile?: string; 29 + eventsFile?: string; 30 + resultFile?: string; 17 31 } 18 32 19 - /** 20 - * Shape of run.json on disk (written by writeRunJson in index.ts). 21 - * Convention: status="running" is advisory; scanner reconciles it against canonical mill status. 22 - */ 23 - interface RunJsonData { 24 - runId: string; 33 + interface CanonicalRunJson { 34 + id?: string; 35 + runId?: string; 25 36 status?: string; 26 - task?: string; 27 - startedAt?: number; 28 - completedAt?: number; 29 - reconciledAt?: number; 37 + createdAt?: string; 38 + updatedAt?: string; 39 + metadata?: Record<string, string>; 40 + paths?: CanonicalRunPaths; 30 41 mill?: { 31 42 command?: string; 32 43 args?: string[]; 33 44 runsDir?: string; 34 45 }; 35 - results?: Array<{ 36 - agent: string; 37 - task: string; 38 - model?: string; 39 - exitCode: number; 40 - text: string; 41 - sessionPath?: string; 42 - usage?: UsageStats; 43 - stopReason?: string; 44 - errorMessage?: string; 45 - }>; 46 - error?: { code: string; message: string; recoverable: boolean }; 47 46 } 48 47 49 - const parseJsonObjectFromText = (text: string): Record<string, unknown> | undefined => { 50 - const lines = text 51 - .split("\n") 52 - .map((line) => line.trim()) 53 - .filter((line) => line.length > 0) 54 - .reverse(); 48 + interface CanonicalSpawnJson { 49 + agent?: string; 50 + model?: string; 51 + exitCode?: number; 52 + text?: string; 53 + sessionRef?: string; 54 + stopReason?: string; 55 + errorMessage?: string; 56 + } 55 57 56 - for (const line of lines) { 57 - try { 58 - const parsed = JSON.parse(line) as unknown; 59 - if (typeof parsed === "object" && parsed !== null) { 60 - return parsed as Record<string, unknown>; 61 - } 62 - } catch { 63 - continue; 64 - } 65 - } 58 + interface CanonicalResultJson { 59 + status?: string; 60 + errorMessage?: string; 61 + spawns?: ReadonlyArray<CanonicalSpawnJson>; 62 + } 66 63 67 - return undefined; 64 + const DEFAULT_USAGE: UsageStats = { 65 + input: 0, 66 + output: 0, 67 + cacheRead: 0, 68 + cacheWrite: 0, 69 + cost: 0, 70 + contextTokens: 0, 71 + turns: 0, 68 72 }; 69 73 70 74 const normalizeRunStatus = (status: string | undefined): RunStatus => { 71 - if (status === undefined) { 72 - return "done"; 73 - } 74 - 75 75 switch (status) { 76 76 case "done": 77 77 case "complete": ··· 82 82 return "cancelled"; 83 83 case "running": 84 84 case "pending": 85 - return "running"; 86 85 default: 87 86 return "running"; 88 87 } 89 88 }; 90 89 91 - const extractStatusFromMillPayload = (payload: Record<string, unknown>): string | undefined => { 92 - const direct = payload.status; 93 - if (typeof direct === "string" && direct.length > 0) { 94 - return direct; 90 + const toEpochMillis = (value: string | undefined): number => { 91 + if (value === undefined) { 92 + return Date.now(); 95 93 } 96 94 97 - const nestedRun = payload.run; 98 - if (typeof nestedRun === "object" && nestedRun !== null) { 99 - const nestedStatus = (nestedRun as { status?: unknown }).status; 100 - if (typeof nestedStatus === "string" && nestedStatus.length > 0) { 101 - return nestedStatus; 95 + const parsed = Date.parse(value); 96 + return Number.isFinite(parsed) ? parsed : Date.now(); 97 + }; 98 + 99 + const readJson = <T>(filePath: string): T | undefined => { 100 + try { 101 + if (!fs.existsSync(filePath)) { 102 + return undefined; 102 103 } 104 + const raw = fs.readFileSync(filePath, "utf-8"); 105 + return JSON.parse(raw) as T; 106 + } catch { 107 + return undefined; 103 108 } 104 - 105 - return undefined; 106 109 }; 107 110 108 - const reconcileRunningStatus = (runJsonPath: string, data: RunJsonData): RunJsonData => { 109 - if (normalizeRunStatus(data.status) !== "running") { 110 - return data; 111 + const toExecutionResults = ( 112 + spawns: ReadonlyArray<CanonicalSpawnJson> | undefined, 113 + fallbackTask: string, 114 + ): ExecutionResult[] => { 115 + if (!Array.isArray(spawns)) { 116 + return []; 111 117 } 112 118 113 - if (typeof data.runId !== "string" || data.runId.length === 0) { 114 - return data; 115 - } 119 + return spawns.map((spawn, index) => ({ 120 + taskId: `spawn-${index + 1}`, 121 + agent: typeof spawn.agent === "string" && spawn.agent.length > 0 ? spawn.agent : "unknown", 122 + task: fallbackTask, 123 + exitCode: typeof spawn.exitCode === "number" ? spawn.exitCode : 0, 124 + messages: [], 125 + stderr: "", 126 + usage: DEFAULT_USAGE, 127 + model: spawn.model, 128 + stopReason: spawn.stopReason, 129 + errorMessage: spawn.errorMessage, 130 + text: spawn.text ?? "", 131 + sessionPath: spawn.sessionRef, 132 + })); 133 + }; 116 134 117 - const command = data.mill?.command?.trim() || "mill"; 118 - const args = [...(data.mill?.args ?? []), "status", data.runId, "--json"]; 135 + const parseCanonicalRun = ( 136 + runDir: string, 137 + sessionDirName?: string, 138 + ): Omit<RunRecord, "promise" | "abort"> | undefined => { 139 + const runJsonPath = path.join(runDir, "run.json"); 140 + const runJson = readJson<CanonicalRunJson>(runJsonPath); 119 141 120 - if (data.mill?.runsDir && data.mill.runsDir.trim().length > 0) { 121 - args.push("--runs-dir", data.mill.runsDir); 142 + if (!runJson) { 143 + return undefined; 122 144 } 123 145 124 - const result = spawnSync(command, args, { 125 - stdio: ["ignore", "pipe", "pipe"], 126 - shell: false, 127 - encoding: "utf-8", 128 - }); 146 + const runId = 147 + typeof runJson.id === "string" 148 + ? runJson.id 149 + : typeof runJson.runId === "string" 150 + ? runJson.runId 151 + : undefined; 129 152 130 - if (result.status !== 0) { 131 - return data; 153 + if (!runId || runId.length === 0) { 154 + return undefined; 132 155 } 133 156 134 - const payload = parseJsonObjectFromText(`${result.stdout}\n${result.stderr}`); 135 - if (!payload) { 136 - return data; 157 + const metadata = runJson.metadata ?? {}; 158 + const source = metadata.source; 159 + 160 + if (source !== "pi-mill") { 161 + return undefined; 137 162 } 138 163 139 - const canonicalStatus = normalizeRunStatus(extractStatusFromMillPayload(payload)); 140 - 141 - if (canonicalStatus === "running") { 142 - return data; 164 + if (sessionDirName !== undefined) { 165 + const sessionKey = metadata.piSessionKey; 166 + if (typeof sessionKey === "string" && sessionKey.length > 0 && sessionKey !== sessionDirName) { 167 + return undefined; 168 + } 143 169 } 144 170 145 - const reconciled: RunJsonData = { 146 - ...data, 147 - status: canonicalStatus, 148 - completedAt: data.completedAt ?? Date.now(), 149 - reconciledAt: Date.now(), 150 - }; 171 + const resultPath = 172 + typeof runJson.paths?.resultFile === "string" && runJson.paths.resultFile.length > 0 173 + ? runJson.paths.resultFile 174 + : path.join(runDir, "result.json"); 175 + const resultJson = readJson<CanonicalResultJson>(resultPath); 151 176 152 - try { 153 - fs.writeFileSync(runJsonPath, `${JSON.stringify(reconciled, null, 2)}\n`, "utf-8"); 154 - } catch { 155 - // best effort persistence; return reconciled in-memory snapshot regardless 156 - } 177 + const status = normalizeRunStatus(runJson.status ?? resultJson?.status); 178 + const startedAt = toEpochMillis(runJson.createdAt); 179 + const completedAt = status === "running" ? undefined : toEpochMillis(runJson.updatedAt); 157 180 158 - return reconciled; 159 - }; 181 + const fallbackTask = metadata.parentTask ?? metadata.programTask ?? metadata.parentTaskId ?? ""; 182 + const results = toExecutionResults(resultJson?.spawns, fallbackTask); 160 183 161 - /** Parse a single run.json into a RunRecord (without promise/abort). */ 162 - function parseRunJson( 163 - data: RunJsonData, 164 - artifactsDir: string, 165 - ): Omit<RunRecord, "promise" | "abort"> { 166 - const status: RunStatus = normalizeRunStatus(data.status); 167 - const results: ExecutionResult[] = (data.results ?? []).map((r) => ({ 168 - taskId: "", 169 - agent: r.agent ?? "unknown", 170 - task: r.task ?? "", 171 - exitCode: r.exitCode ?? -1, 172 - messages: [], 173 - stderr: "", 174 - usage: r.usage ?? { 175 - input: 0, 176 - output: 0, 177 - cacheRead: 0, 178 - cacheWrite: 0, 179 - cost: 0, 180 - contextTokens: 0, 181 - turns: 0, 182 - }, 183 - model: r.model, 184 - stopReason: r.stopReason, 185 - errorMessage: r.errorMessage, 186 - text: r.text ?? "", 187 - sessionPath: r.sessionPath, 188 - })); 184 + const errorMessage = resultJson?.errorMessage; 189 185 190 186 const summary: RunSummary = { 191 - runId: data.runId, 187 + runId, 192 188 status, 193 189 results, 194 - error: data.error as RunSummary["error"], 195 - metadata: { 196 - task: data.task, 197 - millCommand: data.mill?.command, 198 - millArgs: data.mill?.args, 199 - millRunsDir: data.mill?.runsDir, 200 - }, 190 + error: 191 + status === "failed" 192 + ? { 193 + code: "RUNTIME", 194 + message: errorMessage ?? "Run failed.", 195 + recoverable: false, 196 + } 197 + : undefined, 198 + metadata, 201 199 observability: { 202 200 status, 203 201 events: [], 204 - artifacts: [], 205 - artifactsDir, 206 - startedAt: data.startedAt ?? Date.now(), 207 - endedAt: data.completedAt, 202 + artifacts: fs.existsSync(resultPath) ? [runJsonPath, resultPath] : [runJsonPath], 203 + artifactsDir: runDir, 204 + startedAt, 205 + endedAt: completedAt, 208 206 }, 209 207 }; 210 208 211 209 return { 212 - runId: data.runId, 210 + runId, 213 211 status, 214 212 summary, 215 - startedAt: data.startedAt ?? Date.now(), 216 - completedAt: data.completedAt, 213 + startedAt, 214 + completedAt, 217 215 acknowledged: true, 218 - task: data.task, 216 + task: metadata.programTask ?? metadata.parentTask ?? metadata.parentTaskId, 219 217 }; 218 + }; 219 + 220 + /** Get the canonical mill runs base directory. */ 221 + export function getRunsBase(): string { 222 + return path.join(os.homedir(), ".mill", "runs"); 220 223 } 221 224 222 - /** Get the sessions base directory. */ 223 - export function getSessionsBase(): string { 224 - return path.join(os.homedir(), ".pi", "agent", "sessions"); 225 - } 225 + /** Backward-compatible alias for existing imports. */ 226 + export const getSessionsBase = getRunsBase; 226 227 227 228 /** 228 - * Scan all run.json files under a session's .mill directory. 229 - * If sessionDirName is provided, scans only that session. 230 - * If not provided, scans all sessions. 229 + * Scan canonical mill runs and return pi-mill owned records. 230 + * Optional sessionDirName filters by metadata.piSessionKey when present. 231 231 */ 232 232 export function scanRuns( 233 - sessionsBase: string, 233 + runsBase: string, 234 234 sessionDirName?: string, 235 235 ): Omit<RunRecord, "promise" | "abort">[] { 236 236 const records: Omit<RunRecord, "promise" | "abort">[] = []; 237 237 238 - const sessionDirs = sessionDirName ? [sessionDirName] : listSessionDirs(sessionsBase); 239 - 240 - for (const dir of sessionDirs) { 241 - const millDir = path.join(sessionsBase, dir, ".mill"); 242 - if (!fs.existsSync(millDir)) continue; 238 + const runDirs = listRunDirs(runsBase); 243 239 244 - try { 245 - for (const entry of fs.readdirSync(millDir)) { 246 - const runJsonPath = path.join(millDir, entry, "run.json"); 247 - if (!fs.existsSync(runJsonPath)) continue; 248 - try { 249 - const raw = fs.readFileSync(runJsonPath, "utf-8"); 250 - const data: RunJsonData = JSON.parse(raw); 251 - const reconciled = reconcileRunningStatus(runJsonPath, data); 252 - records.push(parseRunJson(reconciled, path.join(millDir, entry))); 253 - } catch { 254 - // Skip malformed run.json files 255 - } 256 - } 257 - } catch { 258 - // Skip inaccessible directories 240 + for (const runDir of runDirs) { 241 + const parsed = parseCanonicalRun(runDir, sessionDirName); 242 + if (parsed) { 243 + records.push(parsed); 259 244 } 260 245 } 261 246 ··· 284 269 } 285 270 286 271 /** 287 - * Cancel a run using metadata from run.json (preferred), with PID-kill fallback. 288 - * Returns the number of cancellation actions attempted. 272 + * Cancel a run by reading run.json from the selected artifacts directory. 273 + * Supports canonical mill run.json (`id`) and legacy pi-mill marker run.json (`runId`). 289 274 */ 290 275 export function cancelRunByPidFiles(artifactsDir: string): number { 291 276 let cancelled = 0; ··· 293 278 try { 294 279 const runJsonPath = path.join(artifactsDir, "run.json"); 295 280 if (fs.existsSync(runJsonPath)) { 296 - const data: RunJsonData = JSON.parse(fs.readFileSync(runJsonPath, "utf-8")); 297 - if (typeof data.runId === "string" && data.runId.length > 0) { 298 - const command = data.mill?.command?.trim() || "mill"; 299 - const args = [...(data.mill?.args ?? []), "cancel", data.runId]; 300 - if (data.mill?.runsDir && data.mill.runsDir.trim().length > 0) { 281 + const data = readJson<CanonicalRunJson>(runJsonPath); 282 + const runId = 283 + data && typeof data.id === "string" 284 + ? data.id 285 + : data && typeof data.runId === "string" 286 + ? data.runId 287 + : undefined; 288 + 289 + if (runId && runId.length > 0) { 290 + const command = data?.mill?.command?.trim() || "mill"; 291 + const args = [...(data?.mill?.args ?? []), "cancel", runId]; 292 + 293 + if (data?.mill?.runsDir && data.mill.runsDir.trim().length > 0) { 301 294 args.push("--runs-dir", data.mill.runsDir); 302 295 } 303 296 ··· 331 324 return cancelled; 332 325 } 333 326 334 - /** List all session directory names under the sessions base. */ 335 - function listSessionDirs(sessionsBase: string): string[] { 327 + /** List canonical run directories under the runs base. */ 328 + function listRunDirs(runsBase: string): string[] { 336 329 try { 337 - if (!fs.existsSync(sessionsBase)) return []; 338 - return fs.readdirSync(sessionsBase).filter((d) => { 339 - try { 340 - return fs.statSync(path.join(sessionsBase, d)).isDirectory(); 341 - } catch { 342 - return false; 343 - } 344 - }); 330 + if (!fs.existsSync(runsBase)) return []; 331 + 332 + return fs 333 + .readdirSync(runsBase) 334 + .map((entry) => path.join(runsBase, entry)) 335 + .filter((entryPath) => { 336 + try { 337 + return fs.statSync(entryPath).isDirectory(); 338 + } catch { 339 + return false; 340 + } 341 + }); 345 342 } catch { 346 343 return []; 347 344 }