@ferris1225/pi-subagents 0.32.2 → 1.0.1
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 +45 -22
- package/agents/reviewer.md +2 -0
- package/package.json +2 -2
- package/src/agents.ts +2 -7
- package/src/announcements.ts +62 -0
- package/src/completion.ts +7 -36
- package/src/config.ts +327 -364
- package/src/dispatch.ts +1682 -1878
- package/src/fixloop.ts +0 -16
- package/src/format.ts +4 -8
- package/src/index.ts +8 -9
- package/src/models.ts +17 -39
- package/src/monitor.ts +64 -175
- package/src/rpc-run.ts +2 -41
- package/src/runtime.ts +272 -285
- package/src/session-fork.ts +0 -4
- package/src/setup.ts +4 -4
- package/src/spawn.ts +542 -562
- package/src/tools.ts +706 -748
- package/src/ui.ts +3 -7
- package/src/widget.ts +90 -178
- package/src/worktree.ts +1 -1
- package/src/inspector-panel.ts +0 -363
- package/src/inspector.ts +0 -369
- package/src/trajectory.ts +0 -503
package/src/fixloop.ts
CHANGED
|
@@ -86,22 +86,6 @@ export function chainKeyFragments(result: SingleResult): string[] {
|
|
|
86
86
|
return extractKeyFragments(getResultOutput(result)).slice(0, CHAIN_SUMMARY_FRAGMENTS_MAX);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
90
|
-
* Compact one-line outcome for a finished chain run, shown in the widget so
|
|
91
|
-
* each round reads as what it did: a reviewer reports its verdict plus the
|
|
92
|
-
* key fragments of what it found ("fail · src/index.ts · render()"), a worker
|
|
93
|
-
* the fragments of what it changed. Failed runs and runs with nothing
|
|
94
|
-
* distinctive get no summary.
|
|
95
|
-
*/
|
|
96
|
-
export function summarizeChainResult(result: SingleResult): string | undefined {
|
|
97
|
-
if (isFailedResult(result)) return undefined;
|
|
98
|
-
const verdict = result.agent === "reviewer" ? reviewVerdict(getResultOutput(result)) : undefined;
|
|
99
|
-
if (verdict === "pass") return "pass";
|
|
100
|
-
const fragments = chainKeyFragments(result);
|
|
101
|
-
if (verdict === "fail") return fragments.length > 0 ? `fail · ${fragments.join(" · ")}` : "fail";
|
|
102
|
-
return fragments.length > 0 ? fragments.join(" · ") : undefined;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
89
|
/**
|
|
106
90
|
* Condensed, readable summary of a completed auto-fix chain: one line per step
|
|
107
91
|
* (run id, role, verdict / what changed) plus aggregate usage. Full per-step
|
package/src/format.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { AgentConfig } from "./agents.ts";
|
|
8
8
|
import { formatTaskSummary } from "./monitor.ts";
|
|
9
|
+
import { emptyUsage } from "./rpc-run.ts";
|
|
9
10
|
import {
|
|
10
11
|
getResultOutput,
|
|
11
12
|
isFailedResult,
|
|
@@ -15,14 +16,9 @@ import {
|
|
|
15
16
|
type UsageStats,
|
|
16
17
|
} from "./spawn.ts";
|
|
17
18
|
|
|
18
|
-
export function emptyUsage(): UsageStats {
|
|
19
|
-
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
19
|
export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
|
|
23
20
|
return {
|
|
24
21
|
agent: agent.name,
|
|
25
|
-
agentSource: agent.source,
|
|
26
22
|
task,
|
|
27
23
|
exitCode: -1,
|
|
28
24
|
messages: [],
|
|
@@ -36,7 +32,6 @@ export function queuedResult(agent: AgentConfig, task: string, thinking?: string
|
|
|
36
32
|
export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
|
|
37
33
|
return {
|
|
38
34
|
agent: agentName,
|
|
39
|
-
agentSource: "unknown",
|
|
40
35
|
task,
|
|
41
36
|
exitCode: 1,
|
|
42
37
|
messages: [],
|
|
@@ -61,7 +56,7 @@ export function dispatchFailedResult(agent: AgentConfig, task: string, error: un
|
|
|
61
56
|
};
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
|
|
59
|
+
function formatTokens(count: number): string {
|
|
65
60
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
66
61
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
67
62
|
return String(count);
|
|
@@ -103,7 +98,8 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
|
|
|
103
98
|
(result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
|
|
104
99
|
].filter((value): value is string => Boolean(value));
|
|
105
100
|
const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
|
|
106
|
-
const
|
|
101
|
+
const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
|
|
102
|
+
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
|
|
107
103
|
if (result.isolation === "worktree") {
|
|
108
104
|
const isolation =
|
|
109
105
|
result.integrationStatus === "integrated"
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* The heavy lifting lives in focused modules:
|
|
6
6
|
* - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
|
|
7
7
|
* - tools.ts — subagent_control / subagent_wait / status / stop
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
8
|
+
* - announcements.ts — session-start recovery, notices, and widget install
|
|
9
|
+
* - widget.ts — active-only TUI run status
|
|
10
|
+
* - runtime.ts — shared per-session state
|
|
10
11
|
*
|
|
11
12
|
* Also registers the `/subagents-setup` command and a `before_agent_start` hook
|
|
12
13
|
* that injects a delegation directive into the parent system prompt so the main
|
|
@@ -19,16 +20,16 @@
|
|
|
19
20
|
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { Text } from "@earendil-works/pi-tui";
|
|
21
22
|
import { discoverAgents } from "./agents.ts";
|
|
23
|
+
import { registerAnnouncements } from "./announcements.ts";
|
|
22
24
|
import { getConfigPath, loadConfig } from "./config.ts";
|
|
23
25
|
import { registerSubagentTool } from "./dispatch.ts";
|
|
24
26
|
import { matchRunIds } from "./format.ts";
|
|
25
|
-
import { registerInspectorCommand } from "./inspector-panel.ts";
|
|
26
27
|
import { buildDelegationDirective } from "./prompt.ts";
|
|
27
28
|
import { createRuntime } from "./runtime.ts";
|
|
28
29
|
import { runSetup } from "./setup.ts";
|
|
29
30
|
import { currentSubagentDepth } from "./spawn.ts";
|
|
30
31
|
import { registerLookupTools } from "./tools.ts";
|
|
31
|
-
import {
|
|
32
|
+
import { clearActiveRunsWidget } from "./widget.ts";
|
|
32
33
|
|
|
33
34
|
export { matchRunIds };
|
|
34
35
|
|
|
@@ -58,13 +59,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
58
59
|
),
|
|
59
60
|
);
|
|
60
61
|
|
|
61
|
-
pi.on("session_shutdown", async () => {
|
|
62
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
63
|
+
clearActiveRunsWidget(ctx);
|
|
62
64
|
await runtime.shutdown();
|
|
63
65
|
});
|
|
64
66
|
|
|
65
67
|
registerSubagentTool(pi, runtime);
|
|
66
68
|
registerLookupTools(pi, runtime);
|
|
67
|
-
registerInspectorCommand(pi, runtime);
|
|
68
69
|
|
|
69
70
|
pi.registerCommand("subagents-setup", {
|
|
70
71
|
description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
|
|
@@ -73,9 +74,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
73
74
|
},
|
|
74
75
|
});
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
// one-time feature announcements after updates.
|
|
78
|
-
registerWidget(pi, runtime);
|
|
77
|
+
registerAnnouncements(pi, runtime);
|
|
79
78
|
|
|
80
79
|
// Proactive dispatch: inject the delegation directive into the parent system prompt.
|
|
81
80
|
pi.on("before_agent_start", async (event, ctx) => {
|
package/src/models.ts
CHANGED
|
@@ -22,8 +22,6 @@ export interface ModelPickerItem {
|
|
|
22
22
|
value: string;
|
|
23
23
|
label: string;
|
|
24
24
|
description?: string;
|
|
25
|
-
/** Visible for diagnosis/search, but cannot be selected. */
|
|
26
|
-
disabled?: boolean;
|
|
27
25
|
}
|
|
28
26
|
|
|
29
27
|
export type ModelListEntry = Pick<
|
|
@@ -72,19 +70,18 @@ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | unde
|
|
|
72
70
|
}
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
73
|
+
* Current authenticated registry models narrowed by the session scope. Scope
|
|
74
|
+
* entries are a session snapshot, so they act only as a whitelist; the live
|
|
75
|
+
* registry remains the source of truth for availability and model metadata.
|
|
77
76
|
*/
|
|
78
|
-
export function
|
|
77
|
+
export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
|
|
78
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
79
79
|
// scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
|
|
80
|
-
// missing field exactly like an empty scope and use the registry
|
|
80
|
+
// missing field exactly like an empty scope and use the full live registry.
|
|
81
81
|
const scopedModels = ctx.scopedModels ?? [];
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
const currentRef = currentModelRef(ctx);
|
|
86
|
-
if (!currentRef) return refs;
|
|
87
|
-
return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
|
|
82
|
+
if (scopedModels.length === 0) return models;
|
|
83
|
+
const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
|
|
84
|
+
return models.filter((model) => scopedRefs.has(modelRef(model)));
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
/**
|
|
@@ -118,7 +115,9 @@ function modelCapabilities(model: ModelListEntry): string {
|
|
|
118
115
|
return capabilities.join(" + ");
|
|
119
116
|
}
|
|
120
117
|
|
|
121
|
-
/** Build the single searchable model list shared by primary/backup/vision picks.
|
|
118
|
+
/** Build the single searchable model list shared by primary/backup/vision picks.
|
|
119
|
+
* Only refs Pi currently reports as available are shown, which means providers
|
|
120
|
+
* without a configured API key/OAuth session never flood the setup picker. */
|
|
122
121
|
export function buildModelPickerItems(options: {
|
|
123
122
|
models: readonly ModelListEntry[];
|
|
124
123
|
availableRefs: readonly string[];
|
|
@@ -132,21 +131,16 @@ export function buildModelPickerItems(options: {
|
|
|
132
131
|
const byRef = new Map<string, ModelListEntry>();
|
|
133
132
|
for (const model of options.models) {
|
|
134
133
|
const ref = modelRef(model);
|
|
135
|
-
if (!byRef.has(ref)) byRef.set(ref, model);
|
|
134
|
+
if (available.has(ref) && !byRef.has(ref)) byRef.set(ref, model);
|
|
136
135
|
}
|
|
137
136
|
|
|
138
137
|
const refs = [...byRef.keys()]
|
|
139
|
-
.filter((ref) =>
|
|
140
|
-
options.slot !== "vision" ||
|
|
141
|
-
byRef.get(ref)?.input.includes("image") === true ||
|
|
142
|
-
ref === configuredRef,
|
|
143
|
-
)
|
|
138
|
+
.filter((ref) => options.slot !== "vision" || byRef.get(ref)?.input.includes("image") === true)
|
|
144
139
|
.sort((left, right) => {
|
|
145
140
|
const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
|
|
146
141
|
const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
|
|
147
142
|
return leftRank - rightRank || left.localeCompare(right);
|
|
148
143
|
});
|
|
149
|
-
if (configuredRef && !byRef.has(configuredRef)) refs.unshift(configuredRef);
|
|
150
144
|
|
|
151
145
|
const dynamic = options.slot === "backup"
|
|
152
146
|
? {
|
|
@@ -164,31 +158,15 @@ export function buildModelPickerItems(options: {
|
|
|
164
158
|
|
|
165
159
|
const items: ModelPickerItem[] = [dynamic];
|
|
166
160
|
for (const ref of refs) {
|
|
167
|
-
const model = byRef.get(ref)
|
|
168
|
-
const tags = [
|
|
161
|
+
const model = byRef.get(ref)!;
|
|
162
|
+
const tags = ["available"];
|
|
169
163
|
if (ref === configuredRef) tags.push("configured");
|
|
170
164
|
if (ref === mainRef) tags.push("current main");
|
|
171
|
-
if (!model) {
|
|
172
|
-
const compatibility = options.slot === "vision"
|
|
173
|
-
? "incompatible with vision (capability unknown)"
|
|
174
|
-
: undefined;
|
|
175
|
-
items.push({
|
|
176
|
-
value: ref,
|
|
177
|
-
label: ref,
|
|
178
|
-
description: [...tags, compatibility, "saved model reference"].filter(Boolean).join(" · "),
|
|
179
|
-
...(options.slot === "vision" ? { disabled: true } : {}),
|
|
180
|
-
});
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
165
|
const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
|
|
184
|
-
const compatibility = options.slot === "vision" && !model.input.includes("image")
|
|
185
|
-
? "incompatible with vision"
|
|
186
|
-
: undefined;
|
|
187
166
|
items.push({
|
|
188
167
|
value: ref,
|
|
189
168
|
label: ref,
|
|
190
|
-
description: [name, modelCapabilities(model),
|
|
191
|
-
...(compatibility ? { disabled: true } : {}),
|
|
169
|
+
description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
|
|
192
170
|
});
|
|
193
171
|
}
|
|
194
172
|
return items;
|
package/src/monitor.ts
CHANGED
|
@@ -2,18 +2,16 @@
|
|
|
2
2
|
* Sub-agent monitor: a module-level singleton store that tracks subagent runs
|
|
3
3
|
* for the current turn.
|
|
4
4
|
*
|
|
5
|
-
* The store notifies
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* as soon as they finish: the tool result is the durable record in the main
|
|
10
|
-
* conversation, so a stale "done" row must not linger in the widget.
|
|
5
|
+
* The store notifies wait/status consumers on every mutation. Each run carries
|
|
6
|
+
* timing information plus a concise activity string ("thinking",
|
|
7
|
+
* "read src/index.ts", ...). Runs are removed after publication; tool results
|
|
8
|
+
* and the finished-run registry are the durable user-facing records.
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
11
|
import { stripVTControlCharacters } from "node:util";
|
|
14
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import {
|
|
16
|
-
import type
|
|
13
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
+
import { emptyUsage, type UsageStats } from "./rpc-run.ts";
|
|
17
15
|
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
18
16
|
|
|
19
17
|
// ---------------------------------------------------------------------------
|
|
@@ -26,20 +24,6 @@ export function isRunActiveStatus(status: RunStatus): boolean {
|
|
|
26
24
|
return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
|
|
27
25
|
}
|
|
28
26
|
|
|
29
|
-
/** Soft state-awareness signals, complementary to the hard idle-kill: a run may
|
|
30
|
-
* be alive (stdout streaming) yet "stuck thinking" (no tool running for a while),
|
|
31
|
-
* or simply taking a long time. Both are surfaced as widget annotations so the
|
|
32
|
-
* user can tell a healthy busy run from one that needs a nudge. */
|
|
33
|
-
export type ActivityState = "needs_attention" | "active_long_running";
|
|
34
|
-
|
|
35
|
-
/** A run with no tool running and no activity for this long is "needs attention"
|
|
36
|
-
* (the model may be stuck between turns). Below the idle-kill threshold so the
|
|
37
|
-
* soft signal always fires before the hard kill. */
|
|
38
|
-
export const NEEDS_ATTENTION_AFTER_MS = 60_000;
|
|
39
|
-
/** A run whose total elapsed time exceeds this is "long-running": still active
|
|
40
|
-
* but worth flagging so the user can decide whether to wait or steer. */
|
|
41
|
-
export const ACTIVE_LONG_RUNNING_AFTER_MS = 240_000;
|
|
42
|
-
|
|
43
27
|
export interface RunView {
|
|
44
28
|
id: number;
|
|
45
29
|
agent: string;
|
|
@@ -61,14 +45,6 @@ export interface RunView {
|
|
|
61
45
|
usage: UsageStats;
|
|
62
46
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
63
47
|
activity?: string;
|
|
64
|
-
/** Total tool calls started by the run so far (a progress signal). */
|
|
65
|
-
toolCount?: number;
|
|
66
|
-
/** Tool currently executing (set on tool_start, cleared on tool_end). When set,
|
|
67
|
-
* the run is NOT idle for needs-attention purposes. */
|
|
68
|
-
currentTool?: string;
|
|
69
|
-
/** Epoch ms of the last live activity (tool, usage, status). Used to derive
|
|
70
|
-
* the needs-attention state: no tool running AND now - lastActivityAt > threshold. */
|
|
71
|
-
lastActivityAt?: number;
|
|
72
48
|
/** Epoch ms when the run started executing (set on first "running" status). */
|
|
73
49
|
startedAt?: number;
|
|
74
50
|
/** Epoch ms when the run finished (set on "done"/"failed"). */
|
|
@@ -77,17 +53,6 @@ export interface RunView {
|
|
|
77
53
|
groupId?: string;
|
|
78
54
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
79
55
|
relationLabel?: string;
|
|
80
|
-
/** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
|
|
81
|
-
annotation?: string;
|
|
82
|
-
/** One-line outcome summary of a finished chain run, shown in the widget so
|
|
83
|
-
* each auto-fix round reads as what it did: a reviewer reports its verdict
|
|
84
|
-
* plus key fragments of what it found ("fail · src/index.ts · render()"), a
|
|
85
|
-
* worker the fragments of what it changed. Unset for non-chain runs. */
|
|
86
|
-
summary?: string;
|
|
87
|
-
/** True when a finished run is intentionally kept in the widget (e.g. an
|
|
88
|
-
* auto-fix chain parent whose chain is still running). beginTurn preserves
|
|
89
|
-
* retained runs so they are not swept between turns. */
|
|
90
|
-
retained?: boolean;
|
|
91
56
|
}
|
|
92
57
|
|
|
93
58
|
/** Optional chain metadata for runs spawned by an auto-fix loop. */
|
|
@@ -107,7 +72,7 @@ const TASK_SUMMARY_ELLIPSIS = "…";
|
|
|
107
72
|
/** Columns reserved at the END of a truncated summary so the distinguishing
|
|
108
73
|
* keywords (paths, symbols, ...) survive; the head gets the rest. */
|
|
109
74
|
const TASK_SUMMARY_TAIL_MAX = 28;
|
|
110
|
-
/** Tail share of a non-default maxWidth (narrow
|
|
75
|
+
/** Tail share of a non-default maxWidth (narrow summaries keep a usable tail). */
|
|
111
76
|
const TASK_SUMMARY_TAIL_SHARE = 0.35;
|
|
112
77
|
const TASK_SUMMARY_TAIL_MIN = 8;
|
|
113
78
|
const TASK_SUMMARY_KEY_SEP = " · ";
|
|
@@ -296,72 +261,49 @@ export function formatDuration(ms: number): string {
|
|
|
296
261
|
/** Elapsed wall time of a run: live while running, final once finished. */
|
|
297
262
|
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
298
263
|
if (run.startedAt === undefined) return "";
|
|
299
|
-
|
|
300
|
-
// must keep ticking: its `endedAt` was stamped when the review itself
|
|
301
|
-
// finished, but the work is ongoing, so show live elapsed until the chain
|
|
302
|
-
// resolves and the row is removed. Without this, subagent_status would show a
|
|
303
|
-
// frozen elapsed for a run the UI otherwise presents as still active.
|
|
304
|
-
const end = run.retained ? now : (run.endedAt ?? now);
|
|
305
|
-
return formatDuration(end - run.startedAt);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/** Strip the provider prefix from a "provider/model-id" reference for compact
|
|
309
|
-
* widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
|
|
310
|
-
* left unchanged. */
|
|
311
|
-
export function compactModelRef(model: string | undefined): string {
|
|
312
|
-
if (!model) return "";
|
|
313
|
-
const slash = model.lastIndexOf("/");
|
|
314
|
-
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
264
|
+
return formatDuration((run.endedAt ?? now) - run.startedAt);
|
|
315
265
|
}
|
|
316
266
|
|
|
317
|
-
/**
|
|
318
|
-
export
|
|
319
|
-
return state === "needs_attention" ? "idle" : "long-running";
|
|
320
|
-
}
|
|
267
|
+
/** Max length of the argument target inside a formatted activity line. */
|
|
268
|
+
export const ACTIVITY_TARGET_MAX = 60;
|
|
321
269
|
|
|
322
|
-
|
|
323
|
-
* (no tool running, idle past the threshold) takes priority over
|
|
324
|
-
* active_long_running (total elapsed past its threshold). Both are suppressed
|
|
325
|
-
* for non-running runs. */
|
|
326
|
-
export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
|
|
327
|
-
if (run.status !== "running" && run.status !== "steering") return undefined;
|
|
328
|
-
if (!run.currentTool) {
|
|
329
|
-
const since = run.lastActivityAt ?? run.startedAt ?? now;
|
|
330
|
-
if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
|
|
331
|
-
}
|
|
332
|
-
if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
|
|
333
|
-
return "active_long_running";
|
|
334
|
-
}
|
|
335
|
-
return undefined;
|
|
336
|
-
}
|
|
270
|
+
const REDACTED = "<redacted>";
|
|
337
271
|
|
|
338
|
-
/**
|
|
339
|
-
*
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
272
|
+
/** Remove credentials embedded in otherwise ordinary activity strings such as
|
|
273
|
+
* shell commands and HTTP headers. */
|
|
274
|
+
function redactSensitiveText(value: string): string {
|
|
275
|
+
let text = stripVTControlCharacters(value);
|
|
276
|
+
text = text.replace(
|
|
277
|
+
/(\bauthorization\s*:\s*(?:bearer|basic)\s+)([^\s'"`;,]+)/giu,
|
|
278
|
+
`$1${REDACTED}`,
|
|
279
|
+
);
|
|
280
|
+
text = text.replace(
|
|
281
|
+
/(\bbearer\s+)([A-Za-z0-9._~+/=-]{6,})/giu,
|
|
282
|
+
`$1${REDACTED}`,
|
|
283
|
+
);
|
|
284
|
+
text = text.replace(
|
|
285
|
+
/(\b(?:api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|token|password|passwd|secret|credential|cookie)\b\s*(?:=|:)\s*)(?:"[^"]*"|'[^']*'|[^\s;&,]+)/giu,
|
|
286
|
+
`$1${REDACTED}`,
|
|
287
|
+
);
|
|
288
|
+
text = text.replace(
|
|
289
|
+
/((?:--?(?:api[-_]?key|access[-_]?token|token|password|secret|credential))\s+)(?:"[^"]*"|'[^']*'|\S+)/giu,
|
|
290
|
+
`$1${REDACTED}`,
|
|
291
|
+
);
|
|
292
|
+
return text.replace(
|
|
293
|
+
/\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[A-Z0-9]{16})\b/gu,
|
|
294
|
+
REDACTED,
|
|
295
|
+
);
|
|
347
296
|
}
|
|
348
297
|
|
|
349
|
-
/**
|
|
350
|
-
*
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
* pass an empty string when there is nothing to append. Styled strings are
|
|
354
|
-
* measured by display width. */
|
|
355
|
-
export function compactLine(left: string, right: string, width: number): string {
|
|
356
|
-
return truncateToWidth(`${left}${right}`, width);
|
|
298
|
+
/** Monitor activity is returned to the parent model and rendered in the terminal,
|
|
299
|
+
* so treat every live string as untrusted before it reaches store state. */
|
|
300
|
+
function sanitizeActivityText(value: string): string {
|
|
301
|
+
return redactSensitiveText(value).replace(/\s+/g, " ").trim();
|
|
357
302
|
}
|
|
358
303
|
|
|
359
|
-
/** Max length of the argument target inside a formatted activity line. */
|
|
360
|
-
export const ACTIVITY_TARGET_MAX = 60;
|
|
361
|
-
|
|
362
304
|
function shortTarget(value: unknown): string {
|
|
363
305
|
if (typeof value !== "string") return "";
|
|
364
|
-
const oneLine = value
|
|
306
|
+
const oneLine = sanitizeActivityText(value);
|
|
365
307
|
// Slice by code point so emoji / CJK-ext never leave a lone surrogate.
|
|
366
308
|
const chars = [...oneLine];
|
|
367
309
|
return chars.length > ACTIVITY_TARGET_MAX ? `${chars.slice(0, ACTIVITY_TARGET_MAX - 1).join("")}…` : oneLine;
|
|
@@ -410,7 +352,8 @@ export function formatToolActivity(toolName: string, args: unknown): string {
|
|
|
410
352
|
default:
|
|
411
353
|
target = pick("path", "command", "query", "pattern", "url", "file", "task");
|
|
412
354
|
}
|
|
413
|
-
|
|
355
|
+
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
356
|
+
return target ? `${safeToolName} ${target}` : safeToolName;
|
|
414
357
|
}
|
|
415
358
|
|
|
416
359
|
// ---------------------------------------------------------------------------
|
|
@@ -423,19 +366,22 @@ export class MonitorStore {
|
|
|
423
366
|
private subscribers = new Set<() => void>();
|
|
424
367
|
|
|
425
368
|
beginTurn(): void {
|
|
426
|
-
// Clear finished runs from a previous turn, but keep
|
|
427
|
-
//
|
|
428
|
-
// Retained runs (e.g. an auto-fix chain parent whose chain is still
|
|
429
|
-
// running) are also preserved — their status is "done" but they must
|
|
430
|
-
// stay visible until the chain resolves.
|
|
369
|
+
// Clear finished runs from a previous turn, but keep active and parked
|
|
370
|
+
// threads so concurrent work is not wiped between parent turns.
|
|
431
371
|
this.runs = this.runs.filter(
|
|
432
|
-
(r) => isRunActiveStatus(r.status) || r.status === "parked"
|
|
372
|
+
(r) => isRunActiveStatus(r.status) || r.status === "parked",
|
|
433
373
|
);
|
|
434
374
|
this.notify();
|
|
435
375
|
}
|
|
436
376
|
|
|
377
|
+
/** Reserve a stable id for a durable result that must remain independently
|
|
378
|
+
* addressable without appearing as a live monitor row. */
|
|
379
|
+
reserveRunId(): number {
|
|
380
|
+
return this.nextId++;
|
|
381
|
+
}
|
|
382
|
+
|
|
437
383
|
addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
|
|
438
|
-
const id = this.
|
|
384
|
+
const id = this.reserveRunId();
|
|
439
385
|
this.runs.push({
|
|
440
386
|
id,
|
|
441
387
|
agent,
|
|
@@ -444,7 +390,7 @@ export class MonitorStore {
|
|
|
444
390
|
model,
|
|
445
391
|
thinking,
|
|
446
392
|
status: "queued",
|
|
447
|
-
usage:
|
|
393
|
+
usage: emptyUsage(),
|
|
448
394
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
449
395
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
450
396
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
@@ -463,7 +409,6 @@ export class MonitorStore {
|
|
|
463
409
|
// A model-fallback retry or resumed generation restarts the clock; a
|
|
464
410
|
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
465
411
|
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
466
|
-
run.lastActivityAt = Date.now();
|
|
467
412
|
} else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
468
413
|
run.endedAt = Date.now();
|
|
469
414
|
}
|
|
@@ -474,7 +419,6 @@ export class MonitorStore {
|
|
|
474
419
|
if (!run) return;
|
|
475
420
|
run.usage = { ...usage };
|
|
476
421
|
if (model) run.model = model;
|
|
477
|
-
run.lastActivityAt = Date.now();
|
|
478
422
|
this.notify();
|
|
479
423
|
}
|
|
480
424
|
|
|
@@ -491,56 +435,25 @@ export class MonitorStore {
|
|
|
491
435
|
setActivity(id: number, text: string): void {
|
|
492
436
|
const run = this.find(id);
|
|
493
437
|
if (!run) return;
|
|
494
|
-
run.activity = text;
|
|
495
|
-
run.lastActivityAt = Date.now();
|
|
438
|
+
run.activity = sanitizeActivityText(text) || undefined;
|
|
496
439
|
this.notify();
|
|
497
440
|
}
|
|
498
441
|
|
|
499
|
-
/** Record a tool starting
|
|
500
|
-
* A running tool means the run is NOT idle, so needs-attention is suppressed
|
|
501
|
-
* while it stays current. */
|
|
442
|
+
/** Record a tool starting and update the run's visible activity. */
|
|
502
443
|
recordToolStart(id: number, toolName: string, activity: string): void {
|
|
503
444
|
const run = this.find(id);
|
|
504
445
|
if (!run) return;
|
|
505
|
-
|
|
506
|
-
run.
|
|
507
|
-
run.activity = activity;
|
|
508
|
-
run.lastActivityAt = Date.now();
|
|
446
|
+
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
447
|
+
run.activity = sanitizeActivityText(activity) || safeToolName;
|
|
509
448
|
this.notify();
|
|
510
449
|
}
|
|
511
450
|
|
|
512
|
-
/** Record a tool
|
|
513
|
-
*
|
|
451
|
+
/** Record a failed tool; successful completions keep their last activity
|
|
452
|
+
* until the next model event supplies a more useful description. */
|
|
514
453
|
recordToolEnd(id: number, toolName: string, isError: boolean): void {
|
|
515
454
|
const run = this.find(id);
|
|
516
455
|
if (!run) return;
|
|
517
|
-
run.
|
|
518
|
-
run.lastActivityAt = Date.now();
|
|
519
|
-
if (isError) run.activity = `✗ ${toolName} failed`;
|
|
520
|
-
this.notify();
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
/** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
|
|
524
|
-
setAnnotation(id: number, text: string): void {
|
|
525
|
-
const run = this.find(id);
|
|
526
|
-
if (!run) return;
|
|
527
|
-
run.annotation = text;
|
|
528
|
-
this.notify();
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Set the run's one-line outcome summary (what a finished chain round did). */
|
|
532
|
-
setSummary(id: number, text: string | undefined): void {
|
|
533
|
-
const run = this.find(id);
|
|
534
|
-
if (!run) return;
|
|
535
|
-
run.summary = text;
|
|
536
|
-
this.notify();
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** Mark a run as retained (kept in the widget despite being finished). */
|
|
540
|
-
setRetained(id: number, retained: boolean): void {
|
|
541
|
-
const run = this.find(id);
|
|
542
|
-
if (!run) return;
|
|
543
|
-
run.retained = retained;
|
|
456
|
+
if (isError) run.activity = `✗ ${sanitizeActivityText(toolName) || "tool"} failed`;
|
|
544
457
|
this.notify();
|
|
545
458
|
}
|
|
546
459
|
|
|
@@ -585,7 +498,7 @@ export class MonitorStore {
|
|
|
585
498
|
thinking,
|
|
586
499
|
...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
|
|
587
500
|
status: "queued",
|
|
588
|
-
usage:
|
|
501
|
+
usage: emptyUsage(),
|
|
589
502
|
});
|
|
590
503
|
this.notify();
|
|
591
504
|
return;
|
|
@@ -598,16 +511,10 @@ export class MonitorStore {
|
|
|
598
511
|
if (isolation) run.isolation = isolation;
|
|
599
512
|
run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
|
|
600
513
|
run.status = "queued";
|
|
601
|
-
run.usage =
|
|
514
|
+
run.usage = emptyUsage();
|
|
602
515
|
run.activity = undefined;
|
|
603
|
-
run.toolCount = undefined;
|
|
604
|
-
run.currentTool = undefined;
|
|
605
|
-
run.lastActivityAt = undefined;
|
|
606
516
|
run.startedAt = undefined;
|
|
607
517
|
run.endedAt = undefined;
|
|
608
|
-
run.annotation = undefined;
|
|
609
|
-
run.summary = undefined;
|
|
610
|
-
run.retained = undefined;
|
|
611
518
|
this.notify();
|
|
612
519
|
}
|
|
613
520
|
|
|
@@ -624,7 +531,7 @@ export class MonitorStore {
|
|
|
624
531
|
this.notify();
|
|
625
532
|
}
|
|
626
533
|
|
|
627
|
-
/** Remove a run
|
|
534
|
+
/** Remove a run after publication. Returns the removed run. */
|
|
628
535
|
removeRun(id: number): RunView | undefined {
|
|
629
536
|
const index = this.runs.findIndex((r) => r.id === id);
|
|
630
537
|
if (index === -1) return undefined;
|
|
@@ -648,7 +555,6 @@ export class MonitorStore {
|
|
|
648
555
|
const usage = formatUsageCompact(run.usage);
|
|
649
556
|
const parts = [run.agent];
|
|
650
557
|
if (run.relationLabel) parts.push(run.relationLabel);
|
|
651
|
-
if (run.summary) parts.push(run.summary);
|
|
652
558
|
if (run.model) parts.push(run.model);
|
|
653
559
|
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
654
560
|
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
@@ -698,7 +604,7 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
|
|
|
698
604
|
}
|
|
699
605
|
}
|
|
700
606
|
|
|
701
|
-
/** User-facing status label
|
|
607
|
+
/** User-facing status label used by tool/status rendering. */
|
|
702
608
|
export function statusLabel(status: RunStatus): string {
|
|
703
609
|
switch (status) {
|
|
704
610
|
case "queued":
|
|
@@ -717,20 +623,3 @@ export function statusLabel(status: RunStatus): string {
|
|
|
717
623
|
return "stopped";
|
|
718
624
|
}
|
|
719
625
|
}
|
|
720
|
-
|
|
721
|
-
/** Theme color matching the status label. */
|
|
722
|
-
export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
|
|
723
|
-
switch (status) {
|
|
724
|
-
case "running":
|
|
725
|
-
case "steering":
|
|
726
|
-
return "accent";
|
|
727
|
-
case "interrupting":
|
|
728
|
-
return "warning";
|
|
729
|
-
case "done":
|
|
730
|
-
return "success";
|
|
731
|
-
case "failed":
|
|
732
|
-
return "error";
|
|
733
|
-
default:
|
|
734
|
-
return "dim";
|
|
735
|
-
}
|
|
736
|
-
}
|