pi-cockpit 0.5.1 → 0.7.0
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 +130 -107
- package/package.json +6 -5
- package/src/agents-store.ts +134 -63
- package/src/ambient.ts +21 -12
- package/src/bash-bg-overlay.ts +30 -7
- package/src/bash-bg-widget.ts +54 -50
- package/src/config.ts +20 -0
- package/src/footer.ts +26 -3
- package/src/icons.ts +5 -1
- package/src/index.ts +381 -59
- package/src/layout.ts +181 -129
- package/src/maestro-store.ts +273 -0
- package/src/public/v1/events.ts +136 -0
- package/src/quiet-tools.ts +367 -310
- package/src/render.ts +107 -25
- package/src/settings-view.ts +57 -1
- package/src/sidebar-controller.ts +317 -0
- package/src/sidebar-render.ts +409 -0
- package/src/split-pane.ts +382 -0
- package/src/stack-widget.ts +16 -12
- package/src/thinking-timer.ts +249 -224
- package/src/tick-policy.ts +54 -0
- package/src/types.ts +51 -4
- package/src/viewport-stability.ts +101 -0
package/README.md
CHANGED
|
@@ -1,107 +1,130 @@
|
|
|
1
|
-
# pi-cockpit
|
|
2
|
-
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`).
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
│
|
|
13
|
-
|
|
14
|
-
│
|
|
15
|
-
│
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
pi
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- **
|
|
36
|
-
- **
|
|
37
|
-
- **
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
1
|
+
# pi-cockpit
|
|
2
|
+
|
|
3
|
+
A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
|
|
4
|
+
|
|
5
|
+
It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.5.1**.
|
|
6
|
+
|
|
7
|
+
The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─ AGENTS · 2 running ─────────────────────────────┐ ← setWidget(aboveEditor)
|
|
11
|
+
│ ⠋ explorer #a1f3c2 map auth read routes.ts │
|
|
12
|
+
│ ⠋ explorer #b9e014 trace jwt read jwt.ts │
|
|
13
|
+
├─ TODO · 1/4 ─────────────────────────────────────┤
|
|
14
|
+
│ 01 ✓ map auth entrypoints │
|
|
15
|
+
│ 02 ⠋ trace jwt verify │
|
|
16
|
+
│ 03 · implement refresh │
|
|
17
|
+
│ 04 · add tests │
|
|
18
|
+
└──────────────────────────────────────────────────┘
|
|
19
|
+
> dispatch a goal… ← editor (Pi native)
|
|
20
|
+
pi/stream-70b · ctx [████░░░░] 42% · $0.52 · 01:23 ← setFooter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
`pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow` pulls `pi-cockpit` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install npm:pi-cockpit # standalone from npm
|
|
29
|
+
# or, for one run:
|
|
30
|
+
pi -e ./packages/pi-cockpit
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## What it shows
|
|
34
|
+
|
|
35
|
+
- **SIDEBAR** — Workflow Session/Run progress, Goal state and budget, Todo tasks, Teammate roster, background jobs, and read-only Team Swarm progress. Empty sections disappear and constrained heights retain current or failed work before secondary detail.
|
|
36
|
+
- **AGENTS** — every running teammate as a table row (status spinner · role · id · label · live tail), sorted running-first. Completed/failed agents show their total duration. On narrow terminals this returns to the below-editor widget.
|
|
37
|
+
- **TODO** — the active plan as numbered rows with four states (done ✓ / in-progress spinner / blocked ! / pending ·). On narrow terminals this returns to the above-editor widget.
|
|
38
|
+
- **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`. `bash_bg` background-job state lives on a **dedicated second footer row** so it no longer competes with the primary line.
|
|
39
|
+
- **Thinking timer** — while the model is thinking, the folded thinking row shows a spinner and running elapsed time; when the run ends, it settles to the actual duration (e.g. `thoughts · 8.4s`).
|
|
40
|
+
- **Quiet mode** — compresses the seven built-in tool calls (read/bash/edit/write/grep/find/ls) into single-line ✓/✗/⋯ summaries and folds thinking blocks. Two glyph sets available: `check` (✓/✗/⋯) and `dot` (●/○/◌). Toggle with `/cockpit quiet`; turning off requires `/reload` to restore native tool renderers.
|
|
41
|
+
|
|
42
|
+
Toggle each block between list and compact with `/cockpit`.
|
|
43
|
+
|
|
44
|
+
## Data sources (and the dependency this implies)
|
|
45
|
+
|
|
46
|
+
| Block | Source | Available on bare Pi? |
|
|
47
|
+
|-------|--------|------------------------|
|
|
48
|
+
| MAESTRO | Versioned `cockpit:maestro-query` / `maestro:ui-snapshot` full snapshots from **pi-maestro-flow** | **No** — Workflow, Goal, and Swarm sections stay hidden |
|
|
49
|
+
| AGENTS | `pi.events` channels `teammate:started` / `teammate:message` / `teammate:complete`, broadcast by **pi-maestro-teammate** | **No** — without that extension no events fire, the block stays hidden |
|
|
50
|
+
| TODO | the `todo-state` snapshot the **pi-maestro-flow** `todo` tool persists after every mutation (re-read on each `tool_execution_end` and on `session_start`) | **No** — without the `todo` tool the block stays hidden |
|
|
51
|
+
| Footer | `ctx.model`, `ctx.getContextUsage()`, session usage totals, `footerData.getGitBranch()` | **Yes** |
|
|
52
|
+
|
|
53
|
+
So on a stock Pi the extension loads without error, the Maestro sections stay empty, and the footer remains available. The roster is **self-accumulated from event deltas**; Todo is back-filled from the latest durable `todo-state`. Workflow, Goal, and Swarm use a versioned full-replacement snapshot with generation fencing and a query path for cold-start recovery.
|
|
54
|
+
|
|
55
|
+
Cockpit has no package dependency on `pi-maestro-flow`. It observes optional producers through public event contracts; removing a producer hides only the matching section.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
`~/.pi/agent/cockpit.json` (created on first run):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"enabled": true,
|
|
64
|
+
"quietMode": false,
|
|
65
|
+
"quietSymbols": "check",
|
|
66
|
+
"agentsMode": "list",
|
|
67
|
+
"todoMode": "list",
|
|
68
|
+
"todoExpanded": false,
|
|
69
|
+
"hideNativeAgents": true,
|
|
70
|
+
"icons": { "mode": "auto" },
|
|
71
|
+
"sidebar": {
|
|
72
|
+
"mode": "auto",
|
|
73
|
+
"width": 40,
|
|
74
|
+
"density": "comfortable"
|
|
75
|
+
},
|
|
76
|
+
"theme": ""
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `quietMode`: when `true`, compresses built-in tool rendering and folds thinking blocks.
|
|
81
|
+
- `quietSymbols`: `"check"` (✓/✗/⋯) or `"dot"` (●/○/◌) lifecycle glyphs for quiet tool rows.
|
|
82
|
+
- `agentsMode` / `todoMode`: `"list"` or `"compact"`.
|
|
83
|
+
- `todoExpanded`: when `true`, expands the todo widget by default.
|
|
84
|
+
- `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. On by default.
|
|
85
|
+
- `sidebar.mode`: `"auto"` or `"on"` enables the dock when at least 72 main columns plus 32 sidebar columns fit; `"off"` always uses widgets.
|
|
86
|
+
- `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
|
|
87
|
+
- `sidebar.density`: `"comfortable"` or `"compact"`.
|
|
88
|
+
- `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
|
|
89
|
+
- `theme`: named theme override; empty string follows the Pi session theme.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
- `/cockpit` — opens the settings overlay.
|
|
94
|
+
- `/cockpit sidebar` — reports the current sidebar mode, width, and density.
|
|
95
|
+
- `/cockpit sidebar auto|on|off` — selects dock behavior.
|
|
96
|
+
- `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
|
|
97
|
+
- `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
|
|
98
|
+
- `/theme` — switch theme with live preview; `/theme <name>` applies directly. Pi ships no standalone theme command; cockpit provides one.
|
|
99
|
+
|
|
100
|
+
## Sidebar compatibility
|
|
101
|
+
|
|
102
|
+
The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.82.1`. A render integration failure disables the split and retries the original renderer at full width.
|
|
103
|
+
|
|
104
|
+
Do not enable `pi-cockpit`'s dock and `pi-atelier@0.7.0`'s sidebar together. Both reserve columns by wrapping the same renderer, and `pi-atelier@0.7.0` does not participate in Cockpit's split-owner marker protocol. Use `"sidebar": { "mode": "off" }` when running Atelier.
|
|
105
|
+
|
|
106
|
+
## Terminal feasibility — what this design deliberately does NOT do
|
|
107
|
+
|
|
108
|
+
The original mockup was a browser page; a terminal is an ANSI stream with no DOM, no CSS, no focus, no hover. Four mockup effects are **out of scope** here, with replacements:
|
|
109
|
+
|
|
110
|
+
| Mockup effect | Why it can't work in a TUI | Replacement |
|
|
111
|
+
|---------------|----------------------------|-------------|
|
|
112
|
+
| Input-focus "power-up" glow (`:has(:focus)`) | no focus pseudo-class; `render(width)` can't see focus | the stack's header dot turns accent-green while an agent is running |
|
|
113
|
+
| Hover-to-expand chips/rows | no hover in a terminal | list mode shows everything; compact mode is one line; `/cockpit` toggles |
|
|
114
|
+
| Scanlines / logo light-up animation | no overlay/animation layer | a braille spinner frame, advanced on each redraw |
|
|
115
|
+
| In-stream thinking-collapse / edit progress bar / colored bash stdout | built-in message & built-in tool rendering is **not** replaceable by extensions (`renderCall`/`renderResult` only apply to tools *you* register) | the conversation stream is left to Pi's native renderer — it is context, not a cockpit deliverable |
|
|
116
|
+
|
|
117
|
+
## Local development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
cd packages/pi-cockpit
|
|
121
|
+
npm test
|
|
122
|
+
npm run typecheck
|
|
123
|
+
npm pack --dry-run
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The package lives inside the `pi-maestro-flow` monorepo under `packages/` so it resolves `@earendil-works/*` types from the root `node_modules`. It is also an exact-pinned dependency of `pi-maestro-flow` and ships as its own npm package (`pi-cockpit`).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT. The split-pane behavior is adapted from `pi-atelier` under its MIT license; attribution is retained in `src/split-pane.ts`.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test --experimental-transform-types tests/config.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
|
|
21
|
+
"test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"pi": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@earendil-works/pi-ai": "*",
|
|
35
35
|
"@earendil-works/pi-coding-agent": "*",
|
|
36
36
|
"@earendil-works/pi-tui": "*",
|
|
37
|
-
"pi-maestro-teammate": "^1.
|
|
37
|
+
"pi-maestro-teammate": "^1.5.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@earendil-works/pi-agent-core": {
|
|
@@ -59,12 +59,13 @@
|
|
|
59
59
|
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
60
60
|
"@earendil-works/pi-tui": "0.82.1",
|
|
61
61
|
"@types/node": "^22.0.0",
|
|
62
|
-
"pi-maestro-teammate": "1.
|
|
62
|
+
"pi-maestro-teammate": "1.5.0",
|
|
63
63
|
"typescript": "^5.5.0"
|
|
64
64
|
},
|
|
65
65
|
"main": "./src/index.ts",
|
|
66
66
|
"exports": {
|
|
67
67
|
".": "./src/index.ts",
|
|
68
|
+
"./v1/events": "./src/public/v1/events.ts",
|
|
68
69
|
"./src/*": "./src/*"
|
|
69
70
|
}
|
|
70
71
|
}
|
package/src/agents-store.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
TeammateCompleteEvent,
|
|
3
|
+
TeammateProgressMessageEvent,
|
|
4
|
+
TeammateStartedEvent,
|
|
5
|
+
} from "pi-maestro-teammate/v1/events";
|
|
6
|
+
import type { AgentProgressSnapshot } from "pi-maestro-teammate/v1/types";
|
|
2
7
|
import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
3
8
|
import type { AgentRow, AgentStatus } from "./types.ts";
|
|
4
9
|
|
|
@@ -19,56 +24,28 @@ function truncateTail(raw: string): string {
|
|
|
19
24
|
return flat.length > TAIL_MAX ? flat.slice(0, TAIL_MAX - 1) + "…" : flat;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
taskIndex: number;
|
|
37
|
-
dependencies?: number[];
|
|
38
|
-
status?: string;
|
|
39
|
-
startedAt?: number | string;
|
|
40
|
-
completedAt?: number | string;
|
|
41
|
-
durationMs?: number;
|
|
42
|
-
lastActivityAt?: number | string;
|
|
27
|
+
// Store inputs are compatibility-relaxed projections of the versioned public
|
|
28
|
+
// contract. The event boundary validates discriminators and identifiers; the
|
|
29
|
+
// optional fields let older teammate versions continue to feed the same store.
|
|
30
|
+
export type StartedPayload = Pick<TeammateStartedEvent, "correlationId" | "agent">
|
|
31
|
+
& Partial<Omit<TeammateStartedEvent, "correlationId" | "agent" | "startedAt">>
|
|
32
|
+
& { startedAt?: number | string };
|
|
33
|
+
export type ProgressPayload = Pick<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex">
|
|
34
|
+
& Partial<Omit<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex" | "startedAt" | "completedAt">>
|
|
35
|
+
& { startedAt?: number | string; completedAt?: number | string };
|
|
36
|
+
export type MessagePayload = Partial<Omit<
|
|
37
|
+
TeammateProgressMessageEvent,
|
|
38
|
+
"progress" | "recentTools" | "isSend" | "isInteraction"
|
|
39
|
+
>> & {
|
|
40
|
+
progress?: ProgressPayload[];
|
|
43
41
|
recentTools?: Array<string | { name?: string; status?: string }>;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
outputTokens?: number;
|
|
48
|
-
lastMessage?: string;
|
|
49
|
-
}
|
|
50
|
-
export interface MessagePayload {
|
|
51
|
-
correlationId: string;
|
|
52
|
-
taskCorrelationId?: string;
|
|
53
|
-
// Present on progress/interaction deltas (teammate/.../index.ts publishProgress);
|
|
54
|
-
// absent on send deltas. Used to self-heal a row when `started` was missed.
|
|
55
|
-
agent?: string;
|
|
56
|
-
name?: string;
|
|
57
|
-
taskIndex?: number;
|
|
58
|
-
dependencies?: number[];
|
|
42
|
+
isSend?: boolean;
|
|
43
|
+
isInteraction?: boolean;
|
|
44
|
+
/** Compatibility with pre-v1 progress deltas; discriminated send events are ignored. */
|
|
59
45
|
message?: string;
|
|
60
|
-
|
|
61
|
-
recentTools?: Array<string | { name?: string; status?: string }>;
|
|
62
|
-
toolCount?: number;
|
|
63
|
-
tokens?: number;
|
|
64
|
-
inputTokens?: number;
|
|
65
|
-
outputTokens?: number;
|
|
66
|
-
status?: string;
|
|
67
|
-
lastActivityAt?: number | string;
|
|
68
|
-
progress?: ProgressPayload[];
|
|
69
|
-
}
|
|
46
|
+
};
|
|
70
47
|
export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">
|
|
71
|
-
& Partial<Pick<TeammateCompleteEvent, "durationMs">>;
|
|
48
|
+
& Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable" | "cancelled">>;
|
|
72
49
|
|
|
73
50
|
/**
|
|
74
51
|
* How long a failed agent stays on screen after it completes.
|
|
@@ -78,6 +55,22 @@ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exi
|
|
|
78
55
|
*/
|
|
79
56
|
export const FAILED_LINGER_MS = 30_000;
|
|
80
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How long a sleeping (wakeable) row stays visible after completion.
|
|
60
|
+
*
|
|
61
|
+
* Matches the native widget's idle-hide window so both surfaces agree on
|
|
62
|
+
* when an idle agent stops occupying screen space.
|
|
63
|
+
*/
|
|
64
|
+
export const SLEEPING_LINGER_MS = 60_000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* How long a terminated (cancelled/aborted) row stays on screen after it ends.
|
|
68
|
+
*
|
|
69
|
+
* Shorter than a failure: cancellation is expected, but the user should still
|
|
70
|
+
* see the × long enough to read which agent was taken down.
|
|
71
|
+
*/
|
|
72
|
+
export const TERMINATED_LINGER_MS = 15_000;
|
|
73
|
+
|
|
81
74
|
/**
|
|
82
75
|
* How long a completed agent's tombstone suppresses self-healing.
|
|
83
76
|
*
|
|
@@ -113,12 +106,23 @@ export function mapAgentStatus(status: unknown): AgentStatus {
|
|
|
113
106
|
return "done";
|
|
114
107
|
case "failed":
|
|
115
108
|
return "failed";
|
|
109
|
+
case "terminated":
|
|
110
|
+
return "terminated";
|
|
116
111
|
case "running":
|
|
117
112
|
default:
|
|
118
113
|
return "running";
|
|
119
114
|
}
|
|
120
115
|
}
|
|
121
116
|
|
|
117
|
+
export const AGENT_STALL_TIMEOUT_MS = 30_000;
|
|
118
|
+
export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
|
|
119
|
+
|
|
120
|
+
export function effectiveAgentStatus(row: AgentRow, now: number = Date.now()): AgentDisplayStatus {
|
|
121
|
+
if (row.status !== "running") return row.status;
|
|
122
|
+
if (row.resultReadyAt !== undefined) return "result-ready";
|
|
123
|
+
return now - row.lastActivityAt >= AGENT_STALL_TIMEOUT_MS ? "stalled" : "running";
|
|
124
|
+
}
|
|
125
|
+
|
|
122
126
|
function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
|
|
123
127
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
124
128
|
if (typeof value === "string") {
|
|
@@ -141,7 +145,7 @@ function terminalTime(
|
|
|
141
145
|
return row.finishedAt ?? now;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
|
-
function latestTool(tools:
|
|
148
|
+
function latestTool(tools: Array<string | { name?: string; status?: string }> | undefined): string | undefined {
|
|
145
149
|
if (!tools?.length) return undefined;
|
|
146
150
|
const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
|
|
147
151
|
?? tools.at(-1);
|
|
@@ -170,7 +174,16 @@ export class AgentsStore {
|
|
|
170
174
|
return at !== undefined && now - at < COMPLETED_TOMBSTONE_MS;
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
private canMaterialize(id: string, parentId: string | undefined, now: number): boolean {
|
|
178
|
+
if (this.isTombstoned(parentId, now)) return false;
|
|
179
|
+
if (!this.isTombstoned(id, now)) return true;
|
|
180
|
+
// An explicit parent restart clears only the graph container's tombstone.
|
|
181
|
+
// Its next full snapshot is authoritative for the child lifecycle too.
|
|
182
|
+
return parentId !== undefined && parentId !== id && this.roster.has(parentId);
|
|
183
|
+
}
|
|
184
|
+
|
|
173
185
|
applyStarted(p: StartedPayload, now: number = Date.now()): void {
|
|
186
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
174
187
|
const id = p.correlationId;
|
|
175
188
|
// An explicit start is authoritative: a woken or re-dispatched agent
|
|
176
189
|
// reuses its correlationId and must reappear even if an earlier run was
|
|
@@ -194,18 +207,27 @@ export class AgentsStore {
|
|
|
194
207
|
? { parentCorrelationId: prev.parentCorrelationId }
|
|
195
208
|
: {}),
|
|
196
209
|
};
|
|
197
|
-
if (row.status !== "done" && row.status !== "failed") delete row.finishedAt;
|
|
210
|
+
if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") delete row.finishedAt;
|
|
198
211
|
this.roster.set(id, row);
|
|
199
212
|
}
|
|
200
213
|
|
|
201
214
|
applyMessage(p: MessagePayload, now = Date.now()): void {
|
|
215
|
+
if (p.isSend === true || p.isInteraction === true) return;
|
|
216
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
202
217
|
if (p.progress) {
|
|
203
|
-
for (const progress of p.progress)
|
|
218
|
+
for (const progress of p.progress) {
|
|
219
|
+
if (typeof progress.correlationId !== "string" || progress.correlationId.length === 0) continue;
|
|
220
|
+
this.applyProgress(p.correlationId, progress, now);
|
|
221
|
+
}
|
|
204
222
|
}
|
|
205
|
-
const targetId = p.taskCorrelationId
|
|
223
|
+
const targetId = typeof p.taskCorrelationId === "string" && p.taskCorrelationId.length > 0
|
|
224
|
+
? p.taskCorrelationId
|
|
225
|
+
: p.correlationId;
|
|
226
|
+
const parentId = p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
|
|
227
|
+
? p.correlationId
|
|
228
|
+
: undefined;
|
|
206
229
|
if (!this.roster.has(targetId)
|
|
207
|
-
&&
|
|
208
|
-
&& !this.isTombstoned(p.correlationId, now)) {
|
|
230
|
+
&& this.canMaterialize(targetId, parentId, now)) {
|
|
209
231
|
// Self-heal the roster: a running agent must stay visible even when its
|
|
210
232
|
// `started` delta was missed (cold start, event reorder, or a foreground
|
|
211
233
|
// dispatch that detached to background). Materialize the row from this
|
|
@@ -228,7 +250,7 @@ export class AgentsStore {
|
|
|
228
250
|
if (!row) return;
|
|
229
251
|
const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
|
|
230
252
|
row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
|
|
231
|
-
const tail = p.
|
|
253
|
+
const tail = p.lastMessage ?? p.message;
|
|
232
254
|
if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
|
|
233
255
|
if (p.recentTools) {
|
|
234
256
|
const tool = latestTool(p.recentTools);
|
|
@@ -239,17 +261,29 @@ export class AgentsStore {
|
|
|
239
261
|
if (typeof p.tokens === "number") row.tokens = p.tokens;
|
|
240
262
|
if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
|
|
241
263
|
if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
|
|
264
|
+
if (typeof p.cacheReadTokens === "number") row.cacheReadTokens = p.cacheReadTokens;
|
|
265
|
+
if (typeof p.cacheWriteTokens === "number") row.cacheWriteTokens = p.cacheWriteTokens;
|
|
266
|
+
if (typeof p.error === "string") row.error = truncateTail(p.error);
|
|
267
|
+
if (typeof p.requestedModel === "string") row.requestedModel = clean(p.requestedModel);
|
|
268
|
+
if (typeof p.resolvedModel === "string") row.resolvedModel = clean(p.resolvedModel);
|
|
269
|
+
if (Array.isArray(p.attemptedModels)) {
|
|
270
|
+
row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
|
|
271
|
+
}
|
|
242
272
|
if (typeof p.status === "string") {
|
|
243
273
|
row.taskStatus = p.status;
|
|
244
274
|
row.status = mapAgentStatus(p.status);
|
|
245
|
-
if (row.status === "done" || row.status === "failed"
|
|
246
|
-
|
|
275
|
+
if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
|
|
276
|
+
row.finishedAt ??= now;
|
|
277
|
+
} else {
|
|
278
|
+
delete row.finishedAt;
|
|
279
|
+
}
|
|
247
280
|
}
|
|
248
281
|
if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
|
|
249
282
|
if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
|
|
250
283
|
}
|
|
251
284
|
|
|
252
285
|
applyComplete(p: CompletePayload, now = Date.now()): void {
|
|
286
|
+
if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
|
|
253
287
|
const pending = [p.correlationId];
|
|
254
288
|
const visited = new Set<string>();
|
|
255
289
|
while (pending.length > 0) {
|
|
@@ -263,16 +297,33 @@ export class AgentsStore {
|
|
|
263
297
|
// only evidence of the failure disappeared in the same frame it appeared.
|
|
264
298
|
// Successes still vanish immediately — the work has simply moved on.
|
|
265
299
|
const row = this.roster.get(id);
|
|
300
|
+
const cancelledById = id === p.correlationId && p.cancelled === true;
|
|
266
301
|
const failedByExitCode = id === p.correlationId
|
|
267
302
|
&& Number.isFinite(p.exitCode)
|
|
268
303
|
&& p.exitCode !== 0;
|
|
269
|
-
if (row && (row.status === "
|
|
304
|
+
if (row && (cancelledById || row.status === "terminated")) {
|
|
305
|
+
// Cancelled/terminated runs carry exitCode 1 but are not failures:
|
|
306
|
+
// a user abort or a result-ready reclaim must not light the red ✗.
|
|
307
|
+
row.status = "terminated";
|
|
308
|
+
row.taskStatus = "terminated";
|
|
309
|
+
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
310
|
+
row.lastActivityAt = now;
|
|
311
|
+
delete row.activeTool;
|
|
312
|
+
delete row.failedAt;
|
|
313
|
+
} else if (row && (row.status === "failed" || failedByExitCode)) {
|
|
270
314
|
row.status = "failed";
|
|
271
315
|
row.taskStatus = "failed";
|
|
272
316
|
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
273
317
|
row.failedAt = now;
|
|
274
318
|
row.lastActivityAt = now;
|
|
275
319
|
delete row.activeTool;
|
|
320
|
+
} else if (row && id === p.correlationId && p.wakeable && p.exitCode === 0) {
|
|
321
|
+
// A wakeable agent enters sleeping state after completion.
|
|
322
|
+
// Keep the row visible so the user can see it is idle but recallable.
|
|
323
|
+
row.status = "sleeping";
|
|
324
|
+
row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
|
|
325
|
+
row.lastActivityAt = now;
|
|
326
|
+
delete row.activeTool;
|
|
276
327
|
} else {
|
|
277
328
|
this.roster.delete(id);
|
|
278
329
|
// Tombstone even when the row never existed: a `complete` that beats
|
|
@@ -282,13 +333,19 @@ export class AgentsStore {
|
|
|
282
333
|
}
|
|
283
334
|
}
|
|
284
335
|
|
|
285
|
-
/** Drop failed rows that have had their time on screen. Returns true if any went. */
|
|
336
|
+
/** Drop failed, terminated or expired sleeping rows that have had their time on screen. Returns true if any went. */
|
|
286
337
|
prune(now = Date.now()): boolean {
|
|
287
338
|
let changed = false;
|
|
288
339
|
for (const [id, row] of this.roster) {
|
|
289
340
|
if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
|
|
290
341
|
this.roster.delete(id);
|
|
291
342
|
changed = true;
|
|
343
|
+
} else if (row.status === "terminated" && row.finishedAt !== undefined && now - row.finishedAt >= TERMINATED_LINGER_MS) {
|
|
344
|
+
this.roster.delete(id);
|
|
345
|
+
changed = true;
|
|
346
|
+
} else if (row.status === "sleeping" && row.finishedAt !== undefined && now - row.finishedAt >= SLEEPING_LINGER_MS) {
|
|
347
|
+
this.roster.delete(id);
|
|
348
|
+
changed = true;
|
|
292
349
|
}
|
|
293
350
|
}
|
|
294
351
|
for (const [id, at] of this.completedAt) {
|
|
@@ -297,18 +354,19 @@ export class AgentsStore {
|
|
|
297
354
|
return changed;
|
|
298
355
|
}
|
|
299
356
|
|
|
300
|
-
/** True while a failed row is still counting down, so the redraw loop must run. */
|
|
357
|
+
/** True while a failed, terminated or sleeping row is still counting down, so the redraw loop must run. */
|
|
301
358
|
hasLingering(): boolean {
|
|
302
359
|
for (const row of this.roster.values()) {
|
|
303
360
|
if (row.failedAt !== undefined) return true;
|
|
361
|
+
if (row.status === "terminated") return true;
|
|
362
|
+
if (row.status === "sleeping") return true;
|
|
304
363
|
}
|
|
305
364
|
return false;
|
|
306
365
|
}
|
|
307
366
|
|
|
308
367
|
private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
|
|
309
368
|
if (!this.roster.has(p.correlationId)
|
|
310
|
-
&&
|
|
311
|
-
&& !this.isTombstoned(parentCorrelationId, now)) {
|
|
369
|
+
&& this.canMaterialize(p.correlationId, parentCorrelationId, now)) {
|
|
312
370
|
// Self-heal: see applyMessage. A graph child whose `started` delta was
|
|
313
371
|
// missed still materializes from its first progress event — unless the
|
|
314
372
|
// child or its graph parent already completed, in which case this late
|
|
@@ -339,12 +397,15 @@ export class AgentsStore {
|
|
|
339
397
|
if (typeof p.status === "string") {
|
|
340
398
|
row.taskStatus = p.status;
|
|
341
399
|
row.status = mapAgentStatus(p.status);
|
|
342
|
-
if (row.status === "done" || row.status === "failed") {
|
|
400
|
+
if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
|
|
343
401
|
row.finishedAt = terminalTime(row, p, now);
|
|
344
402
|
} else {
|
|
345
403
|
delete row.finishedAt;
|
|
346
404
|
}
|
|
347
405
|
}
|
|
406
|
+
row.resultReadyAt = p.resultReadyAt === undefined
|
|
407
|
+
? undefined
|
|
408
|
+
: normalizeStartedAt(p.resultReadyAt, now);
|
|
348
409
|
row.taskIndex = p.taskIndex;
|
|
349
410
|
row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
|
|
350
411
|
row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
|
|
@@ -357,6 +418,16 @@ export class AgentsStore {
|
|
|
357
418
|
if (typeof p.tokens === "number") row.tokens = p.tokens;
|
|
358
419
|
if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
|
|
359
420
|
if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
|
|
421
|
+
row.cacheReadTokens = p.cacheReadTokens;
|
|
422
|
+
row.cacheWriteTokens = p.cacheWriteTokens;
|
|
423
|
+
if (p.error === undefined) delete row.error;
|
|
424
|
+
else row.error = truncateTail(p.error);
|
|
425
|
+
if (p.requestedModel === undefined) delete row.requestedModel;
|
|
426
|
+
else row.requestedModel = clean(p.requestedModel);
|
|
427
|
+
if (p.resolvedModel === undefined) delete row.resolvedModel;
|
|
428
|
+
else row.resolvedModel = clean(p.resolvedModel);
|
|
429
|
+
if (p.attemptedModels === undefined) delete row.attemptedModels;
|
|
430
|
+
else row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
|
|
360
431
|
if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
|
|
361
432
|
row.tail = truncateTail(p.lastMessage);
|
|
362
433
|
}
|