pi-orchestrator-config 4.8.0 → 4.8.2

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
@@ -14,11 +14,10 @@ Single extension that provides:
14
14
  | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
15
  | **Subagent tool** | Delegate tasks to specialist agents (single, parallel, chain, async modes); optional `model` param for model override |
16
16
  | **`list_models`** | List available models and providers for subagent model override |
17
- | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`, `/async-kill`). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
17
+ | **Async background agents** | Spawn agents in background with `async: true` — results surface automatically when complete. Fullscreen overlay dashboard with live output, keyboard nav, and kill support (`/async-status`: `x` kills, `Space` multi-selects, `a` kills all). On **acpx** parents, optional async is coerced to sync; dream/cron need `internal_operations_provider` + `internal_operations_model`. `cli-*` providers support async natively (see `dev-docs/cli-provider.md`) |
18
18
  | **CLI / ACPX providers** | Optional `cli_agents` / `acpx_agents` register `cli-*` / `acpx-*` via native `createProvider` (pi ≥ 0.84): `/login cli-<agent>` or `/login acpx-<agent>`, model refresh, filter when unavailable. Missing context window / maxTokens / cost filled from models.dev (`~/.pi/pi-config/models.dev.json`, 1-day refresh). Cold-start default restore (#753): gates `startup`\|`new`, empty `enabledModels`, trusted project merge — see `dev-docs/cli-provider.md`. Headless Cursor `--approve-mcps` when `CLI_APPROVE_MCPS` is set (`false` opts out) or the process is a sidecar (`SIDECAR_PORT`; `startSidecar()` stamps it while running and restores it on `close()`). Executable sidecar consumers should call `bindSidecarListenExit()` so bind/`stopped` fatals exit 1. Headless Gemini defaults `GEMINI_CLI_TRUST_WORKSPACE=true` and preserves an explicit parent value (including `false`). |
19
19
  | **`/btw` command** | Quick side questions without polluting conversation history — ephemeral overlay |
20
- | **`/async-status` command** | Show status of background agents — select one for live output streaming |
21
- | **`/async-kill` command** | Kill async agents (overlay picker or by name/id) |
20
+ | **`/async-status` command** | Show status of background agents — select one for live output streaming, multi-select and kill (`x`), or kill everything (`a`) |
22
21
  | **`ask_user` tool** | Structured user input with options and free-text — used by workflows |
23
22
  | **Python/pip enforcement** | Auto-fixes `python`/`python3` → `uv run python`; blocks `pip`/`pip3` — requires `uv` |
24
23
  | **Git protection** | Blocks commits/pushes to main/master, merged branches, `--no-verify`, `git add .` |
@@ -37,7 +36,7 @@ Single extension that provides:
37
36
  | **Task tracking** | Structured task lists for multi-step workflows — live widget, progress tracking, reminder nudges (owned, based on [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) MIT) |
38
37
  | **Neovim integration** | Send changed files and review findings to nvim's quickfix list — only active when running inside nvim |
39
38
  | **Inter-agent communication** | P2P (`/coms`) agent communication — on-demand activation via slash command |
40
- | **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/async-kill`, `/status`, `/dream`, `/remember`, `/coms`, `/pi-config-settings` — with autocomplete argument hints |
39
+ | **Slash commands** | `/pr-review`, `/issue-review`, `/release`, `/review-local`, `/review-status`, `/query-db`, `/btw`, `/async-status`, `/status`, `/dream`, `/remember`, `/coms`, `/pi-config-settings` — with autocomplete argument hints |
41
40
  | **GitHub autocomplete** | Type `#` in the editor to get issue/PR suggestions from the current repo — lazy-loaded, 5min cache |
42
41
  | **Command arg completions** | Tab-complete arguments for slash commands — providers and models for `/external-ai`, branches for `/review-local`, PR numbers for `/pr-review`, and more |
43
42
  | **Discord bot** | Control pi sessions from your phone via Discord DMs — send prompts, answer ask_user dialogs, switch sessions |
@@ -83,7 +82,7 @@ for `git-expert` to commit.
83
82
  | `/remember <what>` | Save a memory for future sessions |
84
83
  | `/dream-auto` | Toggle automatic memory dreaming (every 3h + session end) |
85
84
  | `/cron add\|list\|list-all\|remove [--persist]` | Schedule recurring tasks. Session jobs end with the session; `--persist` keeps a job across Pi sessions in this project. Add/remove mutations still work when leader election fails closed on an unsupported platform. For example: `/cron add every 2h check for new issues --persist` or `/cron remove persist:<uuid>`. Pin one job's model by naming it in the request: `/cron add every 2h check for new issues using openrouter/anthropic/claude-opus-5`. A bare `model-id` (e.g. `claude-opus-5`) keeps the normal provider resolution. An unpinned job uses the worker's normal resolution chain (`agent_overrides[worker]`, worker frontmatter, then `agent_provider`/`agent_model`) — cron does not pass the session model down, so a worker-level setting wins over the session model. An `acpx-*` provider cannot be pinned here: async children cannot register those providers, so the job runs on `internal_operations_provider`/`internal_operations_model` when both are set to a non-`acpx-*` pair, and is skipped with a logged error when they are not. `/cron list` opens an overlay for the selected jobs (or both when omitted). Persistent delivery is local at-least-once: a crash or leader recovery can repeat a run. Project cron writes use a stable OS `flock` (included in Docker via `util-linux`) across the entire transaction; native installations need `flock` and a shared filesystem that supports advisory locks. A busy or unavailable lock fails closed after two seconds. **Upgrade gate (enforced):** stop and drain every old Pi process writing crons on the shared mount before starting a version with this kernel lock; old directory-lock writers do not honor `flock` and must not resume. If a legacy `crons.json.mutation.lock` directory exists, new writes fail closed even if its PID is invisible or its heartbeat expired. Only after verifying every old writer cannot resume may an operator remove that stale directory (not `crons.json` or cron tasks). Never remove it merely because a write timed out. |
86
- | `/async-kill [name\|id\|all]` | Kill async agents (overlay picker or by name/id) |
85
+ | `/async-status` | Async agent dashboard — live output, multi-select + `x` kill, `X` kill all |
87
86
  | `/pi-config-settings [project\|global]` | Interactive TUI settings editor — browse all pi-config settings with scope indicators, smart pickers for providers/models, and immediate save |
88
87
  | `/status` | Unified session snapshot — async agents, cron tasks, git branch, context usage |
89
88
  | `/nvim-changed-files` | Send git changed files to nvim's quickfix list (only inside nvim) |
@@ -751,7 +750,7 @@ PI_PIDIFF_ENABLE=false pi
751
750
  | `jq` | JSON processing |
752
751
  | `rg` (ripgrep) | Fast recursive search |
753
752
  | `cr` | CodeRabbit CLI — local AI code reviews |
754
- | `qodo` | Qodo CLI — local code reviews (`qodo --version`; mount `~/.qodo` as above and authenticate inside the container if needed) |
753
+ | `qodo` | Qodo CLI (npm package `@qodo/command`) — local code reviews (`qodo --version`; mount `~/.qodo` as above and authenticate inside the container if needed — `--version` exits non-zero when logged out) |
755
754
  | `curl` | HTTP requests |
756
755
 
757
756
  #### `myk-pi-tools` subcommands
@@ -19,7 +19,7 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
19
19
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
20
20
  import { Type } from "typebox";
21
21
  import { applyExtensionDefaults } from "./themeMap.js";
22
- import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, formatComsResponseType, formatComsResponseBody, formatComsInboundType, sanitizeComsName, createComsInboundTasks, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
22
+ import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, formatComsResponseType, formatComsResponseBody, formatComsInboundType, sanitizeComsName, createComsInboundTasks, shouldEvictOnLeaving, shouldAnnouncePeerLeft, isSessionStillRegistered, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
23
23
  import { openListDetailOverlay, OverlayScrollDetail } from "../orchestrator/overlay-dashboard.js";
24
24
  import * as net from "node:net";
25
25
  import * as fs from "node:fs";
@@ -965,13 +965,27 @@ export default function (pi: ExtensionAPI) {
965
965
  function handlePresence(socket: net.Socket, env: PresenceEnvelope): void {
966
966
  ackOk(socket, env.msg_id);
967
967
  if (env.status === "leaving" && env.sender_name) {
968
+ // Match the session that is actually leaving, not every session sharing that name.
969
+ // On /reload the outgoing session broadcasts this ~200ms AFTER the incoming one has
970
+ // already registered, so matching by name alone evicted the live session: the peer
971
+ // showed as disconnected until something forced it to re-register.
968
972
  for (const [sid, card] of peerCards.entries()) {
969
- if (card.name === env.sender_name) {
973
+ const sameSession = shouldEvictOnLeaving(sid, card.name, env.sender_name, env.sender_session);
974
+ if (sameSession) {
970
975
  peerCards.delete(sid);
971
976
  // Also clean up knownPeerSessions and fire notification
972
977
  if (knownPeerSessions.has(sid)) {
973
978
  knownPeerSessions.delete(sid);
974
- log.debug("presence_leaving_received", "from", env.sender_name);
979
+ log.info("presence_leaving_received", "from", env.sender_name, "sid", sid);
980
+ // Evicting only the departing session is not enough: on /reload the
981
+ // replacement session may already hold a live card under the same name, and
982
+ // announcing a departure would then tell the user and every browser event
983
+ // consumer that a still-connected peer is gone. The registry-removal path
984
+ // below already suppresses this; mirror it here.
985
+ if (!shouldAnnouncePeerLeft(peerCards.values(), card.name)) {
986
+ maybeRefreshWidget();
987
+ break;
988
+ }
975
989
  try {
976
990
  pi.sendMessage({ customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false });
977
991
  try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]` }); } catch {}
@@ -1511,11 +1525,26 @@ Do not respond to this message.`;
1511
1525
  } else {
1512
1526
  if (peerCards.has(sessionId)) {
1513
1527
  const card = peerCards.get(sessionId);
1528
+ const name = knownPeerSessions.get(sessionId) ?? card?.name ?? sessionId;
1529
+ // A peer can reload with a STABLE coms_session_id, so the outgoing session's
1530
+ // cleanup unlinks the very file the incoming session just re-created. Deleting on
1531
+ // that unlink evicted the live peer and announced a peer-left ~200ms after the
1532
+ // join. Re-read the registry and match on the SESSION's own id, never the
1533
+ // name: two sessions may legitimately share a name, and a name-only check
1534
+ // would let the surviving twin keep a card whose session is genuinely gone.
1535
+ const stillRegistered = isSessionStillRegistered(
1536
+ readAllRegistryEntries(identity.project),
1537
+ sessionId,
1538
+ );
1539
+ if (stillRegistered) {
1540
+ log.info("fs_watch_peer_unlink_ignored", name, sessionId);
1541
+ maybeRefreshWidget();
1542
+ return;
1543
+ }
1514
1544
  peerCards.delete(sessionId);
1515
1545
  log.info("fs_watch_peer_removed", card?.name ?? sessionId);
1516
1546
  maybeRefreshWidget();
1517
1547
  if (knownPeerSessions.has(sessionId)) {
1518
- const name = knownPeerSessions.get(sessionId) ?? sessionId;
1519
1548
  knownPeerSessions.delete(sessionId);
1520
1549
  const sameNameStillExists = [...peerCards.values()].some(c => c.name === name);
1521
1550
  if (!sameNameStillExists) {
@@ -732,3 +732,78 @@ export async function getComsOriginTasks(): Promise<Array<{ taskId: string; subj
732
732
  }));
733
733
  } catch { return []; }
734
734
  }
735
+
736
+ /**
737
+ * Whether a "peer left" notification should evict this peer card.
738
+ *
739
+ * A peer's leaving broadcast is matched by SESSION id, never by name alone. On
740
+ * /reload the outgoing session announces itself gone roughly 200ms AFTER the
741
+ * incoming session has already registered under the same name; matching by name
742
+ * therefore removed the live session and the peer showed as disconnected until
743
+ * something forced it to re-register.
744
+ *
745
+ * `senderSession` is absent only for older peers that predate the field, in which
746
+ * case the name check stands alone as before.
747
+ */
748
+ export function shouldEvictOnLeaving(
749
+ cardSession: string,
750
+ cardName: string,
751
+ senderName: string,
752
+ senderSession?: string,
753
+ ): boolean {
754
+ const nameMatches = cardName === senderName;
755
+ if (!nameMatches) {
756
+ log.debug("peer_leaving_no_name_match", { card: cardName, sender: senderName });
757
+ return false;
758
+ }
759
+ // A sender with no session id predates the field, so the name check stands alone.
760
+ const evict = !senderSession || cardSession === senderSession;
761
+ log.debug("peer_leaving_match", {
762
+ card: cardName,
763
+ cardSession,
764
+ senderSession: senderSession ?? "<none>",
765
+ evict,
766
+ });
767
+ return evict;
768
+ }
769
+
770
+ /**
771
+ * Whether a session is still present in the on-disk coms registry.
772
+ *
773
+ * A peer can reload with a STABLE coms_session_id, so the outgoing session's cleanup
774
+ * unlinks the very registry file the incoming session just re-created. The fs-watch
775
+ * unlink handler therefore re-checks the registry before evicting a card.
776
+ *
777
+ * Matching is on the SESSION id, never the name: two sessions may legitimately share
778
+ * a name, and a name-only check would let the surviving twin keep a card whose own
779
+ * session had genuinely gone.
780
+ */
781
+ export function isSessionStillRegistered(
782
+ entries: ReadonlyArray<{ coms_session_id?: string }>,
783
+ sessionId: string,
784
+ ): boolean {
785
+ const present = entries.some((e) => e.coms_session_id === sessionId);
786
+ log.debug("peer_unlink_registry_check", { session: sessionId, present });
787
+ return present;
788
+ }
789
+
790
+ /**
791
+ * Whether a departing session should produce a user-visible "peer left" notice.
792
+ *
793
+ * Evicting only the departing session is not enough on its own: after a /reload the
794
+ * replacement session may already hold a live card under the same name, and announcing
795
+ * a departure then tells the user — and every browser event consumer — that a peer
796
+ * which is still connected has gone. The registry-removal path applies the same guard.
797
+ */
798
+ export function shouldAnnouncePeerLeft(
799
+ cards: Iterable<{ name: string }>,
800
+ departedName: string,
801
+ ): boolean {
802
+ for (const card of cards) {
803
+ if (card.name === departedName) {
804
+ log.debug("peer_left_announcement_suppressed", { peer: departedName });
805
+ return false;
806
+ }
807
+ }
808
+ return true;
809
+ }
@@ -2,6 +2,8 @@
2
2
  * Async agent infrastructure — background agent spawning, polling, result watching.
3
3
  */
4
4
 
5
+ import { parseAsyncOutputLine } from "./async-status-parse.js";
6
+ import { isProcessAlive, signalAndReportSurvivors } from "./async-kill-signal.js";
5
7
  import { createLogger } from "../shared/logger.js";
6
8
  import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
7
9
  import * as fs from "node:fs";
@@ -22,7 +24,7 @@ import {
22
24
  getMainBranch,
23
25
  } from "./git-helpers.js";
24
26
  import { waitForResultFiles } from "./async-wait.js";
25
- import { formatAsyncResultOutput, reviewerOutputArchivePath } from "./async-result-format.js";
27
+ import { formatAsyncResultOutput, reviewerOutputArchivePath, MAX_OUTPUT_CHARS } from "./async-result-format.js";
26
28
  import { cleanupReviewerOutputArchives } from "./reviewer-output-archive.js";
27
29
  const log = createLogger("async_agents");
28
30
 
@@ -72,8 +74,26 @@ export interface AsyncJob {
72
74
  sessionId?: string;
73
75
  model?: string;
74
76
  restoredPid?: number;
77
+ /** Who killed this job, if it was killed. Drives the attribution suffix in delivery headers. */
78
+ killOrigin?: AsyncKillOrigin;
75
79
  }
76
80
 
81
+ /**
82
+ * Who requested an async-agent kill.
83
+ * - `user` — a human action: the async-status overlay `x`/`a`, or the pidash browser UI
84
+ * - `orchestrator` — the LLM via `subagent(asyncKill=...)`
85
+ * - `task-system` — pitasks TaskExecute via `subagents:rpc:stop`
86
+ * The pidash browser UI is a human clicking, so it is a `user` kill.
87
+ */
88
+ export type AsyncKillOrigin = "user" | "orchestrator" | "task-system";
89
+
90
+ /** Human-readable attribution per origin. The single source for every surface. */
91
+ export const KILL_ORIGIN_LABELS: Record<AsyncKillOrigin, string> = {
92
+ user: "Killed by user",
93
+ orchestrator: "Killed by orchestrator",
94
+ "task-system": "Killed by task system",
95
+ };
96
+
77
97
  /** Get the effective working directory for a job. */
78
98
  function jobCwd(job: { cwd?: string; projectCwd?: string }): string {
79
99
  return job.cwd || job.projectCwd || process.cwd();
@@ -126,7 +146,7 @@ export function registerAsyncAgents(
126
146
  } = {},
127
147
  ): {
128
148
  spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
129
- killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
149
+ killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] };
130
150
  getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
131
151
  } {
132
152
  let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
@@ -141,6 +161,155 @@ export function registerAsyncAgents(
141
161
  return outputPath;
142
162
  }
143
163
 
164
+ /**
165
+ * Readable text a killed worker had produced, recovered from its output.log.
166
+ *
167
+ * output.log is a JSONL event stream, so it is run through the same
168
+ * parseAsyncOutputLine the async-status overlay uses — reading it raw ships the
169
+ * raw event JSON to the AI instead of the work the agent actually did. Lines the
170
+ * parser does not recognise are dropped, matching overlay behaviour.
171
+ *
172
+ * Capped so a chatty agent cannot push a megabyte into the delivery message and
173
+ * the AI's context. The budget is sized so the composed kill output (label plus
174
+ * this text) still fits inside formatAsyncResultOutput's MAX_OUTPUT_CHARS: that
175
+ * formatter keeps the HEAD of its budget, so a tail kept here and then re-trimmed
176
+ * from the head would hand the AI the OLDEST part of the newest context — exactly
177
+ * the lines immediately before the kill that explain why it was stopped.
178
+ */
179
+ const TRUNCATION_MARKER = "…(earlier output truncated)\n";
180
+ // Only a bounded tail is ever read: the runner appends output with no size cap, so
181
+ // reading the whole log made killing a chatty agent block the orchestrator and cost
182
+ // memory proportional to that log — for a tail we are about to discard anyway.
183
+ const MAX_TAIL_BYTES = 256 * 1024;
184
+
185
+ /**
186
+ * Last-resort text recovery from a JSONL record the tail boundary cut in half.
187
+ *
188
+ * The read runs to EOF, so a record larger than the tail keeps its END and loses only its
189
+ * opening — the delta field itself is usually intact. Pulling that field out directly is
190
+ * what lets such an event still contribute its text instead of the delivery carrying only
191
+ * the kill label. The second form covers a record cut mid-delta, whose closing quote never
192
+ * made it into the tail.
193
+ */
194
+ function recoverTruncatedDelta(line: string, firstOfBoundedRead: boolean): string | null {
195
+ // A COMPLETE record that parseAsyncOutputLine deliberately rejects is not truncated
196
+ // output. toolcall_delta carries a `delta` field, and lifting it would ship a killed
197
+ // worker's raw tool arguments into the persisted output and the AI-facing delivery.
198
+ try {
199
+ JSON.parse(line);
200
+ return null;
201
+ } catch {
202
+ /* not a complete record — continue */
203
+ }
204
+ // A truncated record is recognised by what it CONTAINS, not by where it sits. The type
205
+ // markers precede the value, so a record cut mid-write still carries them; stderr never
206
+ // does. Positioning was the previous guard and it was wrong twice over: it discarded a
207
+ // legitimate partial final event — exactly what a worker killed mid-write leaves behind —
208
+ // whenever the log was read whole or the event followed other lines.
209
+ // The INNER event type, not the envelope: toolcall_delta is also a message_update, so
210
+ // matching the envelope would admit every truncated tool-call update as agent text.
211
+ const looksLikeTextEvent = line.includes('"text_delta"');
212
+ if (looksLikeTextEvent) {
213
+ const closed = /"delta"\s*:\s*"((?:[^"\\]|\\.)*)"/g;
214
+ let last: RegExpExecArray | null = null;
215
+ let m: RegExpExecArray | null;
216
+ while ((m = closed.exec(line)) !== null) last = m;
217
+ if (last) {
218
+ try {
219
+ return JSON.parse(`"${last[1]}"`);
220
+ } catch {
221
+ /* fall through to the open-ended form */
222
+ }
223
+ }
224
+ const open = line.match(/"delta"\s*:\s*"((?:[^"\\]|\\.)*)$/);
225
+ if (open) {
226
+ try {
227
+ return JSON.parse(`"${open[1]}"`);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+ }
233
+ // The record is larger than the whole window, so even its type markers sit before the
234
+ // tail and the fragment is recognisable only by position and shape: it must be the first
235
+ // line of a BOUNDED read and must end in a closing quote followed by closing braces.
236
+ // A diagnostic like `Error: cannot open "/tmp/x"` contains a quote but neither.
237
+ if (!firstOfBoundedRead) return null;
238
+ if (!/"\s*}\s*}\s*$/.test(line)) return null;
239
+ const q = line.lastIndexOf('"');
240
+ if (q <= 0) return null;
241
+ const fragment = line.slice(0, q);
242
+ try {
243
+ return JSON.parse(`"${fragment}"`);
244
+ } catch {
245
+ return fragment;
246
+ }
247
+ }
248
+ function readPartialWorkerOutput(job: Pick<AsyncJob, "id" | "workerDir">, budget: number): string {
249
+ const outputPath = path.join(job.workerDir, "output.log");
250
+ try {
251
+ if (!fs.existsSync(outputPath)) {
252
+ log.debug("kill_output_absent", job.id);
253
+ return "";
254
+ }
255
+ const size = fs.statSync(outputPath).size;
256
+ let raw: string;
257
+ const usedTailRead = size > MAX_TAIL_BYTES;
258
+ if (!usedTailRead) {
259
+ raw = fs.readFileSync(outputPath, "utf-8");
260
+ } else {
261
+ const fd = fs.openSync(outputPath, "r");
262
+ try {
263
+ const buf = Buffer.alloc(MAX_TAIL_BYTES);
264
+ const read = fs.readSync(fd, buf, 0, MAX_TAIL_BYTES, size - MAX_TAIL_BYTES);
265
+ raw = buf.toString("utf-8", 0, read);
266
+ } finally {
267
+ fs.closeSync(fd);
268
+ }
269
+ // The tail may start mid-record, but that is NOT a reason to discard anything: the
270
+ // record might be complete, and even a partial one can still yield its text. Blanking
271
+ // the first line unconditionally lost a complete event whenever the offset happened to
272
+ // land on a record boundary. Unparseable lines are recovered leniently below instead.
273
+ log.debug("kill_output_tail_read", { job: job.id, size, read: MAX_TAIL_BYTES });
274
+ }
275
+ // Concatenate each parsed value VERBATIM. parseAsyncOutputLine returns text deltas
276
+ // unchanged and prefixes tool events with a newline, so appending preserves both the
277
+ // fragment boundaries of a streamed word ("Hel" + "lo") and the event boundaries.
278
+ // Splitting each value into lines and rejoining with "\n" turned "Hel" "lo" into
279
+ // "Hel\nlo" — corrupting the recovered text of every killed agent.
280
+ let text = "";
281
+ // Truncated-record recovery runs on any line that fails to parse, but recoverTruncatedDelta
282
+ // decides on what the line CONTAINS: a partial assistant event keeps its type markers,
283
+ // while stderr and complete-but-rejected events never qualify. `usedTailRead && i === 0`
284
+ // is passed separately because an oversized record is recognisable only by position —
285
+ // its markers fall before the window.
286
+ const lines = raw.split("\n");
287
+ for (let i = 0; i < lines.length; i++) {
288
+ const line = lines[i];
289
+ if (!line.trim()) continue;
290
+ const parsed = parseAsyncOutputLine(line)
291
+ ?? recoverTruncatedDelta(line, usedTailRead && i === 0);
292
+ if (parsed === null) continue;
293
+ text += parsed;
294
+ }
295
+ text = text.trim();
296
+ if (!text) {
297
+ log.debug("kill_output_no_readable_events", job.id);
298
+ return "";
299
+ }
300
+ const out = text.length > budget
301
+ ? `${TRUNCATION_MARKER}${text.slice(-budget)}`
302
+ : text;
303
+ log.info("kill_output_recovered", { job: job.id, chars: out.length, truncated: out !== text });
304
+ return out;
305
+ } catch (e: any) {
306
+ // Handled loss: the kill path has no other output to fall back on, so the agent's
307
+ // partial work never reaches the result delivered to the AI.
308
+ log.warn("kill_output_recovery_failed", { job: job.id, error: e?.message || String(e) });
309
+ return "";
310
+ }
311
+ }
312
+
144
313
  function preserveReviewerOutput(job: Pick<AsyncJob, "agent" | "id">, output: string): void {
145
314
  if (!job.agent.startsWith("code-reviewer-")) return;
146
315
  const outputPath = reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id);
@@ -596,7 +765,10 @@ export function registerAsyncAgents(
596
765
  }
597
766
  }
598
767
  const duration = j.durationMs || (j.updatedAt ? j.updatedAt - j.startedAt : 0);
599
- sections.push(`## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
768
+ // Killed members carry the same attribution suffix the non-grouped kill path uses, so a
769
+ // group result says who stopped the job instead of only reporting that it failed.
770
+ const killSuffix = j.killOrigin ? ` — ${KILL_ORIGIN_LABELS[j.killOrigin]}` : "";
771
+ sections.push(`## Async Agent Result: ${displayName} ${resultStatus}${killSuffix}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
600
772
  deliverableJobs.push(j);
601
773
  }
602
774
 
@@ -1376,9 +1548,7 @@ export function registerAsyncAgents(
1376
1548
  log.debug("opening_async_status_overlay", { jobs: asyncState.jobs.size });
1377
1549
  await openAsyncStatusOverlay(ctx, {
1378
1550
  listJobs: () => Array.from(asyncState.jobs.values()),
1379
- killJob: (id) => {
1380
- killAsyncAgent(id);
1381
- },
1551
+ killJob: (id) => killAsyncAgent(id, "user"),
1382
1552
  formatDuration,
1383
1553
  readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
1384
1554
  });
@@ -1391,7 +1561,13 @@ export function registerAsyncAgents(
1391
1561
  });
1392
1562
 
1393
1563
  // Kill an async agent by name, id prefix, or "all"
1394
- function killAsyncAgent(target: string): { killed: string[]; errors: string[] } {
1564
+ function killAsyncAgent(target: string, origin: AsyncKillOrigin = "user"): { killed: string[]; errors: string[] } {
1565
+ // Runtime guard: callers outside the type system still get a safe, non-false label.
1566
+ // Object.hasOwn, not a plain lookup — inherited keys such as `toString` would otherwise
1567
+ // pass the truthiness check and make the label a function instead of a string.
1568
+ const killOrigin: AsyncKillOrigin = Object.hasOwn(KILL_ORIGIN_LABELS, origin) ? origin : "user";
1569
+ const killLabel = KILL_ORIGIN_LABELS[killOrigin];
1570
+ log.info("async_kill_requested", { target, origin: killOrigin, label: killLabel });
1395
1571
  const killed: string[] = [];
1396
1572
  const errors: string[] = [];
1397
1573
  const running = Array.from(asyncState.jobs.values()).filter(
@@ -1416,26 +1592,74 @@ export function registerAsyncAgents(
1416
1592
  return { killed, errors };
1417
1593
  }
1418
1594
 
1595
+ // The failed-pid set is per-target: declared outside the loop, a pid that survived one
1596
+ // target's failed signal was re-checked for every later target, and a later agent that
1597
+ // had been signalled successfully would skip its state and result updates and stay
1598
+ // marked running.
1419
1599
  for (const job of targets) {
1420
1600
  const status = readAsyncStatus(job.workerDir);
1601
+ const pidsToSignal: number[] = [];
1421
1602
  if (status?.pid) {
1422
1603
  try {
1423
1604
  const tree = execFileSync("pstree", ["-p", String(status.pid)], { encoding: "utf-8", timeout: 3000 });
1424
1605
  const matches = tree.match(/\((\d+)\)/g);
1425
- const allPids = matches ? [...new Set(matches.map((m: string) => parseInt(m.slice(1, -1), 10)))] : [status.pid];
1426
- for (const pid of allPids) {
1427
- try { process.kill(pid, "SIGKILL"); } catch {}
1428
- }
1606
+ pidsToSignal.push(...(matches ? [...new Set(matches.map((m: string) => parseInt(m.slice(1, -1), 10)))] : [status.pid]));
1429
1607
  } catch {
1430
- try { process.kill(status.pid, "SIGKILL"); } catch {}
1431
- if (status.childPid) try { process.kill(status.childPid, "SIGKILL"); } catch {}
1608
+ pidsToSignal.push(status.pid);
1609
+ if (status.childPid) pidsToSignal.push(status.childPid);
1432
1610
  }
1433
1611
  }
1612
+ const stillAlive = signalAndReportSurvivors(
1613
+ [...new Set(pidsToSignal)],
1614
+ (pid) => { process.kill(pid, "SIGKILL"); },
1615
+ isProcessAlive,
1616
+ );
1434
1617
  const label = job.name || job.agent;
1618
+ // A swallowed signal failure used to be reported as a successful kill: the job was
1619
+ // marked failed and counted as killed while its process kept running. Only claim a
1620
+ // kill when nothing we signalled is still alive.
1621
+ if (stillAlive.length > 0) {
1622
+ errors.push(`Could not stop ${label} (pid ${stillAlive.join(", ")} still running).`);
1623
+ log.error("async_kill_signal_failed", { label, pids: stillAlive, target });
1624
+ continue;
1625
+ }
1435
1626
  killed.push(label);
1436
1627
  job.status = "failed";
1437
1628
  job.updatedAt = Date.now();
1438
1629
  job.durationMs = Date.now() - job.startedAt;
1630
+ // Resolve the agent's prior output BEFORE writing either destination.
1631
+ // Prefer the in-memory value, then what status.json already held, then the worker's
1632
+ // output.log — a job killed mid-run has neither of the first two, because `output`
1633
+ // only lands in status.json on the completion path and job.output stays undefined
1634
+ // until then. Reading output.log is what stops the AI receiving a kill label and
1635
+ // none of the work the agent had actually done.
1636
+ let persistedPriorOutput = "";
1637
+ try {
1638
+ const statusPath = path.join(job.workerDir, "status.json");
1639
+ if (fs.existsSync(statusPath)) {
1640
+ persistedPriorOutput = typeof JSON.parse(fs.readFileSync(statusPath, "utf-8"))?.output === "string"
1641
+ ? JSON.parse(fs.readFileSync(statusPath, "utf-8")).output
1642
+ : "";
1643
+ }
1644
+ } catch (e: any) {
1645
+ log.debug(`kill: could not read prior output for ${job.id}: ${e?.message}`);
1646
+ }
1647
+ const inMemoryOutput = typeof job.output === "string" ? job.output : "";
1648
+ const partialFromLog = inMemoryOutput || persistedPriorOutput ? "" : readPartialWorkerOutput(
1649
+ job,
1650
+ // Budget the label, the newline joining them, and the truncation marker itself —
1651
+ // otherwise the composed string still overruns MAX_OUTPUT_CHARS and
1652
+ // formatAsyncResultOutput head-trims it, which drops the newest lines.
1653
+ Math.max(0, MAX_OUTPUT_CHARS - killLabel.length - TRUNCATION_MARKER.length - 1),
1654
+ );
1655
+ if (partialFromLog) {
1656
+ log.debug("kill: recovered partial output from output.log", { jobId: job.id, chars: partialFromLog.length });
1657
+ }
1658
+ const priorOutput = inMemoryOutput || persistedPriorOutput || partialFromLog;
1659
+ const composedOutput = priorOutput ? `${killLabel}\n${priorOutput}` : killLabel;
1660
+ job.output = composedOutput;
1661
+ job.killOrigin = killOrigin;
1662
+
1439
1663
  // Persist killed state to disk — prevents stale re-delivery on reload
1440
1664
  try {
1441
1665
  const statusPath = path.join(job.workerDir, "status.json");
@@ -1443,7 +1667,10 @@ export function registerAsyncAgents(
1443
1667
  existing.state = "failed";
1444
1668
  existing.exitCode = -9;
1445
1669
  existing.endedAt = Date.now();
1446
- existing.output = "Killed by user";
1670
+ existing.killOrigin = killOrigin;
1671
+ // Same composed text as job.output, so the overlay and pidash read exactly what
1672
+ // the AI receives instead of losing the partial output.
1673
+ existing.output = composedOutput;
1447
1674
  fs.writeFileSync(statusPath, JSON.stringify(existing), { mode: 0o600 });
1448
1675
  } catch (e: any) { log.error(`kill: status.json update failed for ${job.id}: ${e?.message}`); }
1449
1676
  // Delete result file if it exists — prevent re-ingestion on reload
@@ -1465,13 +1692,12 @@ export function registerAsyncAgents(
1465
1692
  // Non-grouped killed job — deliver immediately so AI knows it was killed
1466
1693
  const displayName = job.name || job.agent;
1467
1694
  const duration = job.durationMs || (Date.now() - job.startedAt);
1468
- const rawOutput = typeof job.output === "string" ? job.output : "Killed by user";
1469
1695
  const output = formatAsyncResultOutput(
1470
1696
  job.agent,
1471
- rawOutput,
1697
+ job.output,
1472
1698
  resultOutputPath(job),
1473
1699
  );
1474
- const killContent = `## Async Agent Result: ${displayName} ❌ failed\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
1700
+ const killContent = `## Async Agent Result: ${displayName} ❌ failed — ${killLabel}\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
1475
1701
  if (wasAlreadyDelivered(job.id)) {
1476
1702
  job.delivered = true;
1477
1703
  log.debug(`kill: skipping already-delivered result for ${job.id}`);
@@ -1502,49 +1728,11 @@ export function registerAsyncAgents(
1502
1728
  return { killed, errors };
1503
1729
  }
1504
1730
 
1505
- // /async-kill handler — extracted for readability (closure access preserved)
1506
- async function handleAsyncKill(args: string, ctx: any): Promise<void> {
1507
- // If arg provided, kill directly without interactive selection
1508
- if (args) {
1509
- const { killed, errors } = killAsyncAgent(args);
1510
- if (killed.length > 0) {
1511
- ctx.ui.notify(`Killed: ${killed.join(", ")}`, "info");
1512
- }
1513
- if (errors.length > 0) {
1514
- ctx.ui.notify(errors.join("\n"), "warning");
1515
- }
1516
- return;
1517
- }
1518
-
1519
- if (!ctx.hasUI) return;
1520
- // Same overlay as /async-status, scoped to running/queued
1521
- await openAsyncStatusOverlay(ctx, {
1522
- title: "Kill async agents",
1523
- emptyMessage: "No running async agents.",
1524
- footerHints: "↑↓/jk select · Enter view · x kill · Esc close",
1525
- listJobs: () =>
1526
- Array.from(asyncState.jobs.values()).filter(
1527
- (j) => j.status === "running" || j.status === "queued",
1528
- ),
1529
- killJob: (id) => {
1530
- killAsyncAgent(id);
1531
- },
1532
- formatDuration,
1533
- readLiveStatus: (workerDir) => readAsyncStatus(workerDir),
1534
- });
1535
- }
1536
-
1537
- // /async-kill command — accepts name/id/"all" or interactive overlay
1538
- pi.registerCommand("async-kill", {
1539
- description:
1540
- "Kill async agent(s) — /async-kill <name|id|all> or overlay picker",
1541
- handler: async (_args, ctx) => handleAsyncKill((_args || "").trim(), ctx),
1542
- });
1543
-
1544
1731
  // Handle async-kill from pidash browser UI
1545
1732
  pi.events.on("pidash:async-kill", (target: unknown) => {
1546
1733
  if (typeof target === "string") {
1547
- killAsyncAgent(target);
1734
+ // A human clicking in the browser UI — still a user kill.
1735
+ killAsyncAgent(target, "user");
1548
1736
  }
1549
1737
  });
1550
1738
 
@@ -1612,7 +1800,7 @@ export function registerAsyncAgents(
1612
1800
  // Stop — kill a running async agent
1613
1801
  handleRpc<{ requestId: string; agentId: string }>(
1614
1802
  "subagents:rpc:stop", ({ agentId }) => {
1615
- const { killed, errors } = killAsyncAgent(agentId);
1803
+ const { killed, errors } = killAsyncAgent(agentId, "task-system");
1616
1804
  if (killed.length === 0) throw new Error(errors[0] || "Agent not found");
1617
1805
  },
1618
1806
  );
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Pure signalling helpers for killing async workers.
3
+ *
4
+ * Kept free of pi/TUI deps so the kill decision is unit-testable: a failed signal must
5
+ * never be mistaken for a successful one, and one target's failure must not be charged
6
+ * to the next.
7
+ */
8
+
9
+ /** Signal each pid, returning those still alive afterwards. */
10
+ export function signalAndReportSurvivors(
11
+ pids: ReadonlyArray<number>,
12
+ signal: (pid: number) => void,
13
+ isAlive: (pid: number) => boolean,
14
+ ): number[] {
15
+ const unsignalled: number[] = [];
16
+ for (const pid of pids) {
17
+ try {
18
+ signal(pid);
19
+ } catch {
20
+ unsignalled.push(pid);
21
+ }
22
+ }
23
+ // Only the pids we FAILED to signal need a liveness check. A pid that was signalled
24
+ // successfully is gone, and re-probing it would let one target's leftovers leak into
25
+ // the next target's verdict.
26
+ return unsignalled.filter(isAlive);
27
+ }
28
+
29
+ /**
30
+ * Whether a pid is still running.
31
+ *
32
+ * Only an explicit ESRCH proves the process is gone. Any other failure — EPERM above all —
33
+ * means we were denied, not that the process exited, and treating it as "gone" reports a
34
+ * live worker as killed.
35
+ */
36
+ export function isProcessAlive(pid: number): boolean {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (e: any) {
41
+ return e?.code !== "ESRCH";
42
+ }
43
+ }
@@ -5,7 +5,9 @@ import * as path from "node:path";
5
5
  import { createLogger } from "../shared/logger.js";
6
6
 
7
7
  const log = createLogger("async_agents");
8
- const MAX_OUTPUT_CHARS = 3000;
8
+ /** Delivery budget for a single result's output. Exported so callers can size their own
9
+ * contribution against it — formatAsyncResultOutput keeps the HEAD of this budget. */
10
+ export const MAX_OUTPUT_CHARS = 3000;
9
11
 
10
12
  /** Return the completed-result archive path, outside cleanup-prone worker directories. */
11
13
  export function reviewerOutputArchivePath(projectTmpDir: string, jobId: string): string {
@@ -120,7 +120,7 @@ async function run(config: RunConfig): Promise<void> {
120
120
  stdio: ["ignore", "pipe", "pipe"],
121
121
  });
122
122
 
123
- // Store child PID in status file so /async-kill can find it
123
+ // Store child PID in status file so kill paths can find it
124
124
  if (proc.pid) {
125
125
  status.childPid = proc.pid;
126
126
  writeJson(statusPath, status);
@@ -10,6 +10,10 @@ import {
10
10
  openListDetailOverlay,
11
11
  OverlayScrollDetail,
12
12
  } from "./overlay-dashboard.js";
13
+ import { createLogger } from "../shared/logger.js";
14
+ import { KILL_ORIGIN_LABELS, type AsyncKillOrigin } from "./async-agents.js";
15
+
16
+ const log = createLogger("async-status");
13
17
 
14
18
  export { parseAsyncOutputLine } from "./async-status-parse.js";
15
19
 
@@ -27,10 +31,10 @@ export interface AsyncStatusJobView {
27
31
 
28
32
  export interface AsyncStatusUiDeps {
29
33
  listJobs: () => AsyncStatusJobView[];
30
- killJob: (id: string) => void;
34
+ killJob: (id: string) => { killed: string[]; errors: string[] } | void;
31
35
  formatDuration: (ms: number) => string;
32
36
  /** Optional live status.json reader for detail header. */
33
- readLiveStatus?: (workerDir: string) => { state?: string } | null;
37
+ readLiveStatus?: (workerDir: string) => { state?: string; killOrigin?: string } | null;
34
38
  title?: string;
35
39
  emptyMessage?: string;
36
40
  footerHints?: string;
@@ -40,6 +44,36 @@ function jobTitle(job: AsyncStatusJobView): string {
40
44
  return job.name || job.agent;
41
45
  }
42
46
 
47
+ /**
48
+ * Attribution suffix for the detail header: " · killed by <origin>" when the
49
+ * live status.json records who killed the job, otherwise nothing.
50
+ *
51
+ * Uses KILL_ORIGIN_LABELS rather than echoing the stored key, so the overlay reads
52
+ * "killed by task system" like every other surface instead of "killed by task-system".
53
+ * An unrecognised value is ignored rather than rendered, so a hand-edited
54
+ * status.json cannot inject arbitrary text into the header.
55
+ *
56
+ * Extracted so it can be tested without driving the interactive overlay loop.
57
+ */
58
+ export function formatKillOriginSuffix(
59
+ killOrigin: string | undefined,
60
+ theme: { fg: (color: string, value: string) => string },
61
+ ): string {
62
+ if (!killOrigin) {
63
+ log.debug("kill_origin_header: no origin recorded");
64
+ return "";
65
+ }
66
+ const label = Object.hasOwn(KILL_ORIGIN_LABELS, killOrigin)
67
+ ? KILL_ORIGIN_LABELS[killOrigin as AsyncKillOrigin]
68
+ : null;
69
+ if (!label) {
70
+ log.warn("kill_origin_header: unrecognised origin ignored", { killOrigin });
71
+ return "";
72
+ }
73
+ log.debug("kill_origin_header: rendering attribution", { killOrigin });
74
+ return theme.fg("dim", ` · ${label.charAt(0).toLowerCase()}${label.slice(1)}`);
75
+ }
76
+
43
77
  function isActive(status: string): boolean {
44
78
  return status === "running" || status === "queued";
45
79
  }
@@ -78,6 +112,61 @@ function elapsedMs(job: AsyncStatusJobView): number {
78
112
  return Date.now() - job.startedAt;
79
113
  }
80
114
 
115
+ /**
116
+ * Kill every running/queued job. Destructive and immediate, so gated behind an
117
+ * explicit confirm; a no-op when the host has no confirm dialog.
118
+ */
119
+ /**
120
+ * Kill every running/queued job. No confirmation prompt: `X` is a deliberate,
121
+ * capitalised key, and gating it on `ctx.ui.confirm` left the overlay hanging on
122
+ * hosts where that dialog does not resolve — the key simply appeared dead.
123
+ */
124
+ export function killAllJobs(
125
+ ctx: ExtensionCommandContext,
126
+ deps: AsyncStatusUiDeps,
127
+ ): void {
128
+ const targets = deps.listJobs().filter((j) => isActive(j.status));
129
+ if (targets.length === 0) {
130
+ log.debug("kill_all: nothing active");
131
+ ctx.ui?.notify?.("No running async agents to kill.", "info");
132
+ return;
133
+ }
134
+ // Each kill is attempted independently: a failure on one job must not leave the
135
+ // remaining agents running after the user asked for all of them to die. A callback that
136
+ // returns errors (rather than throwing) counts as a failure too — signalling can fail
137
+ // while the worker is still alive, and reporting that as "killed" would be a lie.
138
+ const failed: string[] = [];
139
+ let killed = 0;
140
+ for (const job of targets) {
141
+ try {
142
+ const result = deps.killJob(job.id);
143
+ if (result?.errors?.length) {
144
+ failed.push(...result.errors);
145
+ log.error("kill_all: job kill reported errors", { job: job.id, errors: result.errors });
146
+ continue;
147
+ }
148
+ killed++;
149
+ } catch (e: any) {
150
+ const message = e?.message || String(e);
151
+ failed.push(`${job.id}: ${message}`);
152
+ log.error("kill_all: job kill failed", { job: job.id, error: message });
153
+ }
154
+ }
155
+ if (failed.length === 0) {
156
+ log.info("kill_all: killed", { count: killed });
157
+ ctx.ui?.notify?.(
158
+ `Killed ${killed} async agent${killed === 1 ? "" : "s"}.`,
159
+ "info",
160
+ );
161
+ return;
162
+ }
163
+ log.error("kill_all: partially failed", { killed, failed: failed.length });
164
+ ctx.ui?.notify?.(
165
+ `Killed ${killed} of ${targets.length} async agents — ${failed.length} failed. See logs.`,
166
+ "warning",
167
+ );
168
+ }
169
+
81
170
  /**
82
171
  * Open fullscreen async-status picker. Loops list → detail until user closes list.
83
172
  */
@@ -96,7 +185,8 @@ export async function openAsyncStatusOverlay(
96
185
  return `jobs · ${active} active / ${jobs.length}`;
97
186
  },
98
187
  footerHints:
99
- deps.footerHints ?? "↑↓/jk select · Enter view · x kill · Esc close",
188
+ deps.footerHints ??
189
+ "↑↓/jk navigate · Space select · x kill selection · X kill all · Esc close",
100
190
  listItems: () => deps.listJobs(),
101
191
  rowParts: (job, theme) => {
102
192
  const shortId = job.id.length > 8 ? job.id.slice(-8) : job.id;
@@ -118,6 +208,15 @@ export async function openAsyncStatusOverlay(
118
208
  onX: (job) => {
119
209
  if (isActive(job.status)) deps.killJob(job.id);
120
210
  },
211
+ selectable: true,
212
+ onXSelected: (jobs) => {
213
+ const targets = jobs.filter((j) => isActive(j.status));
214
+ log.debug("kill_selection", { selected: jobs.length, killed: targets.length });
215
+ for (const job of targets) deps.killJob(job.id);
216
+ },
217
+ onKillAll: () => {
218
+ void killAllJobs(ctx, deps);
219
+ },
121
220
  },
122
221
  createDetail: (job, tui, theme, done) => {
123
222
  const lines: string[] = [];
@@ -182,6 +281,8 @@ export async function openAsyncStatusOverlay(
182
281
  const current = deps.listJobs().find((j) => j.id === jobId) || job;
183
282
  const state = live?.state || current.status;
184
283
  const dur = deps.formatDuration(elapsedMs(current));
284
+ // Show who killed a failed job rather than guessing "user".
285
+ const origin = formatKillOriginSuffix(live?.killOrigin, t);
185
286
  return (
186
287
  `${statusGlyph(state, t)} ` +
187
288
  t.fg(
@@ -189,6 +290,7 @@ export async function openAsyncStatusOverlay(
189
290
  t.bold(`${jobTitle(current)} · ${current.id.slice(-8)}`),
190
291
  ) +
191
292
  t.fg("muted", ` · ${state} · ${dur}`) +
293
+ origin +
192
294
  t.fg("dim", ` · ${current.task.slice(0, 40)}`)
193
295
  );
194
296
  },
@@ -19,7 +19,6 @@
19
19
  * /create-skill <Tab> → (free-text name)
20
20
  * /cron <Tab> → add, list, remove
21
21
  * /dream-auto <Tab> → on, off
22
- * /async-kill <Tab> → all (or type name / id prefix)
23
22
  */
24
23
 
25
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -301,12 +300,6 @@ function registerCompletions(
301
300
  ], prefix);
302
301
  },
303
302
 
304
- "async-kill": (prefix: string) => {
305
- return filter([
306
- { value: "all", label: "all", description: "Kill all running/queued async agents" },
307
- ], prefix);
308
- },
309
-
310
303
  "cron": (prefix: string) => {
311
304
  const parts = prefix.split(/\s+/);
312
305
  const lastPart = parts[parts.length - 1] || "";
@@ -16,18 +16,26 @@ import {
16
16
  visibleWidth,
17
17
  wrapTextWithAnsi,
18
18
  } from "@earendil-works/pi-tui";
19
+ import { createLogger } from "../shared/logger.js";
19
20
  import {
20
21
  reconcileSelection,
21
22
  type OverlayId,
22
23
  type OverlaySelection,
23
24
  } from "./overlay-dashboard-utils.js";
24
25
 
26
+ const log = createLogger("overlay-dashboard");
27
+
25
28
  export {
26
29
  reconcileSelection,
27
30
  type OverlayId,
28
31
  type OverlaySelection,
29
32
  } from "./overlay-dashboard-utils.js";
30
33
 
34
+ /** Footer hint shown when a list spec does not provide its own. */
35
+ export const DEFAULT_LIST_HINTS = "↑↓/jk select · Enter view · x kill · Esc close";
36
+ /** Appended to the default hints only for `selectable` specs. */
37
+ export const SELECT_HINT = "Space multi-select";
38
+
31
39
  export const OVERLAY_OPTS = {
32
40
  overlay: true as const,
33
41
  overlayOptions: {
@@ -55,11 +63,18 @@ export interface OverlayListSpec<
55
63
  title: string;
56
64
  countLabel: (items: readonly TItem[]) => string;
57
65
  borderTitle: (items: readonly TItem[]) => string;
58
- footerHints: string;
66
+ /** Defaults to DEFAULT_LIST_HINTS (+ "Space multi-select" when selectable). */
67
+ footerHints?: string;
59
68
  listItems: () => TItem[];
60
69
  rowParts: (item: TItem, theme: Theme) => OverlayRowParts;
61
70
  /** Optional destructive/action key (x). */
62
71
  onX?: (item: TItem) => void;
72
+ /** Opt-in multi-select: Space toggles the focused row; `x` then acts on the whole selection. */
73
+ selectable?: boolean;
74
+ /** Called instead of onX while the multi-select set is non-empty. */
75
+ onXSelected?: (items: TItem[]) => void;
76
+ /** Optional bulk-action key (a) — destructive, so callers should confirm first. */
77
+ onKillAll?: () => void;
63
78
  }
64
79
 
65
80
  export interface OverlayDetailSpec {
@@ -111,6 +126,8 @@ export class OverlayListDashboard<
111
126
  private ticker: ReturnType<typeof setInterval>;
112
127
  private cachedWidth: number | undefined;
113
128
  private cachedLines: string[] | undefined;
129
+ /** Multi-selected ids — empty unless spec.selectable is set. */
130
+ private readonly multi = new Set<TId>();
114
131
 
115
132
  constructor(
116
133
  private tui: TUI,
@@ -189,7 +206,43 @@ export class OverlayListDashboard<
189
206
  }
190
207
  return;
191
208
  }
209
+ if (this.spec.selectable && (matchesKey(data, Key.space) || data === " ")) {
210
+ const focused = items[this.selection.index];
211
+ if (focused) {
212
+ if (this.multi.has(focused.id)) this.multi.delete(focused.id);
213
+ else this.multi.add(focused.id);
214
+ log.debug("list_multi_select_toggle", { id: focused.id, size: this.multi.size });
215
+ this.invalidate();
216
+ this.tui.requestRender(true);
217
+ }
218
+ return;
219
+ }
220
+ if ((data === "a" || data === "A" || data === "X") && this.spec.onKillAll) {
221
+ log.debug("list_kill_all_key", { key: data, selected: this.multi.size });
222
+ // Cleared for the same reason the x path clears it: killing marks jobs failed, so the
223
+ // stale ids would survive and a following `x` would act on them (or on nothing) instead
224
+ // of on the newly focused running row.
225
+ this.multi.clear();
226
+ this.spec.onKillAll();
227
+ this.invalidate();
228
+ this.tui.requestRender(true);
229
+ return;
230
+ }
192
231
  if (data === "x") {
232
+ if (this.multi.size > 0) {
233
+ const selected = items.filter((i) => this.multi.has(i.id));
234
+ // Cleared unconditionally: killed jobs disappear, and stale ids would leak.
235
+ this.multi.clear();
236
+ log.debug("list_multi_x", { count: selected.length });
237
+ if (selected.length > 0) this.spec.onXSelected?.(selected);
238
+ if (this.items().length === 0) {
239
+ this.close(null);
240
+ return;
241
+ }
242
+ this.invalidate();
243
+ this.tui.requestRender(true);
244
+ return;
245
+ }
193
246
  const item = items[this.selection.index];
194
247
  if (item) {
195
248
  this.spec.onX?.(item);
@@ -209,6 +262,7 @@ export class OverlayListDashboard<
209
262
  const theme = this.theme;
210
263
  const items = this.items();
211
264
  reconcileSelection(this.selection, items);
265
+ log.debug("list_render", { items: items.length, focused: this.selection.index, multi: this.multi.size });
212
266
 
213
267
  const rows = this.tui.terminal.rows || 30;
214
268
  const bodyHeight = Math.max(6, rows - 5);
@@ -246,15 +300,21 @@ export class OverlayListDashboard<
246
300
  theme.fg("border", "╯"),
247
301
  );
248
302
 
249
- lines.push(
250
- truncateToWidth(theme.fg("dim", ` ${this.spec.footerHints}`), width),
251
- );
303
+ lines.push(truncateToWidth(theme.fg("dim", ` ${this.footerText()}`), width));
252
304
 
253
305
  this.cachedLines = lines;
254
306
  this.cachedWidth = width;
255
307
  return lines;
256
308
  }
257
309
 
310
+ /** Spec hints, falling back to the shared defaults (+ toggle hint when selectable). */
311
+ private footerText(): string {
312
+ if (this.spec.footerHints) return this.spec.footerHints;
313
+ if (!this.spec.selectable) return DEFAULT_LIST_HINTS;
314
+ log.debug("list_footer_hints", { source: "default", selectable: true });
315
+ return `${DEFAULT_LIST_HINTS} · ${SELECT_HINT}`;
316
+ }
317
+
258
318
  private renderRows(
259
319
  items: ReadonlyArray<TItem>,
260
320
  width: number,
@@ -263,6 +323,8 @@ export class OverlayListDashboard<
263
323
  const theme = this.theme;
264
324
  const out: string[] = [];
265
325
 
326
+ log.debug("list_render_rows", { items: items.length, height, multi: this.multi.size });
327
+
266
328
  let start = 0;
267
329
  if (items.length > height) {
268
330
  start = Math.min(
@@ -282,7 +344,10 @@ export class OverlayListDashboard<
282
344
  const title = isSelected
283
345
  ? theme.fg("accent", parts.title)
284
346
  : theme.fg("text", parts.title);
285
- const left = ` ${marker} ${parts.glyph} ${title} ${parts.idLabel}`;
347
+ const picked = this.spec.selectable
348
+ ? this.multi.has(item.id) ? theme.fg("accent", "▸") : " "
349
+ : "";
350
+ const left = `${picked} ${marker} ${parts.glyph} ${title} ${parts.idLabel}`;
286
351
  const right = `${parts.rightParts.join(dot)} `;
287
352
  out.push(splitRow(left, right, width));
288
353
  }
@@ -41,7 +41,7 @@ import {
41
41
  decideAsyncLlmDispatch,
42
42
  supportsAsyncLlm,
43
43
  } from "./async-capability.js";
44
- import { autoMarkInProgress, autoCompleteTask } from "./async-agents.js";
44
+ import { autoMarkInProgress, autoCompleteTask, type AsyncKillOrigin } from "./async-agents.js";
45
45
  import { getSetting } from "./project-settings.js";
46
46
  import { checkSyncLimit } from "./sync-limit.js";
47
47
  import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
@@ -596,7 +596,7 @@ function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): stri
596
596
  export function registerSubagentTool(
597
597
  pi: ExtensionAPI,
598
598
  spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string },
599
- killAsyncAgent: (target: string) => { killed: string[]; errors: string[] },
599
+ killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] },
600
600
  ): void {
601
601
  // Only the orchestrator (top-level pi) can spawn subagents.
602
602
  // Child processes set PI_SUBAGENT_CHILD=1 to prevent infinite recursion.
@@ -604,11 +604,13 @@ export function registerSubagentTool(
604
604
 
605
605
  // ── Extracted execute helpers (closure over pi, spawnAsyncAgent, killAsyncAgent) ──
606
606
 
607
+ // asyncKill is an LLM-issued decision, not a user action.
607
608
  function executeAsyncKill(
608
609
  params: { asyncKill: string },
609
610
  mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
610
611
  ) {
611
- const { killed, errors } = killAsyncAgent(params.asyncKill);
612
+ const { killed, errors } = killAsyncAgent(params.asyncKill, "orchestrator");
613
+ log.info("subagent_async_kill", { target: params.asyncKill, origin: "orchestrator", killed: killed.length });
612
614
  const lines: string[] = [];
613
615
  if (killed.length > 0) lines.push(`Killed: ${killed.join(", ")}`);
614
616
  if (errors.length > 0) lines.push(errors.join("\n"));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pidiff-ui",
3
3
  "private": true,
4
- "version": "4.8.0",
4
+ "version": "4.8.2",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-orchestrator-config",
3
- "version": "4.8.0",
3
+ "version": "4.8.2",
4
4
  "description": "Orchestrator pattern for pi: specialist subagents, enforcement, code review loop, workflow commands",
5
5
  "keywords": [
6
6
  "pi-package"