@danielbdev/opencode-agents 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @danielbdev/opencode-agents
2
+
3
+ An [OpenCode](https://opencode.ai) V2 plugin that shows the status of your **subagents** in the session
4
+ sidebar, below the MCP block.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ opencode plugin add @danielbdev/opencode-agents
10
+ ```
11
+
12
+ Requires OpenCode V2.
13
+
14
+ ## What it shows
15
+
16
+ ```
17
+ Agents
18
+ 2 running · 4 done · 1 failed
19
+ explore · 42s
20
+ general · 3m
21
+ verify · failed · 3m
22
+ ```
23
+
24
+ Succeeded subagents are counted but not listed, so the section stays short. Running ones come first,
25
+ then the failed ones.
26
+
27
+ Colour comes from the theme's semantic feedback slots — succeeded green, running yellow, failed or
28
+ interrupted red — and the count line takes the worst state present, so a failure colours the whole
29
+ section at a glance. Nothing here names a colour: it resolves through `theme.text.feedback[kind].base`,
30
+ so the semaphore follows whatever theme you run.
31
+
32
+ ## How it works
33
+
34
+ TUI-only: subagent data is already in the TUI's data layer, so this package ships **no server half, no
35
+ credential handling and no RPC**. It reads the session family of the session it renders in.
36
+
37
+ The state comes from fields that are queryable, not inferred from events:
38
+
39
+ | Field | Meaning |
40
+ | ----- | ------- |
41
+ | `parentID` | marks a record as a subagent |
42
+ | `agent` | the agent name |
43
+ | `outcome` | `succeeded`, `failed` or `interrupted` |
44
+ | `time.created` / `time.idle` | elapsed time |
45
+ | `data.session.status(id)` | adds the liveness bit: `idle` or `running` |
46
+
47
+ ## Security
48
+
49
+ This plugin **reads no credentials and makes no network requests**. It has no server half, so there is
50
+ nothing to authenticate and nothing to send anywhere.
51
+
52
+ What it does read is the host's session records for the session it renders in: the agent name, the outcome
53
+ and the timing of each subagent. That is the feature — a status section cannot work without reading
54
+ status. Nothing is written back, nothing is persisted, and the data never leaves the TUI process.
55
+
56
+ Like every package in this repository it ships its sources unminified, with no build step and no
57
+ install-time script, and each release carries a provenance attestation.
58
+
59
+ ## Development
60
+
61
+ ```sh
62
+ npm install
63
+ npm test # node --test, no build step (Node 24 type stripping)
64
+ npm run typecheck
65
+ ```
66
+
67
+ `src/core/agents.ts` holds the state mapping, the counts and the formatters with no host dependency, so
68
+ all of it runs under plain `node --test`.
69
+
70
+ This package lives in a monorepo of atomic plugins: each package does one thing and installs alone.
71
+
72
+ ## Releasing
73
+
74
+ Same flow as every package in this repository: bump, commit, tag `<directory>@<version>`, push the tag,
75
+ and GitHub Actions publishes it over OIDC. See the repository README of the sibling package for the
76
+ exact commands.
77
+
78
+ ## License
79
+
80
+ MIT
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
- {
2
- "name": "@danielbdev/opencode-agents",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@danielbdev/opencode-agents",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "OpenCode V2 plugin that shows subagent status in the session sidebar",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/danielbdev/opencode-plugins.git",
10
+ "directory": "packages/agents"
11
+ },
12
+ "keywords": [
13
+ "opencode",
14
+ "opencode-plugin",
15
+ "opencode-agents",
16
+ "subagents",
17
+ "sidebar"
18
+ ],
19
+ "exports": {
20
+ "./tui": "./src/tui.tsx"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "README.md",
25
+ "LICENSE"
26
+ ],
27
+ "scripts": {
28
+ "test": "node --test \"src/**/*.test.ts\"",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "dependencies": {
32
+ "@opencode/plugin": "^2.0.4"
33
+ },
34
+ "peerDependencies": {
35
+ "@opentui/core": ">=0.5.8",
36
+ "@opentui/solid": ">=0.5.8",
37
+ "solid-js": ">=1.9.0"
38
+ },
39
+ "devDependencies": {
40
+ "@opencode/client": "^2.0.4",
41
+ "@opencode/theme": "^2.0.4",
42
+ "@opentui/core": ">=0.5.8",
43
+ "@opentui/solid": ">=0.5.8",
44
+ "solid-js": ">=1.9.0",
45
+ "typescript": "^5.9.0"
46
+ }
47
+ }
@@ -0,0 +1,204 @@
1
+ import { test } from "node:test"
2
+ import assert from "node:assert/strict"
3
+
4
+ import {
5
+ feedbackOf,
6
+ formatAgentLine,
7
+ formatCounts,
8
+ formatElapsed,
9
+ stateOf,
10
+ summarize,
11
+ summaryFeedback,
12
+ type SessionLike,
13
+ } from "./agents.ts"
14
+
15
+ const NOW = 1_800_000_000_000
16
+
17
+ function subagent(overrides: Partial<SessionLike> & { id: string }): SessionLike {
18
+ return { parentID: "ses_root", agent: "explore", time: { created: NOW - 42_000 }, ...overrides }
19
+ }
20
+
21
+ test("stateOf reads the queryable outcome before anything else", () => {
22
+ assert.equal(stateOf({ id: "a", outcome: "succeeded" }, false), "succeeded")
23
+ assert.equal(stateOf({ id: "a", outcome: "failed" }, false), "failed")
24
+ assert.equal(stateOf({ id: "a", outcome: "interrupted" }, false), "interrupted")
25
+ assert.equal(stateOf({ id: "a", outcome: "succeeded" }, true), "succeeded")
26
+ })
27
+
28
+ test("stateOf falls back to liveness when there is no outcome yet", () => {
29
+ assert.equal(stateOf({ id: "a" }, true), "running")
30
+ assert.equal(stateOf({ id: "a" }, false), "idle")
31
+ })
32
+
33
+ test("summarize counts only subagents and ignores the root session", () => {
34
+ const summary = summarize(
35
+ [
36
+ { id: "ses_root", agent: "gentle-orchestrator" },
37
+ subagent({ id: "s1", outcome: "succeeded" }),
38
+ subagent({ id: "s2", outcome: "failed" }),
39
+ ],
40
+ new Set(),
41
+ NOW,
42
+ )
43
+
44
+ assert.equal(summary.total, 2)
45
+ assert.equal(summary.succeeded, 1)
46
+ assert.equal(summary.failed, 1)
47
+ assert.equal(summary.running, 0)
48
+ assert.equal(summary.done, 1)
49
+ })
50
+
51
+ test("summarize separates done from failed and counts running separately", () => {
52
+ const summary = summarize(
53
+ [
54
+ subagent({ id: "s1", outcome: "succeeded" }),
55
+ subagent({ id: "s2", outcome: "succeeded" }),
56
+ subagent({ id: "s3", outcome: "interrupted" }),
57
+ subagent({ id: "s4" }),
58
+ subagent({ id: "s5" }),
59
+ ],
60
+ new Set(["s4", "s5"]),
61
+ NOW,
62
+ )
63
+
64
+ assert.equal(summary.total, 5)
65
+ assert.equal(summary.succeeded, 2)
66
+ assert.equal(summary.failed, 1)
67
+ assert.equal(summary.running, 2)
68
+ assert.equal(summary.done, 2)
69
+ })
70
+
71
+ test("summarize lists running first, then failed, and never lists succeeded ones", () => {
72
+ const summary = summarize(
73
+ [
74
+ subagent({ id: "ok", outcome: "succeeded" }),
75
+ subagent({ id: "boom", outcome: "failed", agent: "verify" }),
76
+ subagent({ id: "busy", agent: "general" }),
77
+ ],
78
+ new Set(["busy"]),
79
+ NOW,
80
+ )
81
+
82
+ assert.deepEqual(
83
+ summary.entries.map((entry) => [entry.id, entry.state]),
84
+ [
85
+ ["busy", "running"],
86
+ ["boom", "failed"],
87
+ ],
88
+ )
89
+ assert.equal(summary.entries[0]?.name, "general")
90
+ })
91
+
92
+ test("summarize measures a running subagent against now and a finished one against its end", () => {
93
+ const summary = summarize(
94
+ [
95
+ subagent({ id: "busy", time: { created: NOW - 90_000 } }),
96
+ subagent({ id: "done", outcome: "succeeded", time: { created: NOW - 500_000, idle: NOW - 440_000 } }),
97
+ subagent({ id: "no-idle", outcome: "failed", time: { created: NOW - 30_000, updated: NOW - 10_000 } }),
98
+ ],
99
+ new Set(["busy"]),
100
+ NOW,
101
+ )
102
+
103
+ const byId = new Map(summary.entries.map((entry) => [entry.id, entry]))
104
+ assert.equal(byId.get("busy")?.elapsedMs, 90_000)
105
+ assert.equal(byId.get("no-idle")?.elapsedMs, 20_000)
106
+ })
107
+
108
+ test("summarize names an unnamed subagent and survives a missing time block", () => {
109
+ const summary = summarize([subagent({ id: "s1", agent: undefined, time: undefined })], new Set(), NOW)
110
+
111
+ assert.equal(summary.entries.length, 0)
112
+ assert.equal(summary.total, 1)
113
+
114
+ const named = summarize(
115
+ [subagent({ id: "s2", agent: undefined, outcome: "failed" })],
116
+ new Set(),
117
+ NOW,
118
+ )
119
+ assert.equal(named.entries[0]?.name, "subagent")
120
+ })
121
+
122
+ test("formatElapsed renders seconds, minutes and hours", () => {
123
+ assert.equal(formatElapsed(0), "0s")
124
+ assert.equal(formatElapsed(42_000), "42s")
125
+ assert.equal(formatElapsed(59_999), "59s")
126
+ assert.equal(formatElapsed(60_000), "1m")
127
+ assert.equal(formatElapsed(192_000), "3m")
128
+ assert.equal(formatElapsed(3_900_000), "1h 5m")
129
+ })
130
+
131
+ test("formatCounts omits a zero failed bucket and has an explicit empty state", () => {
132
+ const busy = summarize(
133
+ [subagent({ id: "a" }), subagent({ id: "b", outcome: "succeeded" })],
134
+ new Set(["a"]),
135
+ NOW,
136
+ )
137
+ assert.equal(formatCounts(busy), "1 running · 1 done")
138
+
139
+ const broken = summarize(
140
+ [subagent({ id: "a", outcome: "failed" }), subagent({ id: "b", outcome: "succeeded" })],
141
+ new Set(),
142
+ NOW,
143
+ )
144
+ assert.equal(formatCounts(broken), "0 running · 1 done · 1 failed")
145
+
146
+ const empty = summarize([{ id: "ses_root" }], new Set(), NOW)
147
+ assert.equal(formatCounts(empty), "no subagents yet")
148
+ })
149
+
150
+ test("formatAgentLine pairs the name with the elapsed time", () => {
151
+ assert.equal(
152
+ formatAgentLine({ id: "a", name: "explore", state: "running", elapsedMs: 42_000 }),
153
+ "explore · 42s",
154
+ )
155
+ assert.equal(
156
+ formatAgentLine({ id: "b", name: "verify", state: "failed", elapsedMs: 192_000 }),
157
+ "verify · failed · 3m",
158
+ )
159
+ })
160
+
161
+ test("feedbackOf maps every state onto a theme feedback kind", () => {
162
+ assert.equal(feedbackOf("running"), "warning")
163
+ assert.equal(feedbackOf("succeeded"), "success")
164
+ assert.equal(feedbackOf("failed"), "error")
165
+ assert.equal(feedbackOf("interrupted"), "error")
166
+ assert.equal(feedbackOf("idle"), "info")
167
+ })
168
+
169
+ test("a failed subagent keeps its final duration even while liveness still reports running", () => {
170
+ const summary = summarize(
171
+ [
172
+ subagent({
173
+ id: "boom",
174
+ outcome: "failed",
175
+ time: { created: NOW - 60_000, updated: NOW - 20_000 },
176
+ }),
177
+ ],
178
+ // Liveness lags: the outcome is already recorded but the status bit still says running.
179
+ new Set(["boom"]),
180
+ NOW,
181
+ )
182
+
183
+ assert.equal(summary.entries[0]?.state, "failed")
184
+ assert.equal(summary.entries[0]?.elapsedMs, 40_000)
185
+ })
186
+
187
+ test("summaryFeedback reports the worst state present", () => {
188
+ const allGood = summarize([subagent({ id: "a", outcome: "succeeded" })], new Set(), NOW)
189
+ assert.equal(summaryFeedback(allGood), "success")
190
+
191
+ const busy = summarize([subagent({ id: "a" }), subagent({ id: "b", outcome: "succeeded" })], new Set(["a"]), NOW)
192
+ assert.equal(summaryFeedback(busy), "warning")
193
+
194
+ const broken = summarize([subagent({ id: "a" }), subagent({ id: "b", outcome: "failed" })], new Set(["a"]), NOW)
195
+ assert.equal(summaryFeedback(broken), "error")
196
+
197
+ const empty = summarize([], new Set(), NOW)
198
+ assert.equal(summaryFeedback(empty), "info")
199
+
200
+ // Finished without a recorded outcome is still a green section: nothing is
201
+ // running and nothing failed.
202
+ const idleOnly = summarize([subagent({ id: "a" })], new Set(), NOW)
203
+ assert.equal(summaryFeedback(idleOnly), "success")
204
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Pure model for the subagent section.
3
+ *
4
+ * Everything here is host-free on purpose: the TUI plugin feeds it the session
5
+ * records it already has, so the whole state mapping, the counts and the
6
+ * formatters run under plain `node --test`.
7
+ *
8
+ * The fields come from `SessionInfo` in `@opencode/client`, verified rather
9
+ * than assumed:
10
+ *
11
+ * parentID?: string // marks a subagent
12
+ * agent?: string // the agent name
13
+ * outcome?: "succeeded" | "failed" | "interrupted" // queryable state
14
+ * time: { created, updated, idle? }
15
+ *
16
+ * Liveness is not part of the record: `data.session.status(id)` reports it, so
17
+ * the caller passes the running ids in.
18
+ */
19
+
20
+ export type AgentState = "running" | "succeeded" | "failed" | "interrupted" | "idle"
21
+
22
+ /** The theme's semantic colour slots, so the view never names a colour. */
23
+ export type FeedbackKind = "success" | "warning" | "error" | "info"
24
+
25
+ /** The slice of a session record this module needs. */
26
+ export interface SessionLike {
27
+ readonly id: string
28
+ readonly parentID?: string
29
+ readonly agent?: string
30
+ readonly outcome?: "succeeded" | "failed" | "interrupted"
31
+ readonly time?: {
32
+ readonly created?: number
33
+ readonly updated?: number
34
+ readonly idle?: number
35
+ }
36
+ }
37
+
38
+ export interface AgentEntry {
39
+ readonly id: string
40
+ readonly name: string
41
+ readonly state: AgentState
42
+ readonly elapsedMs: number
43
+ }
44
+
45
+ export interface AgentSummary {
46
+ /** Only the subagents worth a line: running first, then the failed ones. */
47
+ readonly entries: readonly AgentEntry[]
48
+ readonly running: number
49
+ readonly succeeded: number
50
+ /** Failed and interrupted together: both stopped for a reason. */
51
+ readonly failed: number
52
+ /** Finished without failing: succeeded or simply no longer running. */
53
+ readonly done: number
54
+ readonly total: number
55
+ }
56
+
57
+ const UNNAMED = "subagent"
58
+
59
+ /** A recorded outcome wins over liveness; liveness only covers the gap before one exists. */
60
+ export function stateOf(session: SessionLike, running: boolean): AgentState {
61
+ if (session.outcome === "succeeded") return "succeeded"
62
+ if (session.outcome === "failed") return "failed"
63
+ if (session.outcome === "interrupted") return "interrupted"
64
+ return running ? "running" : "idle"
65
+ }
66
+
67
+ function elapsedOf(session: SessionLike, running: boolean, now: number): number {
68
+ const created = session.time?.created
69
+ if (created === undefined) return 0
70
+
71
+ // A running subagent is measured against now; a finished one against when it
72
+ // stopped, falling back to its last update when no idle stamp exists.
73
+ const end = running ? now : (session.time?.idle ?? session.time?.updated ?? now)
74
+ return Math.max(0, end - created)
75
+ }
76
+
77
+ function isSubagent(session: SessionLike): boolean {
78
+ return session.parentID !== undefined
79
+ }
80
+
81
+ function isFailure(state: AgentState): boolean {
82
+ return state === "failed" || state === "interrupted"
83
+ }
84
+
85
+ /** Subagents only: the root session is the session itself, not a child. */
86
+ export function summarize(
87
+ sessions: readonly SessionLike[],
88
+ runningIds: ReadonlySet<string>,
89
+ now: number,
90
+ ): AgentSummary {
91
+ const entries: AgentEntry[] = []
92
+ let running = 0
93
+ let succeeded = 0
94
+ let failed = 0
95
+ let done = 0
96
+
97
+ const listed: AgentEntry[] = []
98
+
99
+ for (const session of sessions) {
100
+ if (!isSubagent(session)) continue
101
+
102
+ const isRunning = runningIds.has(session.id)
103
+ const state = stateOf(session, isRunning)
104
+
105
+ if (state === "running") running += 1
106
+ if (state === "succeeded") succeeded += 1
107
+ if (isFailure(state)) failed += 1
108
+ if (state === "succeeded" || state === "idle") done += 1
109
+
110
+ if (state === "running" || isFailure(state)) {
111
+ listed.push({
112
+ id: session.id,
113
+ name: session.agent ?? UNNAMED,
114
+ state,
115
+ // Measured against the state actually shown, not the raw liveness bit: a
116
+ // recorded outcome wins over liveness in `stateOf`, so keying the clock
117
+ // off liveness would let a failed entry's duration keep growing.
118
+ elapsedMs: elapsedOf(session, state === "running", now),
119
+ })
120
+ }
121
+ }
122
+
123
+ entries.push(
124
+ ...listed.filter((entry) => entry.state === "running"),
125
+ ...listed.filter((entry) => isFailure(entry.state)),
126
+ )
127
+
128
+ return { entries, running, succeeded, failed, done, total: running + done + failed }
129
+ }
130
+
131
+ export function formatElapsed(ms: number): string {
132
+ if (ms < 60_000) return `${Math.floor(ms / 1000)}s`
133
+ if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m`
134
+
135
+ const hours = Math.floor(ms / 3_600_000)
136
+ const minutes = Math.floor((ms % 3_600_000) / 60_000)
137
+ return `${hours}h ${minutes}m`
138
+ }
139
+
140
+ export function formatCounts(summary: AgentSummary): string {
141
+ if (summary.total === 0) return "no subagents yet"
142
+
143
+ const parts = [`${summary.running} running`, `${summary.done} done`]
144
+ if (summary.failed > 0) parts.push(`${summary.failed} failed`)
145
+ return parts.join(" · ")
146
+ }
147
+
148
+ export function formatAgentLine(entry: AgentEntry): string {
149
+ const elapsed = formatElapsed(entry.elapsedMs)
150
+ return entry.state === "running"
151
+ ? `${entry.name} · ${elapsed}`
152
+ : `${entry.name} · ${entry.state} · ${elapsed}`
153
+ }
154
+
155
+ export function feedbackOf(state: AgentState): FeedbackKind {
156
+ if (state === "failed" || state === "interrupted") return "error"
157
+ if (state === "running") return "warning"
158
+ if (state === "succeeded") return "success"
159
+ return "info"
160
+ }
161
+
162
+ /** The count line takes the worst state present, so a failure colours the whole section. */
163
+ export function summaryFeedback(summary: AgentSummary): FeedbackKind {
164
+ if (summary.failed > 0) return "error"
165
+ if (summary.running > 0) return "warning"
166
+ if (summary.done > 0) return "success"
167
+ return "info"
168
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,95 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { createMemo, createSignal, onCleanup } from "solid-js"
4
+
5
+ import {
6
+ feedbackOf,
7
+ formatAgentLine,
8
+ formatCounts,
9
+ summarize,
10
+ summaryFeedback,
11
+ type FeedbackKind,
12
+ type SessionLike,
13
+ } from "./core/agents.ts"
14
+
15
+ /** Elapsed times move on their own; a running subagent needs no event to age. */
16
+ const ELAPSED_TICK_MS = 10_000
17
+
18
+ const EMPTY = summarize([], new Set(), Date.now())
19
+
20
+ /**
21
+ * Colours come from the theme's semantic feedback slots
22
+ * (`FeedbackKind = error | warning | success | info`), so the semaphore follows
23
+ * whatever theme is active instead of naming a colour here.
24
+ */
25
+ function feedbackColor(context: Plugin.Context, kind: FeedbackKind): string {
26
+ const feedback = (
27
+ context.theme as unknown as
28
+ | { text?: { feedback?: Record<string, { base?: unknown } | undefined> } }
29
+ | undefined
30
+ )?.text?.feedback
31
+
32
+ return (feedback?.[kind]?.base ?? "white") as string
33
+ }
34
+
35
+ function titleColor(context: Plugin.Context): string {
36
+ const text = (context.theme as unknown as { text?: Record<string, unknown> } | undefined)?.text
37
+ return (text?.["base"] ?? "white") as string
38
+ }
39
+
40
+ /**
41
+ * TUI-only plugin.
42
+ *
43
+ * Subagent data is already in the TUI's data layer, so there is no server half,
44
+ * no credential and no RPC: the section reads the session family of the session
45
+ * it renders in.
46
+ */
47
+ export default Plugin.define({
48
+ id: "opencode-agents.tui",
49
+ setup(context) {
50
+ const AgentsSection = (props: { sessionID: string }) => {
51
+ const [tick, setTick] = createSignal(0)
52
+
53
+ const timer = setInterval(() => setTick((value) => value + 1), ELAPSED_TICK_MS)
54
+ onCleanup(() => clearInterval(timer))
55
+
56
+ const stopEvents = context.data.listen(({ details }) => {
57
+ if (details.type.startsWith("session.")) setTick((value) => value + 1)
58
+ })
59
+ onCleanup(() => stopEvents())
60
+
61
+ const summary = createMemo(() => {
62
+ tick()
63
+
64
+ const root = props.sessionID
65
+ if (!root) return EMPTY
66
+
67
+ const ids = context.data.session.family(root)
68
+ const sessions = ids
69
+ .map((id) => context.data.session.get(id))
70
+ .filter((session): session is NonNullable<typeof session> => session !== undefined)
71
+
72
+ const running = new Set(ids.filter((id) => context.data.session.status(id) === "running"))
73
+
74
+ return summarize(sessions as readonly SessionLike[], running, Date.now())
75
+ })
76
+
77
+ return (
78
+ <box flexDirection="column">
79
+ <text fg={titleColor(context)}>Agents</text>
80
+ <text fg={feedbackColor(context, summaryFeedback(summary()))}>{formatCounts(summary())}</text>
81
+ {summary().entries.map((entry) => (
82
+ <text fg={feedbackColor(context, feedbackOf(entry.state))}>{formatAgentLine(entry)}</text>
83
+ ))}
84
+ </box>
85
+ )
86
+ }
87
+
88
+ const stopSlot = context.ui.slot({
89
+ append: "sidebar.content",
90
+ render: (input) => <AgentsSection sessionID={input.sessionID} />,
91
+ })
92
+
93
+ return () => stopSlot()
94
+ },
95
+ })