pi-crew 0.1.10 → 0.1.11
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 +17 -9
- package/docs/architecture.md +2 -2
- package/docs/usage.md +19 -9
- package/package.json +1 -1
- package/schema.json +10 -0
- package/src/config/config.ts +25 -0
- package/src/extension/help.ts +1 -1
- package/src/extension/register.ts +16 -5
- package/src/extension/team-tool.ts +8 -2
- package/src/runtime/runtime-resolver.ts +7 -8
- package/src/ui/crew-widget.ts +97 -33
- package/src/ui/powerbar-publisher.ts +55 -0
- package/src/ui/run-dashboard.ts +26 -9
- package/src/ui/transcript-viewer.ts +219 -204
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Current highlights:
|
|
|
32
32
|
- detached async/background runner
|
|
33
33
|
- stale async PID detection
|
|
34
34
|
- active run summary and async completion notifications in Pi sessions
|
|
35
|
-
-
|
|
35
|
+
- real child Pi worker execution by default, with explicit scaffold/dry-run opt-out
|
|
36
36
|
- child Pi JSON output parsing for final text, usage, and event counts
|
|
37
37
|
- retryable model fallback attempts per task
|
|
38
38
|
- aggregate usage totals in status/summary
|
|
@@ -89,19 +89,21 @@ npm run ci
|
|
|
89
89
|
|
|
90
90
|
## Runtime safety model
|
|
91
91
|
|
|
92
|
-
By default, `run`
|
|
92
|
+
By default, `run` launches each crew task as a separate child Pi process. This matches the subagent model from `pi-subagents`: the parent session orchestrates while worker sessions execute independently and stream durable output back to run state.
|
|
93
93
|
|
|
94
|
-
|
|
94
|
+
Use scaffold/dry-run mode only when you explicitly want prompts/artifacts without launching workers:
|
|
95
95
|
|
|
96
|
-
```
|
|
97
|
-
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"runtime": { "mode": "scaffold" }
|
|
99
|
+
}
|
|
98
100
|
```
|
|
99
101
|
|
|
100
|
-
or
|
|
102
|
+
or disable workers globally:
|
|
101
103
|
|
|
102
104
|
```json
|
|
103
105
|
{
|
|
104
|
-
"executeWorkers":
|
|
106
|
+
"executeWorkers": false
|
|
105
107
|
}
|
|
106
108
|
```
|
|
107
109
|
|
|
@@ -143,7 +145,7 @@ Supported config:
|
|
|
143
145
|
```json
|
|
144
146
|
{
|
|
145
147
|
"asyncByDefault": false,
|
|
146
|
-
"executeWorkers":
|
|
148
|
+
"executeWorkers": true,
|
|
147
149
|
"notifierIntervalMs": 5000,
|
|
148
150
|
"requireCleanWorktreeLeader": true,
|
|
149
151
|
"autonomous": {
|
|
@@ -163,6 +165,11 @@ Supported config:
|
|
|
163
165
|
"maxRunMinutes": 60,
|
|
164
166
|
"maxRetriesPerTask": 1,
|
|
165
167
|
"heartbeatStaleMs": 60000
|
|
168
|
+
},
|
|
169
|
+
"ui": {
|
|
170
|
+
"widgetPlacement": "aboveEditor",
|
|
171
|
+
"widgetMaxLines": 8,
|
|
172
|
+
"powerbar": true
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
```
|
|
@@ -619,7 +626,8 @@ Optional child Pi smoke check is explicit only:
|
|
|
619
626
|
## Environment variables
|
|
620
627
|
|
|
621
628
|
```text
|
|
622
|
-
|
|
629
|
+
PI_CREW_EXECUTE_WORKERS=0 disable child workers and use scaffold/dry-run mode
|
|
630
|
+
PI_TEAMS_EXECUTE_WORKERS=0 legacy disable flag
|
|
623
631
|
PI_TEAMS_MOCK_CHILD_PI=success test/mock child worker success
|
|
624
632
|
PI_TEAMS_MOCK_CHILD_PI=json-success
|
|
625
633
|
PI_TEAMS_MOCK_CHILD_PI=retryable-failure
|
package/docs/architecture.md
CHANGED
|
@@ -32,8 +32,8 @@ Implemented now:
|
|
|
32
32
|
- durable run manifests and task files
|
|
33
33
|
- workflow validation
|
|
34
34
|
- foreground workflow scheduler
|
|
35
|
-
-
|
|
36
|
-
-
|
|
35
|
+
- child Pi task execution by default
|
|
36
|
+
- explicit scaffold/dry-run task execution via `runtime.mode=scaffold` or `executeWorkers=false`
|
|
37
37
|
- safety-first create/update/delete for agents, teams, and workflows
|
|
38
38
|
- backups for update/delete mutations
|
|
39
39
|
- dry-run support for management mutations
|
package/docs/usage.md
CHANGED
|
@@ -19,7 +19,7 @@ Supported fields:
|
|
|
19
19
|
```json
|
|
20
20
|
{
|
|
21
21
|
"asyncByDefault": false,
|
|
22
|
-
"executeWorkers":
|
|
22
|
+
"executeWorkers": true,
|
|
23
23
|
"notifierIntervalMs": 5000,
|
|
24
24
|
"requireCleanWorktreeLeader": true,
|
|
25
25
|
"autonomous": {
|
|
@@ -28,6 +28,11 @@ Supported fields:
|
|
|
28
28
|
"injectPolicy": true,
|
|
29
29
|
"preferAsyncForLongTasks": false,
|
|
30
30
|
"allowWorktreeSuggestion": true
|
|
31
|
+
},
|
|
32
|
+
"ui": {
|
|
33
|
+
"widgetPlacement": "aboveEditor",
|
|
34
|
+
"widgetMaxLines": 8,
|
|
35
|
+
"powerbar": true
|
|
31
36
|
}
|
|
32
37
|
}
|
|
33
38
|
```
|
|
@@ -47,9 +52,9 @@ Then open Pi and run:
|
|
|
47
52
|
/team-autonomy status
|
|
48
53
|
```
|
|
49
54
|
|
|
50
|
-
##
|
|
55
|
+
## Default run: real worker execution
|
|
51
56
|
|
|
52
|
-
By default, `pi-crew`
|
|
57
|
+
By default, `pi-crew` launches each task as a separate child Pi worker process. The parent Pi session orchestrates; workers execute independently and stream output to durable run state.
|
|
53
58
|
|
|
54
59
|
```json
|
|
55
60
|
{
|
|
@@ -59,16 +64,21 @@ By default, `pi-crew` does not launch child workers. It creates a durable run, p
|
|
|
59
64
|
}
|
|
60
65
|
```
|
|
61
66
|
|
|
62
|
-
##
|
|
67
|
+
## Scaffold / dry run
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
Use scaffold mode only when you want durable prompts/artifacts without launching child workers.
|
|
65
70
|
|
|
66
|
-
```
|
|
67
|
-
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"action": "run",
|
|
74
|
+
"team": "default",
|
|
75
|
+
"goal": "Plan only",
|
|
76
|
+
"config": {
|
|
77
|
+
"runtime": { "mode": "scaffold" }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
68
80
|
```
|
|
69
81
|
|
|
70
|
-
Then run normally. Each task can spawn a child Pi worker.
|
|
71
|
-
|
|
72
82
|
## Async run
|
|
73
83
|
|
|
74
84
|
```json
|
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -78,6 +78,16 @@
|
|
|
78
78
|
"enabled": { "type": "boolean" },
|
|
79
79
|
"needsAttentionAfterMs": { "type": "integer", "minimum": 1 }
|
|
80
80
|
}
|
|
81
|
+
},
|
|
82
|
+
"ui": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"additionalProperties": false,
|
|
85
|
+
"description": "Pi UI settings for the crew widget, dashboard, and optional powerbar segments.",
|
|
86
|
+
"properties": {
|
|
87
|
+
"widgetPlacement": { "type": "string", "enum": ["aboveEditor", "belowEditor"] },
|
|
88
|
+
"widgetMaxLines": { "type": "integer", "minimum": 1, "maximum": 50 },
|
|
89
|
+
"powerbar": { "type": "boolean" }
|
|
90
|
+
}
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -47,6 +47,12 @@ export interface CrewWorktreeConfig {
|
|
|
47
47
|
linkNodeModules?: boolean;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export interface CrewUiConfig {
|
|
51
|
+
widgetPlacement?: "aboveEditor" | "belowEditor";
|
|
52
|
+
widgetMaxLines?: number;
|
|
53
|
+
powerbar?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
export interface AgentOverrideConfig {
|
|
51
57
|
disabled?: boolean;
|
|
52
58
|
model?: string | false;
|
|
@@ -71,6 +77,7 @@ export interface PiTeamsConfig {
|
|
|
71
77
|
control?: CrewControlConfig;
|
|
72
78
|
worktree?: CrewWorktreeConfig;
|
|
73
79
|
agents?: CrewAgentsConfig;
|
|
80
|
+
ui?: CrewUiConfig;
|
|
74
81
|
}
|
|
75
82
|
|
|
76
83
|
export interface LoadedPiTeamsConfig {
|
|
@@ -136,6 +143,12 @@ function mergeConfig(base: PiTeamsConfig, override: PiTeamsConfig): PiTeamsConfi
|
|
|
136
143
|
...withoutUndefined((override.worktree ?? {}) as Record<string, unknown>),
|
|
137
144
|
};
|
|
138
145
|
}
|
|
146
|
+
if (base.ui || override.ui) {
|
|
147
|
+
merged.ui = {
|
|
148
|
+
...(base.ui ?? {}),
|
|
149
|
+
...withoutUndefined((override.ui ?? {}) as Record<string, unknown>),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
139
152
|
if (base.agents || override.agents) {
|
|
140
153
|
merged.agents = {
|
|
141
154
|
...(base.agents ?? {}),
|
|
@@ -289,6 +302,17 @@ function parseAgentOverride(value: unknown): AgentOverrideConfig | undefined {
|
|
|
289
302
|
return Object.values(override).some((entry) => entry !== undefined) ? override : undefined;
|
|
290
303
|
}
|
|
291
304
|
|
|
305
|
+
function parseUiConfig(value: unknown): CrewUiConfig | undefined {
|
|
306
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
307
|
+
const obj = value as Record<string, unknown>;
|
|
308
|
+
const ui: CrewUiConfig = {
|
|
309
|
+
widgetPlacement: obj.widgetPlacement === "aboveEditor" || obj.widgetPlacement === "belowEditor" ? obj.widgetPlacement : undefined,
|
|
310
|
+
widgetMaxLines: parsePositiveInteger(obj.widgetMaxLines, 50),
|
|
311
|
+
powerbar: typeof obj.powerbar === "boolean" ? obj.powerbar : undefined,
|
|
312
|
+
};
|
|
313
|
+
return Object.values(ui).some((entry) => entry !== undefined) ? ui : undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
292
316
|
function parseAgentsConfig(value: unknown): CrewAgentsConfig | undefined {
|
|
293
317
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
294
318
|
const obj = value as Record<string, unknown>;
|
|
@@ -320,6 +344,7 @@ function parseConfig(raw: unknown): PiTeamsConfig {
|
|
|
320
344
|
control: parseControlConfig(obj.control),
|
|
321
345
|
worktree: parseWorktreeConfig(obj.worktree),
|
|
322
346
|
agents: parseAgentsConfig(obj.agents),
|
|
347
|
+
ui: parseUiConfig(obj.ui),
|
|
323
348
|
};
|
|
324
349
|
}
|
|
325
350
|
|
package/src/extension/help.ts
CHANGED
|
@@ -40,6 +40,6 @@ export function piTeamsHelp(): string {
|
|
|
40
40
|
"- /team-validate",
|
|
41
41
|
"- /team-help",
|
|
42
42
|
"",
|
|
43
|
-
"Real child workers are
|
|
43
|
+
"Real child workers are enabled by default. Use runtime.mode=scaffold or executeWorkers=false only for dry runs.",
|
|
44
44
|
].join("\n");
|
|
45
45
|
}
|
|
@@ -11,6 +11,7 @@ import { listRuns } from "./run-index.ts";
|
|
|
11
11
|
import { RunDashboard, type RunDashboardSelection } from "../ui/run-dashboard.ts";
|
|
12
12
|
import { registerPiCrewRpc, type PiCrewRpcHandle } from "./cross-extension-rpc.ts";
|
|
13
13
|
import { stopCrewWidget, updateCrewWidget, type CrewWidgetState } from "../ui/crew-widget.ts";
|
|
14
|
+
import { clearPiCrewPowerbar, registerPiCrewPowerbarSegments, updatePiCrewPowerbar } from "../ui/powerbar-publisher.ts";
|
|
14
15
|
import { DurableTextViewer, DurableTranscriptViewer } from "../ui/transcript-viewer.ts";
|
|
15
16
|
import { loadRunManifestById } from "../state/state-store.ts";
|
|
16
17
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
@@ -109,14 +110,22 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
109
110
|
currentCtx = ctx;
|
|
110
111
|
notifyActiveRuns(ctx);
|
|
111
112
|
const loadedConfig = loadConfig(ctx.cwd);
|
|
113
|
+
registerPiCrewPowerbarSegments(pi.events, loadedConfig.config.ui);
|
|
112
114
|
startAsyncRunNotifier(ctx, notifierState, loadedConfig.config.notifierIntervalMs ?? 5000);
|
|
113
|
-
updateCrewWidget(ctx, widgetState);
|
|
114
|
-
|
|
115
|
+
updateCrewWidget(ctx, widgetState, loadedConfig.config.ui);
|
|
116
|
+
updatePiCrewPowerbar(pi.events, ctx.cwd, loadedConfig.config.ui);
|
|
117
|
+
widgetState.interval = setInterval(() => {
|
|
118
|
+
if (!currentCtx) return;
|
|
119
|
+
const config = loadConfig(currentCtx.cwd).config.ui;
|
|
120
|
+
updateCrewWidget(currentCtx, widgetState, config);
|
|
121
|
+
updatePiCrewPowerbar(pi.events, currentCtx.cwd, config);
|
|
122
|
+
}, 1000);
|
|
115
123
|
widgetState.interval.unref?.();
|
|
116
124
|
});
|
|
117
125
|
pi.on("session_shutdown", () => {
|
|
118
126
|
stopAsyncRunNotifier(notifierState);
|
|
119
|
-
stopCrewWidget(currentCtx, widgetState);
|
|
127
|
+
stopCrewWidget(currentCtx, widgetState, currentCtx ? loadConfig(currentCtx.cwd).config.ui : undefined);
|
|
128
|
+
clearPiCrewPowerbar(pi.events);
|
|
120
129
|
currentCtx = undefined;
|
|
121
130
|
rpcHandle?.unsubscribe();
|
|
122
131
|
rpcHandle = undefined;
|
|
@@ -130,7 +139,9 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
130
139
|
parameters: TeamToolParams as never,
|
|
131
140
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
132
141
|
const output = await handleTeamTool(params as TeamToolParamsValue, ctx);
|
|
133
|
-
|
|
142
|
+
const config = loadConfig(ctx.cwd).config.ui;
|
|
143
|
+
updateCrewWidget(ctx, widgetState, config);
|
|
144
|
+
updatePiCrewPowerbar(pi.events, ctx.cwd, config);
|
|
134
145
|
return output;
|
|
135
146
|
},
|
|
136
147
|
};
|
|
@@ -323,7 +334,7 @@ export function registerPiTeams(pi: ExtensionAPI): void {
|
|
|
323
334
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
324
335
|
for (;;) {
|
|
325
336
|
const runs = listRuns(ctx.cwd).slice(0, 50);
|
|
326
|
-
const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui,
|
|
337
|
+
const selection = await ctx.ui.custom<RunDashboardSelection | undefined>((_tui, theme, _keybindings, done) => new RunDashboard(runs, done, theme), {
|
|
327
338
|
overlay: true,
|
|
328
339
|
overlayOptions: { width: "90%", maxHeight: "80%", anchor: "center" },
|
|
329
340
|
});
|
|
@@ -318,10 +318,10 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
318
318
|
"",
|
|
319
319
|
`Runtime: ${runtime.kind}${runtime.fallback ? ` (fallback from ${runtime.requestedMode})` : ""}${runtime.reason ? ` - ${runtime.reason}` : ""}`,
|
|
320
320
|
runtime.kind === "child-process"
|
|
321
|
-
? "Child Pi worker execution
|
|
321
|
+
? "Child Pi worker execution is enabled by default; each task is launched as a separate Pi process. Set runtime.mode=scaffold or executeWorkers=false only for dry runs."
|
|
322
322
|
: runtime.kind === "live-session"
|
|
323
323
|
? "Experimental live-session worker execution was enabled."
|
|
324
|
-
: "Safe scaffold mode: child Pi workers were not launched
|
|
324
|
+
: "Safe scaffold mode: child Pi workers were not launched because runtime.mode=scaffold or executeWorkers=false was configured.",
|
|
325
325
|
].join("\n");
|
|
326
326
|
return result(text, { action: "run", status: executed.manifest.status === "failed" ? "error" : "ok", runId: executed.manifest.runId, artifactsRoot: executed.manifest.artifactsRoot }, executed.manifest.status === "failed");
|
|
327
327
|
}
|
|
@@ -508,6 +508,7 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
508
508
|
const runtime = configRecord(cfg.runtime);
|
|
509
509
|
const limits = configRecord(cfg.limits);
|
|
510
510
|
const worktree = configRecord(cfg.worktree);
|
|
511
|
+
const ui = configRecord(cfg.ui);
|
|
511
512
|
return {
|
|
512
513
|
asyncByDefault: typeof cfg.asyncByDefault === "boolean" ? cfg.asyncByDefault : undefined,
|
|
513
514
|
executeWorkers: typeof cfg.executeWorkers === "boolean" ? cfg.executeWorkers : undefined,
|
|
@@ -542,6 +543,11 @@ function configPatchFromConfig(config: unknown): PiTeamsConfig {
|
|
|
542
543
|
enabled: typeof control.enabled === "boolean" ? control.enabled : undefined,
|
|
543
544
|
needsAttentionAfterMs: typeof control.needsAttentionAfterMs === "number" && Number.isInteger(control.needsAttentionAfterMs) && control.needsAttentionAfterMs > 0 ? control.needsAttentionAfterMs : undefined,
|
|
544
545
|
} : undefined,
|
|
546
|
+
ui: Object.keys(ui).length > 0 ? {
|
|
547
|
+
widgetPlacement: ui.widgetPlacement === "aboveEditor" || ui.widgetPlacement === "belowEditor" ? ui.widgetPlacement : undefined,
|
|
548
|
+
widgetMaxLines: typeof ui.widgetMaxLines === "number" && Number.isInteger(ui.widgetMaxLines) && ui.widgetMaxLines > 0 ? ui.widgetMaxLines : undefined,
|
|
549
|
+
powerbar: typeof ui.powerbar === "boolean" ? ui.powerbar : undefined,
|
|
550
|
+
} : undefined,
|
|
545
551
|
};
|
|
546
552
|
}
|
|
547
553
|
|
|
@@ -48,22 +48,21 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
|
|
|
48
48
|
|
|
49
49
|
export async function resolveCrewRuntime(config: PiTeamsConfig, env: NodeJS.ProcessEnv = process.env): Promise<CrewRuntimeCapabilities> {
|
|
50
50
|
const requestedMode = config.runtime?.mode ?? "auto";
|
|
51
|
-
const
|
|
51
|
+
const workersDisabled = config.executeWorkers === false || env.PI_CREW_EXECUTE_WORKERS === "0" || env.PI_TEAMS_EXECUTE_WORKERS === "0";
|
|
52
52
|
if (requestedMode === "scaffold") return scaffoldCaps(requestedMode);
|
|
53
|
-
if (
|
|
53
|
+
if (workersDisabled) return scaffoldCaps(requestedMode, "Child worker execution disabled by config/env. Set runtime.mode=scaffold or executeWorkers=false only for dry runs.");
|
|
54
|
+
if (requestedMode === "child-process") return childCaps(requestedMode);
|
|
54
55
|
if (requestedMode === "live-session" || (requestedMode === "auto" && config.runtime?.preferLiveSession === true)) {
|
|
55
56
|
const live = await isLiveSessionRuntimeAvailable(1500, env);
|
|
56
57
|
if (live.available) return liveCaps(requestedMode);
|
|
57
58
|
if (requestedMode === "live-session" && config.runtime?.allowChildProcessFallback === false) return { ...scaffoldCaps(requestedMode), available: false, reason: live.reason };
|
|
58
|
-
|
|
59
|
-
return { ...scaffoldCaps(requestedMode), fallback: "scaffold", reason: live.reason };
|
|
59
|
+
return { ...childCaps(requestedMode), fallback: "child-process", reason: live.reason };
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
return scaffoldCaps(requestedMode);
|
|
61
|
+
return childCaps(requestedMode);
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
function scaffoldCaps(requestedMode: CrewRuntimeMode): CrewRuntimeCapabilities {
|
|
66
|
-
return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false };
|
|
64
|
+
function scaffoldCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
|
|
65
|
+
return { kind: "scaffold", requestedMode, available: true, steer: false, resume: false, liveToolActivity: false, transcript: false, ...(reason ? { reason } : {}) };
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
function childCaps(requestedMode: CrewRuntimeMode, reason?: string): CrewRuntimeCapabilities {
|
package/src/ui/crew-widget.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { CrewUiConfig } from "../config/config.ts";
|
|
2
3
|
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
-
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
4
4
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
5
5
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
6
7
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
8
|
|
|
8
9
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -15,12 +16,48 @@ const TOOL_LABELS: Record<string, string> = {
|
|
|
15
16
|
find: "finding files",
|
|
16
17
|
ls: "listing",
|
|
17
18
|
};
|
|
19
|
+
const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
20
|
+
|
|
21
|
+
type ThemeLike = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
22
|
+
type WidgetComponent = { render(width: number): string[]; invalidate(): void };
|
|
18
23
|
|
|
19
24
|
export interface CrewWidgetState {
|
|
20
25
|
frame: number;
|
|
21
26
|
interval?: ReturnType<typeof setInterval>;
|
|
22
27
|
}
|
|
23
28
|
|
|
29
|
+
interface WidgetRun {
|
|
30
|
+
run: TeamRunManifest;
|
|
31
|
+
agents: CrewAgentRecord[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function visibleWidth(value: string): number {
|
|
35
|
+
return value.replace(ANSI_PATTERN, "").length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncate(value: string, width: number): string {
|
|
39
|
+
if (width <= 0) return "";
|
|
40
|
+
if (visibleWidth(value) <= width) return value;
|
|
41
|
+
if (width <= 1) return "…";
|
|
42
|
+
let output = "";
|
|
43
|
+
let visible = 0;
|
|
44
|
+
for (let index = 0; index < value.length;) {
|
|
45
|
+
const slice = value.slice(index);
|
|
46
|
+
const ansi = slice.match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
|
|
47
|
+
if (ansi?.[0]) {
|
|
48
|
+
output += ansi[0];
|
|
49
|
+
index += ansi[0].length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const char = value[index]!;
|
|
53
|
+
if (visible >= width - 1) break;
|
|
54
|
+
output += char;
|
|
55
|
+
visible += 1;
|
|
56
|
+
index += char.length;
|
|
57
|
+
}
|
|
58
|
+
return `${output}\u001b[0m…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
24
61
|
function elapsed(iso: string | undefined, now = Date.now()): string | undefined {
|
|
25
62
|
if (!iso) return undefined;
|
|
26
63
|
const ms = Math.max(0, now - new Date(iso).getTime());
|
|
@@ -45,7 +82,7 @@ function agentActivity(agent: CrewAgentRecord): string {
|
|
|
45
82
|
const recent = agent.progress?.recentOutput?.at(-1);
|
|
46
83
|
if (recent) return recent.replace(/\s+/g, " ").trim();
|
|
47
84
|
if (agent.progress?.activityState === "needs_attention") return "needs attention";
|
|
48
|
-
if (agent.status === "queued") return "queued
|
|
85
|
+
if (agent.status === "queued") return "queued";
|
|
49
86
|
if (agent.status === "running") return "thinking…";
|
|
50
87
|
if (agent.status === "failed") return agent.error ?? "failed";
|
|
51
88
|
return "done";
|
|
@@ -61,59 +98,86 @@ function agentStats(agent: CrewAgentRecord): string {
|
|
|
61
98
|
return parts.join(" · ");
|
|
62
99
|
}
|
|
63
100
|
|
|
64
|
-
function runStep(run: TeamRunManifest, agents: CrewAgentRecord[]): string {
|
|
65
|
-
if (isLikelyOrphanedActiveRun(run, agents)) return "stale";
|
|
66
|
-
const running = agents.find((agent) => agent.status === "running");
|
|
67
|
-
if (running) return running.taskId;
|
|
68
|
-
const queued = agents.find((agent) => agent.status === "queued");
|
|
69
|
-
if (queued) return queued.taskId;
|
|
70
|
-
return run.status;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
101
|
function agentsFor(run: TeamRunManifest): CrewAgentRecord[] {
|
|
74
102
|
try { return readCrewAgents(run); } catch { return []; }
|
|
75
103
|
}
|
|
76
104
|
|
|
77
|
-
|
|
105
|
+
function activeWidgetRuns(cwd: string): WidgetRun[] {
|
|
78
106
|
const runs = listRuns(cwd).slice(0, 20);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
107
|
+
return runs.map((run) => ({ run, agents: agentsFor(run) })).filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function statusSummary(runs: WidgetRun[]): string {
|
|
111
|
+
const runningAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "running").length;
|
|
112
|
+
const queuedAgents = runs.flatMap((item) => item.agents).filter((agent) => agent.status === "queued").length;
|
|
113
|
+
return `● pi-crew · runs=${runs.length} agents=${runningAgents} running${queuedAgents ? ` · ${queuedAgents} queued` : ""} · /team-dashboard`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildCrewWidgetLines(cwd: string, frame = 0, maxLines = 8): string[] {
|
|
117
|
+
const runs = activeWidgetRuns(cwd);
|
|
118
|
+
if (!runs.length) return [];
|
|
84
119
|
const runningGlyph = SPINNER[frame % SPINNER.length] ?? "⠋";
|
|
85
|
-
const lines: string[] = [];
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
lines.push(`${glyph(stale ? "failed" : run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${runStep(run, agents)} · ${countText}`);
|
|
95
|
-
for (const agent of (stale ? [] : agents.filter((item) => item.status === "running" || item.status === "queued").slice(0, 2))) {
|
|
120
|
+
const lines: string[] = [statusSummary(runs)];
|
|
121
|
+
for (const { run, agents } of runs) {
|
|
122
|
+
const running = agents.find((agent) => agent.status === "running");
|
|
123
|
+
const queued = agents.find((agent) => agent.status === "queued");
|
|
124
|
+
const step = running?.taskId ?? queued?.taskId ?? run.status;
|
|
125
|
+
const completed = agents.filter((agent) => agent.status === "completed").length;
|
|
126
|
+
lines.push(`${glyph(run.status, runningGlyph)} ${run.runId.slice(-8)} ${run.team}/${run.workflow ?? "none"} · ${step} · ${completed}/${agents.length} done`);
|
|
127
|
+
const activeAgents = agents.filter((item) => item.status === "running" || item.status === "queued");
|
|
128
|
+
for (const agent of activeAgents.slice(0, 3)) {
|
|
96
129
|
const stats = agentStats(agent);
|
|
97
130
|
lines.push(` ${glyph(agent.status, runningGlyph)} ${agent.taskId} ${agent.role}→${agent.agent} · ${agentActivity(agent)}${stats ? ` · ${stats}` : ""}`);
|
|
98
131
|
}
|
|
132
|
+
if (activeAgents.length > 3) lines.push(` … +${activeAgents.length - 3} more agents`);
|
|
99
133
|
if (lines.length >= maxLines) break;
|
|
100
134
|
}
|
|
101
135
|
return lines.slice(0, maxLines);
|
|
102
136
|
}
|
|
103
137
|
|
|
104
|
-
|
|
138
|
+
class CrewWidgetComponent implements WidgetComponent {
|
|
139
|
+
private cwd: string;
|
|
140
|
+
private frame: number;
|
|
141
|
+
private maxLines: number;
|
|
142
|
+
private theme: ThemeLike;
|
|
143
|
+
|
|
144
|
+
constructor(cwd: string, frame: number, maxLines: number, theme: ThemeLike) {
|
|
145
|
+
this.cwd = cwd;
|
|
146
|
+
this.frame = frame;
|
|
147
|
+
this.maxLines = maxLines;
|
|
148
|
+
this.theme = theme;
|
|
149
|
+
}
|
|
150
|
+
invalidate(): void {}
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
|
|
153
|
+
const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
|
|
154
|
+
return buildCrewWidgetLines(this.cwd, this.frame, this.maxLines).map((line, index) => {
|
|
155
|
+
const colored = index === 0
|
|
156
|
+
? line.replace("● pi-crew", `${fg("accent", "●")} ${bold("pi-crew")}`)
|
|
157
|
+
: line.replace(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏▶◦✓✗■·])/, (match, icon: string) => match.replace(icon, fg(icon === "✓" ? "success" : icon === "✗" ? "error" : icon === "◦" ? "dim" : "accent", icon)));
|
|
158
|
+
return truncate(colored, width);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function updateCrewWidget(ctx: Pick<ExtensionContext, "cwd" | "hasUI" | "ui">, state: CrewWidgetState, config?: CrewUiConfig): void {
|
|
105
164
|
if (!ctx.hasUI) return;
|
|
106
165
|
state.frame += 1;
|
|
107
|
-
const
|
|
166
|
+
const maxLines = config?.widgetMaxLines ?? 8;
|
|
167
|
+
const lines = buildCrewWidgetLines(ctx.cwd, state.frame, maxLines);
|
|
108
168
|
ctx.ui.setStatus("pi-crew", lines.length ? lines[0] : undefined);
|
|
109
|
-
|
|
169
|
+
if (!lines.length) {
|
|
170
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
ctx.ui.setWidget("pi-crew", ((_tui: unknown, theme: unknown) => new CrewWidgetComponent(ctx.cwd, state.frame, maxLines, theme as ThemeLike)) as never, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
110
174
|
}
|
|
111
175
|
|
|
112
|
-
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState): void {
|
|
176
|
+
export function stopCrewWidget(ctx: Pick<ExtensionContext, "hasUI" | "ui"> | undefined, state: CrewWidgetState, config?: CrewUiConfig): void {
|
|
113
177
|
if (state.interval) clearInterval(state.interval);
|
|
114
178
|
state.interval = undefined;
|
|
115
179
|
if (ctx?.hasUI) {
|
|
116
180
|
ctx.ui.setStatus("pi-crew", undefined);
|
|
117
|
-
ctx.ui.setWidget("pi-crew", undefined, { placement: "aboveEditor" });
|
|
181
|
+
ctx.ui.setWidget("pi-crew", undefined, { placement: config?.widgetPlacement ?? "aboveEditor" });
|
|
118
182
|
}
|
|
119
183
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CrewUiConfig } from "../config/config.ts";
|
|
2
|
+
import { listRuns } from "../extension/run-index.ts";
|
|
3
|
+
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
|
+
import { isDisplayActiveRun } from "../runtime/process-status.ts";
|
|
5
|
+
|
|
6
|
+
type EventBus = { emit?: (event: string, data: unknown) => void } | undefined;
|
|
7
|
+
|
|
8
|
+
function safeEmit(events: EventBus, event: string, data: unknown): void {
|
|
9
|
+
try { events?.emit?.(event, data); } catch {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerPiCrewPowerbarSegments(events: EventBus, config?: CrewUiConfig): void {
|
|
13
|
+
if (config?.powerbar === false) return;
|
|
14
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-active", label: "pi-crew active agents" });
|
|
15
|
+
safeEmit(events, "powerbar:register-segment", { id: "pi-crew-progress", label: "pi-crew run progress" });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function updatePiCrewPowerbar(events: EventBus, cwd: string, config?: CrewUiConfig): void {
|
|
19
|
+
if (config?.powerbar === false) return;
|
|
20
|
+
const active = listRuns(cwd).slice(0, 20).map((run) => {
|
|
21
|
+
let agents = [] as ReturnType<typeof readCrewAgents>;
|
|
22
|
+
try { agents = readCrewAgents(run); } catch {}
|
|
23
|
+
return { run, agents };
|
|
24
|
+
}).filter((item) => isDisplayActiveRun(item.run, item.agents));
|
|
25
|
+
if (!active.length) {
|
|
26
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
27
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const agents = active.flatMap((item) => item.agents);
|
|
31
|
+
const running = agents.filter((agent) => agent.status === "running").length;
|
|
32
|
+
const queued = agents.filter((agent) => agent.status === "queued").length;
|
|
33
|
+
const completed = agents.filter((agent) => agent.status === "completed").length;
|
|
34
|
+
const total = Math.max(1, agents.length);
|
|
35
|
+
safeEmit(events, "powerbar:update", {
|
|
36
|
+
id: "pi-crew-active",
|
|
37
|
+
icon: "⚙",
|
|
38
|
+
text: `crew ${running || active.length}`,
|
|
39
|
+
suffix: queued ? `${queued}q` : undefined,
|
|
40
|
+
color: running ? "accent" : "warning",
|
|
41
|
+
});
|
|
42
|
+
safeEmit(events, "powerbar:update", {
|
|
43
|
+
id: "pi-crew-progress",
|
|
44
|
+
text: active[0]?.run.team ?? "crew",
|
|
45
|
+
bar: Math.round((completed / total) * 100),
|
|
46
|
+
suffix: `${completed}/${total}`,
|
|
47
|
+
color: completed === total ? "success" : "accent",
|
|
48
|
+
barSegments: 8,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function clearPiCrewPowerbar(events: EventBus): void {
|
|
53
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-active" });
|
|
54
|
+
safeEmit(events, "powerbar:update", { id: "pi-crew-progress" });
|
|
55
|
+
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -10,6 +10,8 @@ interface DashboardComponent {
|
|
|
10
10
|
handleInput(data: string): void;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
type DashboardTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
14
|
+
|
|
13
15
|
export type RunDashboardAction = "status" | "summary" | "artifacts" | "api" | "events" | "agents" | "agent-events" | "agent-output" | "agent-transcript" | "reload";
|
|
14
16
|
export interface RunDashboardSelection {
|
|
15
17
|
runId: string;
|
|
@@ -49,6 +51,14 @@ function padVisible(value: string, width: number): string {
|
|
|
49
51
|
return `${value}${" ".repeat(Math.max(0, width - visibleLength(value)))}`;
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
function colorForStatus(status: string): string {
|
|
55
|
+
if (status === "completed") return "success";
|
|
56
|
+
if (status === "failed" || status === "stale") return "error";
|
|
57
|
+
if (status === "cancelled" || status === "blocked") return "warning";
|
|
58
|
+
if (status === "running") return "accent";
|
|
59
|
+
return "dim";
|
|
60
|
+
}
|
|
61
|
+
|
|
52
62
|
function statusIcon(status: string): string {
|
|
53
63
|
if (status === "completed") return "✓";
|
|
54
64
|
if (status === "failed" || status === "stale") return "✗";
|
|
@@ -143,23 +153,28 @@ export class RunDashboard implements DashboardComponent {
|
|
|
143
153
|
private showFullProgress = false;
|
|
144
154
|
private readonly runs: TeamRunManifest[];
|
|
145
155
|
private readonly done: (selection: RunDashboardSelection | undefined) => void;
|
|
156
|
+
private readonly theme: DashboardTheme;
|
|
146
157
|
|
|
147
|
-
constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void) {
|
|
158
|
+
constructor(runs: TeamRunManifest[], done: (selection: RunDashboardSelection | undefined) => void, theme: unknown = {}) {
|
|
148
159
|
this.runs = runs;
|
|
149
160
|
this.done = done;
|
|
161
|
+
this.theme = theme as DashboardTheme;
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
invalidate(): void {}
|
|
153
165
|
|
|
154
166
|
render(width: number): string[] {
|
|
167
|
+
const fg = this.theme.fg?.bind(this.theme) ?? ((_color: string, text: string) => text);
|
|
168
|
+
const bold = this.theme.bold?.bind(this.theme) ?? ((text: string) => text);
|
|
155
169
|
const innerWidth = Math.max(20, width - 4);
|
|
156
170
|
const borderWidth = Math.min(innerWidth, Math.max(0, width - 2));
|
|
171
|
+
const border = (text: string) => fg("border", text);
|
|
157
172
|
const lines = [
|
|
158
|
-
`╭${"─".repeat(borderWidth)}
|
|
159
|
-
`│ ${padVisible(truncate("pi-crew dashboard"
|
|
160
|
-
`│ ${padVisible(truncate("↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close", innerWidth - 1), innerWidth - 1)}│`,
|
|
173
|
+
border(`╭${"─".repeat(borderWidth)}╮`),
|
|
174
|
+
`│ ${padVisible(truncate(`${fg("accent", "●")} ${bold("pi-crew dashboard")}`, innerWidth - 1), innerWidth - 1)}│`,
|
|
175
|
+
`│ ${padVisible(truncate(fg("dim", "↑/↓/j/k select • r reload • p progress • s/u/a/i actions • d agents • e/v/o viewers • q close"), innerWidth - 1), innerWidth - 1)}│`,
|
|
161
176
|
`│ ${padVisible(truncate(`Runs: ${this.runs.length} • ${countByStatus(this.runs)}`, innerWidth - 1), innerWidth - 1)}│`,
|
|
162
|
-
`├${"─".repeat(borderWidth)}
|
|
177
|
+
border(`├${"─".repeat(borderWidth)}┤`),
|
|
163
178
|
];
|
|
164
179
|
if (this.runs.length === 0) {
|
|
165
180
|
lines.push(`│ ${padVisible(truncate("No runs found.", innerWidth - 1), innerWidth - 1)}│`);
|
|
@@ -168,15 +183,17 @@ export class RunDashboard implements DashboardComponent {
|
|
|
168
183
|
const runRows = rows.filter((row) => row.run);
|
|
169
184
|
for (const row of rows) {
|
|
170
185
|
if (!row.run) {
|
|
171
|
-
lines.push(`│ ${padVisible(truncate(row.label, innerWidth - 1), innerWidth - 1)}│`);
|
|
186
|
+
lines.push(`│ ${padVisible(truncate(fg("accent", row.label), innerWidth - 1), innerWidth - 1)}│`);
|
|
172
187
|
continue;
|
|
173
188
|
}
|
|
174
189
|
const index = runRows.findIndex((candidate) => candidate.run?.runId === row.run?.runId);
|
|
175
|
-
|
|
190
|
+
const label = runLabel(row.run, index === this.selected);
|
|
191
|
+
const status = isLikelyOrphanedActiveRun(row.run, agentsFor(row.run)) ? "stale" : row.run.status;
|
|
192
|
+
lines.push(`│ ${padVisible(truncate(fg(colorForStatus(status), label), innerWidth - 1), innerWidth - 1)}│`);
|
|
176
193
|
}
|
|
177
194
|
const selectedRun = selectedRunFromGrouped(this.runs, this.selected);
|
|
178
195
|
if (selectedRun) {
|
|
179
|
-
lines.push(`├${"─".repeat(borderWidth)}┤`);
|
|
196
|
+
lines.push(border(`├${"─".repeat(borderWidth)}┤`));
|
|
180
197
|
const details = [
|
|
181
198
|
`Selected: ${selectedRun.runId}`,
|
|
182
199
|
`Status: ${selectedRun.status} | Team: ${selectedRun.team} | Workflow: ${selectedRun.workflow ?? "none"}`,
|
|
@@ -191,7 +208,7 @@ export class RunDashboard implements DashboardComponent {
|
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
}
|
|
194
|
-
lines.push(`╰${"─".repeat(borderWidth)}╯`);
|
|
211
|
+
lines.push(border(`╰${"─".repeat(borderWidth)}╯`));
|
|
195
212
|
return lines.map((line) => truncate(line, width));
|
|
196
213
|
}
|
|
197
214
|
|
|
@@ -1,204 +1,219 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
-
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
-
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
-
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
-
|
|
7
|
-
function visibleWidth(text: string): number {
|
|
8
|
-
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function truncate(text: string, width: number): string {
|
|
12
|
-
if (width <= 0) return "";
|
|
13
|
-
if (visibleWidth(text) <= width) return text;
|
|
14
|
-
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function wrap(text: string, width: number): string[] {
|
|
18
|
-
const source = text.split(/\r?\n/);
|
|
19
|
-
const lines: string[] = [];
|
|
20
|
-
for (const raw of source) {
|
|
21
|
-
const line = raw || " ";
|
|
22
|
-
if (line.length <= width) {
|
|
23
|
-
lines.push(line);
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
-
}
|
|
28
|
-
return lines;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function textFromContent(content: unknown): string {
|
|
36
|
-
if (typeof content === "string") return content;
|
|
37
|
-
if (!Array.isArray(content)) return "";
|
|
38
|
-
return content.map((part) => {
|
|
39
|
-
const obj = asRecord(part);
|
|
40
|
-
if (!obj) return "";
|
|
41
|
-
if (typeof obj.text === "string") return obj.text;
|
|
42
|
-
if (typeof obj.content === "string") return obj.content;
|
|
43
|
-
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
-
return "";
|
|
45
|
-
}).filter(Boolean).join("\n");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const text = textFromContent(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
private
|
|
98
|
-
private
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
this.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
this.lastHeight =
|
|
185
|
-
|
|
186
|
-
this.scroll =
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
type Component = { invalidate(): void; render(width: number): string[]; handleInput(data: string): void };
|
|
3
|
+
type TranscriptTheme = { fg?: (color: string, text: string) => string; bold?: (text: string) => string };
|
|
4
|
+
import type { TeamRunManifest } from "../state/types.ts";
|
|
5
|
+
import { agentOutputPath, readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
6
|
+
|
|
7
|
+
function visibleWidth(text: string): number {
|
|
8
|
+
return text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "").length;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function truncate(text: string, width: number): string {
|
|
12
|
+
if (width <= 0) return "";
|
|
13
|
+
if (visibleWidth(text) <= width) return text;
|
|
14
|
+
return width <= 1 ? "…" : `${text.slice(0, Math.max(0, width - 1))}…`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function wrap(text: string, width: number): string[] {
|
|
18
|
+
const source = text.split(/\r?\n/);
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
for (const raw of source) {
|
|
21
|
+
const line = raw || " ";
|
|
22
|
+
if (line.length <= width) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 0; index < line.length; index += width) lines.push(line.slice(index, index + width));
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function textFromContent(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content.map((part) => {
|
|
39
|
+
const obj = asRecord(part);
|
|
40
|
+
if (!obj) return "";
|
|
41
|
+
if (typeof obj.text === "string") return obj.text;
|
|
42
|
+
if (typeof obj.content === "string") return obj.content;
|
|
43
|
+
if (typeof obj.name === "string") return `[tool:${obj.name}]`;
|
|
44
|
+
return "";
|
|
45
|
+
}).filter(Boolean).join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function truncateBody(text: string, max = 1000): string {
|
|
49
|
+
return text.length > max ? `${text.slice(0, max)}… (truncated ${text.length - max} chars)` : text;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatTranscriptEvent(event: unknown): string[] {
|
|
53
|
+
const obj = asRecord(event);
|
|
54
|
+
if (!obj) return [String(event)];
|
|
55
|
+
const type = typeof obj.type === "string" ? obj.type : undefined;
|
|
56
|
+
const toolName = typeof obj.toolName === "string" ? obj.toolName : typeof obj.name === "string" ? obj.name : undefined;
|
|
57
|
+
if (type && /tool/i.test(type)) {
|
|
58
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : typeof obj.result === "string" ? obj.result : "");
|
|
59
|
+
return [`[Tool${toolName ? `: ${toolName}` : ""}] ${type}`, truncateBody(text.trim() || "(no output)")];
|
|
60
|
+
}
|
|
61
|
+
const message = asRecord(obj.message);
|
|
62
|
+
if (message) {
|
|
63
|
+
const role = typeof message.role === "string" ? message.role : "message";
|
|
64
|
+
const text = textFromContent(message.content);
|
|
65
|
+
const label = role === "assistant" ? "Assistant" : role === "user" ? "User" : role;
|
|
66
|
+
if (text.trim()) return [`[${label}]:`, text.trim()];
|
|
67
|
+
}
|
|
68
|
+
if (type) {
|
|
69
|
+
const text = textFromContent(obj.content) || (typeof obj.text === "string" ? obj.text : "");
|
|
70
|
+
return text.trim() ? [`[${type}]: ${text.trim()}`] : [`[${type}]`];
|
|
71
|
+
}
|
|
72
|
+
return [JSON.stringify(event)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function formatTranscriptText(text: string): string[] {
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
for (const raw of text.split(/\r?\n/).filter(Boolean)) {
|
|
78
|
+
try {
|
|
79
|
+
lines.push(...formatTranscriptEvent(JSON.parse(raw)));
|
|
80
|
+
} catch {
|
|
81
|
+
lines.push(raw);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return lines.length ? lines : ["(no transcript content)"];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRunTranscript(manifest: TeamRunManifest, taskId?: string): { title: string; path: string; lines: string[] } {
|
|
88
|
+
const agents = readCrewAgents(manifest);
|
|
89
|
+
const agent = taskId ? agents.find((item) => item.taskId === taskId || item.id === taskId) : agents.find((item) => item.transcriptPath) ?? agents[0];
|
|
90
|
+
const selectedTaskId = agent?.taskId ?? taskId ?? "unknown";
|
|
91
|
+
const transcriptPath = agent?.transcriptPath && fs.existsSync(agent.transcriptPath) ? agent.transcriptPath : agentOutputPath(manifest, selectedTaskId);
|
|
92
|
+
const text = fs.existsSync(transcriptPath) ? fs.readFileSync(transcriptPath, "utf-8") : "";
|
|
93
|
+
return { title: `${manifest.runId}:${selectedTaskId}`, path: transcriptPath, lines: formatTranscriptText(text) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class DurableTextViewer implements Component {
|
|
97
|
+
private scroll = 0;
|
|
98
|
+
private lastHeight = 10;
|
|
99
|
+
private autoScroll = true;
|
|
100
|
+
private title: string;
|
|
101
|
+
private subtitle: string;
|
|
102
|
+
private lines: string[];
|
|
103
|
+
private theme: unknown;
|
|
104
|
+
private done: (result: undefined) => void;
|
|
105
|
+
|
|
106
|
+
constructor(title: string, subtitle: string, lines: string[], theme: unknown, done: (result: undefined) => void) {
|
|
107
|
+
this.title = title;
|
|
108
|
+
this.subtitle = subtitle;
|
|
109
|
+
this.lines = lines.length ? lines : ["(empty)"];
|
|
110
|
+
this.theme = theme;
|
|
111
|
+
this.done = done;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
invalidate(): void {}
|
|
115
|
+
|
|
116
|
+
handleInput(data: string): void {
|
|
117
|
+
if (data === "q" || data === "\u001b") {
|
|
118
|
+
this.done(undefined);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const maxScroll = Math.max(0, this.lines.length - this.lastHeight);
|
|
122
|
+
if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
|
|
123
|
+
else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
|
|
124
|
+
else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
|
|
125
|
+
else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
|
|
126
|
+
else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
|
|
127
|
+
else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const th = this.theme as TranscriptTheme;
|
|
132
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
133
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
134
|
+
const inner = Math.max(20, width - 4);
|
|
135
|
+
this.lastHeight = 16;
|
|
136
|
+
const body = this.lines.flatMap((line) => wrap(line, inner));
|
|
137
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
138
|
+
if (this.autoScroll) this.scroll = maxScroll;
|
|
139
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
140
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
141
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
142
|
+
const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
|
|
143
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
|
|
144
|
+
return [
|
|
145
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
146
|
+
row(`${bold(this.title)} ${fg("dim", this.subtitle)}`),
|
|
147
|
+
row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
|
|
148
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
149
|
+
...visible.map(row),
|
|
150
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
151
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
|
|
152
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class DurableTranscriptViewer implements Component {
|
|
158
|
+
private scroll = 0;
|
|
159
|
+
private lastHeight = 10;
|
|
160
|
+
private autoScroll = true;
|
|
161
|
+
private manifest: TeamRunManifest;
|
|
162
|
+
private theme: unknown;
|
|
163
|
+
private done: (result: undefined) => void;
|
|
164
|
+
private taskId?: string;
|
|
165
|
+
|
|
166
|
+
constructor(manifest: TeamRunManifest, theme: unknown, done: (result: undefined) => void, taskId?: string) {
|
|
167
|
+
this.manifest = manifest;
|
|
168
|
+
this.theme = theme;
|
|
169
|
+
this.done = done;
|
|
170
|
+
this.taskId = taskId;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void {}
|
|
174
|
+
|
|
175
|
+
handleInput(data: string): void {
|
|
176
|
+
if (data === "q" || data === "\u001b") {
|
|
177
|
+
this.done(undefined);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const content = readRunTranscript(this.manifest, this.taskId).lines;
|
|
181
|
+
const maxScroll = Math.max(0, content.length - this.lastHeight);
|
|
182
|
+
if (data === "k" || data === "\u001b[A") { this.scroll = Math.max(0, this.scroll - 1); this.autoScroll = false; }
|
|
183
|
+
else if (data === "j" || data === "\u001b[B") { this.scroll = Math.min(maxScroll, this.scroll + 1); this.autoScroll = this.scroll >= maxScroll; }
|
|
184
|
+
else if (data === "\u001b[5~") { this.scroll = Math.max(0, this.scroll - this.lastHeight); this.autoScroll = false; }
|
|
185
|
+
else if (data === "\u001b[6~") { this.scroll = Math.min(maxScroll, this.scroll + this.lastHeight); this.autoScroll = this.scroll >= maxScroll; }
|
|
186
|
+
else if (data === "g" || data === "\u001b[H") { this.scroll = 0; this.autoScroll = false; }
|
|
187
|
+
else if (data === "G" || data === "\u001b[F") { this.scroll = maxScroll; this.autoScroll = true; }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
render(width: number): string[] {
|
|
191
|
+
const th = this.theme as TranscriptTheme;
|
|
192
|
+
const fg = th.fg?.bind(th) ?? ((_color: string, text: string) => text);
|
|
193
|
+
const bold = th.bold?.bind(th) ?? ((text: string) => text);
|
|
194
|
+
const inner = Math.max(20, width - 4);
|
|
195
|
+
const data = readRunTranscript(this.manifest, this.taskId);
|
|
196
|
+
const body = data.lines.flatMap((line) => wrap(line, inner));
|
|
197
|
+
this.lastHeight = 16;
|
|
198
|
+
const maxScroll = Math.max(0, body.length - this.lastHeight);
|
|
199
|
+
if (this.autoScroll) this.scroll = maxScroll;
|
|
200
|
+
this.scroll = Math.min(this.scroll, maxScroll);
|
|
201
|
+
const visible = body.slice(this.scroll, this.scroll + this.lastHeight);
|
|
202
|
+
const pad = (text: string) => `${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))}`;
|
|
203
|
+
const colorLine = (line: string) => line.startsWith("[User]") ? fg("accent", line) : line.startsWith("[Assistant]") ? bold(line) : line.startsWith("[Tool") ? fg("muted", line) : line.startsWith("[Result]") ? fg("dim", line) : line;
|
|
204
|
+
const row = (text: string) => `${fg("border", "│")} ${truncate(pad(colorLine(text)), inner)} ${fg("border", "│")}`;
|
|
205
|
+
const lines = [
|
|
206
|
+
fg("border", `╭${"─".repeat(inner + 2)}╮`),
|
|
207
|
+
row(`${bold("pi-crew transcript")} ${fg("dim", data.title)}`),
|
|
208
|
+
row(fg("dim", data.path)),
|
|
209
|
+
row(fg("dim", "j/k scroll · PgUp/PgDn · g/G top/bottom · q close")),
|
|
210
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
211
|
+
...visible.map(row),
|
|
212
|
+
fg("border", `├${"─".repeat(inner + 2)}┤`),
|
|
213
|
+
row(fg("dim", `${body.length} lines · ${body.length ? Math.round(((this.scroll + visible.length) / body.length) * 100) : 100}% · auto-scroll ${this.autoScroll ? "on" : "off"}`)),
|
|
214
|
+
fg("border", `╰${"─".repeat(inner + 2)}╯`),
|
|
215
|
+
];
|
|
216
|
+
return lines;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|