pi-orchestrator-config 4.8.1 → 4.8.3
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 +5 -6
- package/extensions/coms/coms-p2p.ts +33 -4
- package/extensions/coms/coms-shared.ts +75 -0
- package/extensions/orchestrator/async-agents.ts +246 -58
- package/extensions/orchestrator/async-kill-signal.ts +43 -0
- package/extensions/orchestrator/async-result-format.ts +3 -1
- package/extensions/orchestrator/async-runner.ts +1 -1
- package/extensions/orchestrator/async-status-ui.ts +105 -3
- package/extensions/orchestrator/extended-autocomplete.ts +0 -7
- package/extensions/orchestrator/overlay-dashboard.ts +70 -5
- package/extensions/orchestrator/subagent-tool.ts +5 -3
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/shared/create-runtime-provider.ts +21 -1
- package/extensions/shared/models-dev.ts +17 -4
- package/extensions/shared/openai-compatible-discovery.ts +36 -13
- package/extensions/shared/pricing-provenance.ts +145 -0
- package/package.json +1 -1
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
|
|
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`, `/
|
|
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-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1431
|
-
if (status.childPid)
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
);
|