@ferris1225/pi-subagents 4.1.1 → 4.1.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 +461 -381
- package/agents/cleaner.md +16 -6
- package/agents/documenter.md +46 -0
- package/agents/explorer.md +15 -11
- package/agents/reviewer.md +8 -4
- package/agents/worker.md +9 -4
- package/package.json +55 -55
- package/src/agents.ts +53 -0
- package/src/announcements.ts +18 -1
- package/src/completion.ts +160 -160
- package/src/config.ts +43 -13
- package/src/dispatch.ts +233 -303
- package/src/fixloop.ts +259 -62
- package/src/index.ts +3 -3
- package/src/models.ts +189 -189
- package/src/monitor.ts +101 -22
- package/src/prompt.ts +47 -12
- package/src/recovery.ts +145 -145
- package/src/rpc-run.ts +29 -10
- package/src/runtime.ts +13 -7
- package/src/session-fork.ts +80 -80
- package/src/setup.ts +162 -130
- package/src/spawn.ts +53 -13
- package/src/thread-lifecycle.ts +240 -54
- package/src/tools.ts +65 -37
- package/src/widget.ts +68 -22
- package/src/worktree.ts +27 -4
package/src/models.ts
CHANGED
|
@@ -1,189 +1,189 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Model routing, capability-aware thinking, and setup-picker helpers.
|
|
3
|
-
*
|
|
4
|
-
* Runtime has one explicit fallback only: a configured agent model hands
|
|
5
|
-
* off directly to the current main-window model. Setup lists only currently
|
|
6
|
-
* available models and derives thinking choices from Pi's model metadata.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
clampThinkingLevel,
|
|
11
|
-
getSupportedThinkingLevels,
|
|
12
|
-
type Api,
|
|
13
|
-
type Model,
|
|
14
|
-
} from "@earendil-works/pi-ai";
|
|
15
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
|
|
17
|
-
|
|
18
|
-
export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
|
|
19
|
-
Partial<Pick<ExtensionContext, "scopedModels">>;
|
|
20
|
-
|
|
21
|
-
export const CURRENT_MAIN_MODEL = "__current_main_model__";
|
|
22
|
-
|
|
23
|
-
export interface ModelPickerItem {
|
|
24
|
-
value: string;
|
|
25
|
-
label: string;
|
|
26
|
-
description?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export type ModelListEntry = Pick<
|
|
30
|
-
Model<Api>,
|
|
31
|
-
"provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
|
|
32
|
-
>;
|
|
33
|
-
|
|
34
|
-
export interface ResolvedAgentModelRoute {
|
|
35
|
-
/** Effective first candidate. Undefined means let Pi use its normal default. */
|
|
36
|
-
primaryRef?: string;
|
|
37
|
-
/** Current main-window model when it differs from the selection. */
|
|
38
|
-
mainFallbackRef?: string;
|
|
39
|
-
/** Runtime order, useful for status/tests. */
|
|
40
|
-
candidateRefs: string[];
|
|
41
|
-
/** Configured ref skipped because Pi does not currently report it available. */
|
|
42
|
-
unavailableSelectedRef?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface AgentModelRouteInput {
|
|
46
|
-
selectedRef?: string;
|
|
47
|
-
mainRef?: string;
|
|
48
|
-
declaredDefaultRef?: string;
|
|
49
|
-
/** When supplied, a configured selection outside this live set is skipped. */
|
|
50
|
-
availableRefs?: readonly string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function cleanModelRef(ref: string | undefined): string | undefined {
|
|
54
|
-
const trimmed = ref?.trim();
|
|
55
|
-
return trimmed || undefined;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function modelRef(model: { provider: string; id: string }): string {
|
|
59
|
-
return `${model.provider}/${model.id}`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
|
|
63
|
-
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Current authenticated registry models narrowed by the session scope. Scope
|
|
68
|
-
* entries are a session snapshot, so they act only as a whitelist; the live
|
|
69
|
-
* registry remains the source of truth for availability and model metadata.
|
|
70
|
-
*/
|
|
71
|
-
export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
|
|
72
|
-
const models = ctx.modelRegistry.getAvailable();
|
|
73
|
-
// scopedModels was added after the original Pi minimum. Treat a missing field
|
|
74
|
-
// exactly like an empty scope and use the full live registry.
|
|
75
|
-
const scopedModels = ctx.scopedModels ?? [];
|
|
76
|
-
if (scopedModels.length === 0) return models;
|
|
77
|
-
const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
|
|
78
|
-
return models.filter((model) => scopedRefs.has(modelRef(model)));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function findModelByRef(
|
|
82
|
-
models: readonly Model<Api>[],
|
|
83
|
-
ref: string | undefined,
|
|
84
|
-
): Model<Api> | undefined {
|
|
85
|
-
const normalized = cleanModelRef(ref);
|
|
86
|
-
return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Resolve one agent's runtime route:
|
|
91
|
-
*
|
|
92
|
-
* configured selection -> current main-window model
|
|
93
|
-
*
|
|
94
|
-
* Without an override, current main is primary; the agent-declared default is
|
|
95
|
-
* used only when no main model exists. A configured selection that Pi no longer
|
|
96
|
-
* reports as available is skipped immediately instead of spawning a doomed child.
|
|
97
|
-
*/
|
|
98
|
-
export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
|
|
99
|
-
const selectedRef = cleanModelRef(input.selectedRef);
|
|
100
|
-
const mainRef = cleanModelRef(input.mainRef);
|
|
101
|
-
const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
|
|
102
|
-
const available = input.availableRefs
|
|
103
|
-
? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
|
|
104
|
-
: undefined;
|
|
105
|
-
const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
|
|
106
|
-
const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
|
|
107
|
-
const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
|
|
108
|
-
const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
|
|
109
|
-
const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
|
|
110
|
-
return {
|
|
111
|
-
primaryRef,
|
|
112
|
-
...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
|
|
113
|
-
candidateRefs,
|
|
114
|
-
...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** The exact levels Pi exposes for this model, including `off` when supported. */
|
|
119
|
-
export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
|
|
120
|
-
return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Clamp an agent preference to the effective model's actual capability map. */
|
|
124
|
-
export function resolveThinkingLevel(
|
|
125
|
-
model: Model<Api> | undefined,
|
|
126
|
-
preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
|
|
127
|
-
): ThinkingLevel {
|
|
128
|
-
return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function modelCapabilities(model: ModelListEntry): string {
|
|
132
|
-
const input = model.input.includes("image") ? "vision" : "text-only";
|
|
133
|
-
const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
|
|
134
|
-
return `${input} · thinking: ${thinking}`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Build one searchable list for agent model selection. Only models Pi
|
|
138
|
-
* currently reports as available are supplied by setup. */
|
|
139
|
-
export function buildModelPickerItems(options: {
|
|
140
|
-
models: readonly ModelListEntry[];
|
|
141
|
-
configuredRef?: string;
|
|
142
|
-
mainRef?: string;
|
|
143
|
-
}): ModelPickerItem[] {
|
|
144
|
-
const configuredRef = cleanModelRef(options.configuredRef);
|
|
145
|
-
const mainRef = cleanModelRef(options.mainRef);
|
|
146
|
-
const byRef = new Map<string, ModelListEntry>();
|
|
147
|
-
for (const model of options.models) {
|
|
148
|
-
const ref = modelRef(model);
|
|
149
|
-
if (!byRef.has(ref)) byRef.set(ref, model);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const refs = [...byRef.keys()]
|
|
153
|
-
.sort((left, right) => {
|
|
154
|
-
const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
|
|
155
|
-
const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
|
|
156
|
-
return leftRank - rightRank || left.localeCompare(right);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const dynamic: ModelPickerItem = {
|
|
160
|
-
value: CURRENT_MAIN_MODEL,
|
|
161
|
-
label: "Current main model (dynamic)",
|
|
162
|
-
description: "Clear agent override; use the current main model dynamically",
|
|
163
|
-
};
|
|
164
|
-
const items: ModelPickerItem[] = [dynamic];
|
|
165
|
-
for (const ref of refs) {
|
|
166
|
-
const model = byRef.get(ref)!;
|
|
167
|
-
const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
|
|
168
|
-
.filter(Boolean);
|
|
169
|
-
const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
|
|
170
|
-
items.push({
|
|
171
|
-
value: ref,
|
|
172
|
-
label: ref,
|
|
173
|
-
description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
return items;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** The dynamic choice removes the persisted per-agent override. */
|
|
180
|
-
export function applyAgentModelChoice(
|
|
181
|
-
current: Record<string, string>,
|
|
182
|
-
agentName: string,
|
|
183
|
-
choice: string,
|
|
184
|
-
): Record<string, string> {
|
|
185
|
-
const next = { ...current };
|
|
186
|
-
if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
|
|
187
|
-
else next[agentName] = choice.trim();
|
|
188
|
-
return next;
|
|
189
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* Model routing, capability-aware thinking, and setup-picker helpers.
|
|
3
|
+
*
|
|
4
|
+
* Runtime has one explicit fallback only: a configured agent model hands
|
|
5
|
+
* off directly to the current main-window model. Setup lists only currently
|
|
6
|
+
* available models and derives thinking choices from Pi's model metadata.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
clampThinkingLevel,
|
|
11
|
+
getSupportedThinkingLevels,
|
|
12
|
+
type Api,
|
|
13
|
+
type Model,
|
|
14
|
+
} from "@earendil-works/pi-ai";
|
|
15
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
|
|
19
|
+
Partial<Pick<ExtensionContext, "scopedModels">>;
|
|
20
|
+
|
|
21
|
+
export const CURRENT_MAIN_MODEL = "__current_main_model__";
|
|
22
|
+
|
|
23
|
+
export interface ModelPickerItem {
|
|
24
|
+
value: string;
|
|
25
|
+
label: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ModelListEntry = Pick<
|
|
30
|
+
Model<Api>,
|
|
31
|
+
"provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
export interface ResolvedAgentModelRoute {
|
|
35
|
+
/** Effective first candidate. Undefined means let Pi use its normal default. */
|
|
36
|
+
primaryRef?: string;
|
|
37
|
+
/** Current main-window model when it differs from the selection. */
|
|
38
|
+
mainFallbackRef?: string;
|
|
39
|
+
/** Runtime order, useful for status/tests. */
|
|
40
|
+
candidateRefs: string[];
|
|
41
|
+
/** Configured ref skipped because Pi does not currently report it available. */
|
|
42
|
+
unavailableSelectedRef?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentModelRouteInput {
|
|
46
|
+
selectedRef?: string;
|
|
47
|
+
mainRef?: string;
|
|
48
|
+
declaredDefaultRef?: string;
|
|
49
|
+
/** When supplied, a configured selection outside this live set is skipped. */
|
|
50
|
+
availableRefs?: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cleanModelRef(ref: string | undefined): string | undefined {
|
|
54
|
+
const trimmed = ref?.trim();
|
|
55
|
+
return trimmed || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function modelRef(model: { provider: string; id: string }): string {
|
|
59
|
+
return `${model.provider}/${model.id}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
|
|
63
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Current authenticated registry models narrowed by the session scope. Scope
|
|
68
|
+
* entries are a session snapshot, so they act only as a whitelist; the live
|
|
69
|
+
* registry remains the source of truth for availability and model metadata.
|
|
70
|
+
*/
|
|
71
|
+
export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
|
|
72
|
+
const models = ctx.modelRegistry.getAvailable();
|
|
73
|
+
// scopedModels was added after the original Pi minimum. Treat a missing field
|
|
74
|
+
// exactly like an empty scope and use the full live registry.
|
|
75
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
76
|
+
if (scopedModels.length === 0) return models;
|
|
77
|
+
const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
|
|
78
|
+
return models.filter((model) => scopedRefs.has(modelRef(model)));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function findModelByRef(
|
|
82
|
+
models: readonly Model<Api>[],
|
|
83
|
+
ref: string | undefined,
|
|
84
|
+
): Model<Api> | undefined {
|
|
85
|
+
const normalized = cleanModelRef(ref);
|
|
86
|
+
return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve one agent's runtime route:
|
|
91
|
+
*
|
|
92
|
+
* configured selection -> current main-window model
|
|
93
|
+
*
|
|
94
|
+
* Without an override, current main is primary; the agent-declared default is
|
|
95
|
+
* used only when no main model exists. A configured selection that Pi no longer
|
|
96
|
+
* reports as available is skipped immediately instead of spawning a doomed child.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
|
|
99
|
+
const selectedRef = cleanModelRef(input.selectedRef);
|
|
100
|
+
const mainRef = cleanModelRef(input.mainRef);
|
|
101
|
+
const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
|
|
102
|
+
const available = input.availableRefs
|
|
103
|
+
? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
|
|
104
|
+
: undefined;
|
|
105
|
+
const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
|
|
106
|
+
const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
|
|
107
|
+
const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
|
|
108
|
+
const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
|
|
109
|
+
const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
|
|
110
|
+
return {
|
|
111
|
+
primaryRef,
|
|
112
|
+
...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
|
|
113
|
+
candidateRefs,
|
|
114
|
+
...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The exact levels Pi exposes for this model, including `off` when supported. */
|
|
119
|
+
export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
|
|
120
|
+
return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Clamp an agent preference to the effective model's actual capability map. */
|
|
124
|
+
export function resolveThinkingLevel(
|
|
125
|
+
model: Model<Api> | undefined,
|
|
126
|
+
preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
|
|
127
|
+
): ThinkingLevel {
|
|
128
|
+
return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function modelCapabilities(model: ModelListEntry): string {
|
|
132
|
+
const input = model.input.includes("image") ? "vision" : "text-only";
|
|
133
|
+
const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
|
|
134
|
+
return `${input} · thinking: ${thinking}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Build one searchable list for agent model selection. Only models Pi
|
|
138
|
+
* currently reports as available are supplied by setup. */
|
|
139
|
+
export function buildModelPickerItems(options: {
|
|
140
|
+
models: readonly ModelListEntry[];
|
|
141
|
+
configuredRef?: string;
|
|
142
|
+
mainRef?: string;
|
|
143
|
+
}): ModelPickerItem[] {
|
|
144
|
+
const configuredRef = cleanModelRef(options.configuredRef);
|
|
145
|
+
const mainRef = cleanModelRef(options.mainRef);
|
|
146
|
+
const byRef = new Map<string, ModelListEntry>();
|
|
147
|
+
for (const model of options.models) {
|
|
148
|
+
const ref = modelRef(model);
|
|
149
|
+
if (!byRef.has(ref)) byRef.set(ref, model);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const refs = [...byRef.keys()]
|
|
153
|
+
.sort((left, right) => {
|
|
154
|
+
const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
|
|
155
|
+
const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
|
|
156
|
+
return leftRank - rightRank || left.localeCompare(right);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const dynamic: ModelPickerItem = {
|
|
160
|
+
value: CURRENT_MAIN_MODEL,
|
|
161
|
+
label: "Current main model (dynamic)",
|
|
162
|
+
description: "Clear agent override; use the current main model dynamically",
|
|
163
|
+
};
|
|
164
|
+
const items: ModelPickerItem[] = [dynamic];
|
|
165
|
+
for (const ref of refs) {
|
|
166
|
+
const model = byRef.get(ref)!;
|
|
167
|
+
const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
|
|
170
|
+
items.push({
|
|
171
|
+
value: ref,
|
|
172
|
+
label: ref,
|
|
173
|
+
description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return items;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The dynamic choice removes the persisted per-agent override. */
|
|
180
|
+
export function applyAgentModelChoice(
|
|
181
|
+
current: Record<string, string>,
|
|
182
|
+
agentName: string,
|
|
183
|
+
choice: string,
|
|
184
|
+
): Record<string, string> {
|
|
185
|
+
const next = { ...current };
|
|
186
|
+
if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
|
|
187
|
+
else next[agentName] = choice.trim();
|
|
188
|
+
return next;
|
|
189
|
+
}
|
package/src/monitor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
|
|
22
|
+
export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
|
|
22
23
|
|
|
23
24
|
export function isRunActiveStatus(status: RunStatus): boolean {
|
|
24
25
|
return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
|
|
@@ -45,25 +46,35 @@ export interface RunView {
|
|
|
45
46
|
usage: UsageStats;
|
|
46
47
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
47
48
|
activity?: string;
|
|
48
|
-
/** Epoch ms when
|
|
49
|
+
/** Epoch ms when this logical run first started executing. */
|
|
49
50
|
startedAt?: number;
|
|
50
|
-
/** Epoch ms when the
|
|
51
|
+
/** Epoch ms when the current active segment started. */
|
|
52
|
+
activeSince?: number;
|
|
53
|
+
/** Cumulative active execution time from closed segments; parked time is excluded. */
|
|
54
|
+
elapsedMs: number;
|
|
55
|
+
/** Epoch ms when the latest active segment stopped. */
|
|
51
56
|
endedAt?: number;
|
|
52
|
-
/**
|
|
57
|
+
/** Why this generation reused retained context, shown in the widget/status. */
|
|
58
|
+
continuationKind?: ContinuationKind;
|
|
59
|
+
/** When set, this is an internal managed-workflow step. */
|
|
53
60
|
groupId?: string;
|
|
54
61
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
55
62
|
relationLabel?: string;
|
|
56
|
-
/**
|
|
63
|
+
/** Stable owning run whose row represents the whole managed workflow. */
|
|
57
64
|
parentRunId?: number;
|
|
65
|
+
/** This stable top-level row currently owns a multi-stage managed workflow.
|
|
66
|
+
* Its elapsed time is workflow-wide; active child rows own stage telemetry. */
|
|
67
|
+
managedWorkflow?: boolean;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
|
-
/** Optional
|
|
70
|
+
/** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
|
|
61
71
|
export interface RunChainMeta {
|
|
62
72
|
groupId?: string;
|
|
63
73
|
relationLabel?: string;
|
|
64
74
|
parentRunId?: number;
|
|
65
75
|
isolation?: IsolationMode;
|
|
66
76
|
forkedFromRunId?: number;
|
|
77
|
+
continuationKind?: ContinuationKind;
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
// ---------------------------------------------------------------------------
|
|
@@ -276,10 +287,32 @@ export function formatDuration(ms: number): string {
|
|
|
276
287
|
return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
277
288
|
}
|
|
278
289
|
|
|
279
|
-
/**
|
|
290
|
+
/** Cumulative active time across generations; parked gaps never count. */
|
|
291
|
+
export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
|
|
292
|
+
let elapsed = run.elapsedMs ?? 0;
|
|
293
|
+
if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
|
|
294
|
+
// Keep formatting tolerant of older/synthetic RunView values that predate
|
|
295
|
+
// segmented timing and carry only startedAt/endedAt.
|
|
296
|
+
if (elapsed === 0 && run.startedAt !== undefined && run.activeSince === undefined) {
|
|
297
|
+
elapsed = Math.max(0, (run.endedAt ?? now) - run.startedAt);
|
|
298
|
+
}
|
|
299
|
+
return elapsed;
|
|
300
|
+
}
|
|
301
|
+
|
|
280
302
|
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
281
|
-
if (run.startedAt === undefined) return "";
|
|
282
|
-
return formatDuration((run
|
|
303
|
+
if (run.startedAt === undefined && run.elapsedMs <= 0) return "";
|
|
304
|
+
return formatDuration(elapsedMilliseconds(run, now));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
|
|
308
|
+
switch (kind) {
|
|
309
|
+
case "resume-retained": return "resume: current objective";
|
|
310
|
+
case "resume-appended": return "resume: appended objective";
|
|
311
|
+
case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
|
|
312
|
+
case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
|
|
313
|
+
case "retarget": return "retarget: replacement objective";
|
|
314
|
+
default: return undefined;
|
|
315
|
+
}
|
|
283
316
|
}
|
|
284
317
|
|
|
285
318
|
/** Max length of the argument target inside a formatted activity line. */
|
|
@@ -409,11 +442,13 @@ export class MonitorStore {
|
|
|
409
442
|
thinking,
|
|
410
443
|
status: "queued",
|
|
411
444
|
usage: emptyUsage(),
|
|
445
|
+
elapsedMs: 0,
|
|
412
446
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
413
447
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
414
448
|
...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
|
|
415
449
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
416
450
|
...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
|
|
451
|
+
...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
|
|
417
452
|
});
|
|
418
453
|
this.notify();
|
|
419
454
|
return id;
|
|
@@ -422,17 +457,33 @@ export class MonitorStore {
|
|
|
422
457
|
setStatus(id: number, status: RunStatus): void {
|
|
423
458
|
const run = this.find(id);
|
|
424
459
|
if (!run) return;
|
|
460
|
+
const previousStatus = run.status;
|
|
461
|
+
const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
|
|
462
|
+
const isExecuting = status === "running" || status === "steering" || status === "interrupting";
|
|
463
|
+
const now = Date.now();
|
|
425
464
|
run.status = status;
|
|
426
|
-
if (
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
run.
|
|
465
|
+
if (isExecuting && !wasExecuting) {
|
|
466
|
+
run.startedAt ??= now;
|
|
467
|
+
run.activeSince = now;
|
|
468
|
+
run.endedAt = undefined;
|
|
469
|
+
} else if (!isExecuting && wasExecuting && run.activeSince !== undefined) {
|
|
470
|
+
run.elapsedMs += Math.max(0, now - run.activeSince);
|
|
471
|
+
run.activeSince = undefined;
|
|
472
|
+
}
|
|
473
|
+
if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
474
|
+
run.endedAt = now;
|
|
433
475
|
}
|
|
434
476
|
this.notify();
|
|
435
477
|
}
|
|
478
|
+
/** Switch a stable top-level row from one model run to workflow ownership.
|
|
479
|
+
* The original role remains for identity; child rows show stage telemetry. */
|
|
480
|
+
setManagedWorkflow(id: number, active: boolean): void {
|
|
481
|
+
const run = this.find(id);
|
|
482
|
+
if (!run) return;
|
|
483
|
+
run.managedWorkflow = active || undefined;
|
|
484
|
+
this.notify();
|
|
485
|
+
}
|
|
486
|
+
|
|
436
487
|
setUsage(id: number, usage: UsageStats, model?: string): void {
|
|
437
488
|
const run = this.find(id);
|
|
438
489
|
if (!run) return;
|
|
@@ -512,8 +563,29 @@ export class MonitorStore {
|
|
|
512
563
|
this.notify();
|
|
513
564
|
}
|
|
514
565
|
|
|
515
|
-
|
|
516
|
-
|
|
566
|
+
setContinuationKind(id: number, kind: ContinuationKind): void {
|
|
567
|
+
const run = this.find(id);
|
|
568
|
+
if (!run) return;
|
|
569
|
+
run.continuationKind = kind;
|
|
570
|
+
this.notify();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
getElapsedMs(id: number, now: number = Date.now()): number | undefined {
|
|
574
|
+
const run = this.find(id);
|
|
575
|
+
return run ? elapsedMilliseconds(run, now) : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Reuse a stable logical run id for a resumed generation without discarding
|
|
579
|
+
* active time accumulated by earlier generations. */
|
|
580
|
+
restartRun(
|
|
581
|
+
id: number,
|
|
582
|
+
agent: string,
|
|
583
|
+
task: string,
|
|
584
|
+
model?: string,
|
|
585
|
+
thinking?: string,
|
|
586
|
+
isolation?: IsolationMode,
|
|
587
|
+
meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
|
|
588
|
+
): void {
|
|
517
589
|
const run = this.find(id);
|
|
518
590
|
if (!run) {
|
|
519
591
|
this.runs.push({
|
|
@@ -526,6 +598,8 @@ export class MonitorStore {
|
|
|
526
598
|
...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
|
|
527
599
|
status: "queued",
|
|
528
600
|
usage: emptyUsage(),
|
|
601
|
+
elapsedMs: meta?.elapsedMs ?? 0,
|
|
602
|
+
continuationKind: meta?.continuationKind,
|
|
529
603
|
});
|
|
530
604
|
this.notify();
|
|
531
605
|
return;
|
|
@@ -540,8 +614,11 @@ export class MonitorStore {
|
|
|
540
614
|
run.status = "queued";
|
|
541
615
|
run.usage = emptyUsage();
|
|
542
616
|
run.activity = undefined;
|
|
543
|
-
run.
|
|
617
|
+
run.managedWorkflow = undefined;
|
|
618
|
+
run.activeSince = undefined;
|
|
544
619
|
run.endedAt = undefined;
|
|
620
|
+
run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
|
|
621
|
+
run.continuationKind = meta?.continuationKind;
|
|
545
622
|
this.notify();
|
|
546
623
|
}
|
|
547
624
|
|
|
@@ -580,12 +657,14 @@ export class MonitorStore {
|
|
|
580
657
|
|
|
581
658
|
summarize(run: RunView): string {
|
|
582
659
|
const usage = formatUsageCompact(run.usage);
|
|
583
|
-
const parts = [run.agent];
|
|
660
|
+
const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
|
|
661
|
+
const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
|
|
662
|
+
if (continuation) parts.push(continuation);
|
|
584
663
|
if (run.relationLabel) parts.push(run.relationLabel);
|
|
585
|
-
if (run.model) parts.push(run.model);
|
|
586
|
-
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
664
|
+
if (!run.managedWorkflow && run.model) parts.push(run.model);
|
|
665
|
+
if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
587
666
|
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
588
|
-
if (usage) parts.push(usage);
|
|
667
|
+
if (!run.managedWorkflow && usage) parts.push(usage);
|
|
589
668
|
const elapsed = formatElapsed(run);
|
|
590
669
|
if (elapsed) parts.push(elapsed);
|
|
591
670
|
return parts.join(" · ");
|