mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-04 09:47:54 +08:00
Compare commits
11 Commits
feat/volce
...
fix/schema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccd4116635 | ||
|
|
bc5da42b2c | ||
|
|
5b0741e986 | ||
|
|
9e1f606f7f | ||
|
|
7eae504d15 | ||
|
|
eda400d8a5 | ||
|
|
82197a87dc | ||
|
|
dee51c1607 | ||
|
|
f06adcc1ae | ||
|
|
06ebe34b40 | ||
|
|
7785654ad5 |
211
hermes_state.py
211
hermes_state.py
@@ -256,109 +256,136 @@ class SessionDB:
|
|||||||
self._conn.close()
|
self._conn.close()
|
||||||
self._conn = None
|
self._conn = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_schema_columns(schema_sql: str) -> Dict[str, Dict[str, str]]:
|
||||||
|
"""Extract expected columns per table from SCHEMA_SQL.
|
||||||
|
|
||||||
|
Uses an in-memory SQLite database to parse the SQL — SQLite itself
|
||||||
|
handles all syntax (DEFAULT expressions with commas, inline
|
||||||
|
REFERENCES, CHECK constraints, etc.) so there are zero regex
|
||||||
|
edge cases. The in-memory DB is opened, the schema DDL is
|
||||||
|
executed, and PRAGMA table_info extracts the column metadata.
|
||||||
|
|
||||||
|
Adding a column to SCHEMA_SQL is all that's needed; the
|
||||||
|
reconciliation loop picks it up automatically.
|
||||||
|
"""
|
||||||
|
ref = sqlite3.connect(":memory:")
|
||||||
|
try:
|
||||||
|
ref.executescript(schema_sql)
|
||||||
|
table_columns: Dict[str, Dict[str, str]] = {}
|
||||||
|
for (tbl,) in ref.execute(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
|
).fetchall():
|
||||||
|
cols: Dict[str, str] = {}
|
||||||
|
for row in ref.execute(
|
||||||
|
f'PRAGMA table_info("{tbl}")'
|
||||||
|
).fetchall():
|
||||||
|
# row: (cid, name, type, notnull, dflt_value, pk)
|
||||||
|
col_name = row[1]
|
||||||
|
col_type = row[2] or ""
|
||||||
|
notnull = row[3]
|
||||||
|
default = row[4]
|
||||||
|
pk = row[5]
|
||||||
|
# Reconstruct the type expression for ALTER TABLE ADD COLUMN
|
||||||
|
parts = [col_type] if col_type else []
|
||||||
|
if notnull and not pk:
|
||||||
|
parts.append("NOT NULL")
|
||||||
|
if default is not None:
|
||||||
|
parts.append(f"DEFAULT {default}")
|
||||||
|
cols[col_name] = " ".join(parts)
|
||||||
|
table_columns[tbl] = cols
|
||||||
|
return table_columns
|
||||||
|
finally:
|
||||||
|
ref.close()
|
||||||
|
|
||||||
|
def _reconcile_columns(self, cursor: sqlite3.Cursor) -> None:
|
||||||
|
"""Ensure live tables have every column declared in SCHEMA_SQL.
|
||||||
|
|
||||||
|
Follows the Beets/sqlite-utils pattern: the CREATE TABLE definition
|
||||||
|
in SCHEMA_SQL is the single source of truth for the desired schema.
|
||||||
|
On every startup this method diffs the live columns (via PRAGMA
|
||||||
|
table_info) against the declared columns, and ADDs any that are
|
||||||
|
missing.
|
||||||
|
|
||||||
|
This makes column additions a declarative operation — just add
|
||||||
|
the column to SCHEMA_SQL and it appears on the next startup.
|
||||||
|
Version-gated migration blocks are no longer needed for ADD COLUMN.
|
||||||
|
"""
|
||||||
|
expected = self._parse_schema_columns(SCHEMA_SQL)
|
||||||
|
for table_name, declared_cols in expected.items():
|
||||||
|
# Get current columns from the live table
|
||||||
|
try:
|
||||||
|
rows = cursor.execute(
|
||||||
|
f'PRAGMA table_info("{table_name}")'
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
continue # Table doesn't exist yet (shouldn't happen after executescript)
|
||||||
|
live_cols = set()
|
||||||
|
for row in rows:
|
||||||
|
# PRAGMA table_info returns (cid, name, type, notnull, dflt_value, pk)
|
||||||
|
name = row[1] if isinstance(row, (tuple, list)) else row["name"]
|
||||||
|
live_cols.add(name)
|
||||||
|
|
||||||
|
for col_name, col_type in declared_cols.items():
|
||||||
|
if col_name not in live_cols:
|
||||||
|
safe_name = col_name.replace('"', '""')
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
f'ALTER TABLE "{table_name}" ADD COLUMN "{safe_name}" {col_type}'
|
||||||
|
)
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
# Expected: "duplicate column name" from a race or
|
||||||
|
# re-run. Unexpected: "Cannot add a NOT NULL column
|
||||||
|
# with default value NULL" from a schema mistake.
|
||||||
|
# Log at DEBUG so it's visible in agent.log.
|
||||||
|
logger.debug(
|
||||||
|
"reconcile %s.%s: %s", table_name, col_name, exc,
|
||||||
|
)
|
||||||
|
|
||||||
def _init_schema(self):
|
def _init_schema(self):
|
||||||
"""Create tables and FTS if they don't exist, run migrations."""
|
"""Create tables and FTS if they don't exist, reconcile columns.
|
||||||
|
|
||||||
|
Schema management follows the declarative reconciliation pattern
|
||||||
|
(Beets, sqlite-utils): SCHEMA_SQL is the single source of truth.
|
||||||
|
On existing databases, _reconcile_columns() diffs live columns
|
||||||
|
against SCHEMA_SQL and ADDs any missing ones. This eliminates
|
||||||
|
the version-gated migration chain for column additions, making
|
||||||
|
it impossible for reordered or inserted migrations to skip columns.
|
||||||
|
|
||||||
|
The schema_version table is retained for future data migrations
|
||||||
|
(transforming existing rows) which cannot be handled declaratively.
|
||||||
|
"""
|
||||||
cursor = self._conn.cursor()
|
cursor = self._conn.cursor()
|
||||||
|
|
||||||
cursor.executescript(SCHEMA_SQL)
|
cursor.executescript(SCHEMA_SQL)
|
||||||
|
|
||||||
# Check schema version and run migrations
|
# ── Declarative column reconciliation ──────────────────────────
|
||||||
|
# Diff live tables against SCHEMA_SQL and ADD any missing columns.
|
||||||
|
# This is idempotent and self-healing: even if a version-gated
|
||||||
|
# migration was skipped (e.g. due to version renumbering), the
|
||||||
|
# column gets created here.
|
||||||
|
self._reconcile_columns(cursor)
|
||||||
|
|
||||||
|
# ── Schema version bookkeeping ─────────────────────────────────
|
||||||
|
# Bump to current so future data migrations (if any) can gate on
|
||||||
|
# version. No version-gated column additions remain.
|
||||||
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
cursor.execute("SELECT version FROM schema_version LIMIT 1")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
cursor.execute("INSERT INTO schema_version (version) VALUES (?)", (SCHEMA_VERSION,))
|
cursor.execute(
|
||||||
|
"INSERT INTO schema_version (version) VALUES (?)",
|
||||||
|
(SCHEMA_VERSION,),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
|
current_version = row["version"] if isinstance(row, sqlite3.Row) else row[0]
|
||||||
if current_version < 2:
|
if current_version < SCHEMA_VERSION:
|
||||||
# v2: add finish_reason column to messages
|
cursor.execute(
|
||||||
try:
|
"UPDATE schema_version SET version = ?",
|
||||||
cursor.execute("ALTER TABLE messages ADD COLUMN finish_reason TEXT")
|
(SCHEMA_VERSION,),
|
||||||
except sqlite3.OperationalError:
|
)
|
||||||
pass # Column already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 2")
|
|
||||||
if current_version < 3:
|
|
||||||
# v3: add title column to sessions
|
|
||||||
try:
|
|
||||||
cursor.execute("ALTER TABLE sessions ADD COLUMN title TEXT")
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass # Column already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 3")
|
|
||||||
if current_version < 4:
|
|
||||||
# v4: add unique index on title (NULLs allowed, only non-NULL must be unique)
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
|
||||||
"ON sessions(title) WHERE title IS NOT NULL"
|
|
||||||
)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass # Index already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 4")
|
|
||||||
if current_version < 5:
|
|
||||||
new_columns = [
|
|
||||||
("cache_read_tokens", "INTEGER DEFAULT 0"),
|
|
||||||
("cache_write_tokens", "INTEGER DEFAULT 0"),
|
|
||||||
("reasoning_tokens", "INTEGER DEFAULT 0"),
|
|
||||||
("billing_provider", "TEXT"),
|
|
||||||
("billing_base_url", "TEXT"),
|
|
||||||
("billing_mode", "TEXT"),
|
|
||||||
("estimated_cost_usd", "REAL"),
|
|
||||||
("actual_cost_usd", "REAL"),
|
|
||||||
("cost_status", "TEXT"),
|
|
||||||
("cost_source", "TEXT"),
|
|
||||||
("pricing_version", "TEXT"),
|
|
||||||
]
|
|
||||||
for name, column_type in new_columns:
|
|
||||||
try:
|
|
||||||
# name and column_type come from the hardcoded tuple above,
|
|
||||||
# not user input. Double-quote identifier escaping is applied
|
|
||||||
# as defense-in-depth; SQLite DDL cannot be parameterized.
|
|
||||||
safe_name = name.replace('"', '""')
|
|
||||||
cursor.execute(f'ALTER TABLE sessions ADD COLUMN "{safe_name}" {column_type}')
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 5")
|
|
||||||
if current_version < 6:
|
|
||||||
# v6: add reasoning columns to messages table — preserves assistant
|
|
||||||
# reasoning text and structured reasoning_details across gateway
|
|
||||||
# session turns. Without these, reasoning chains are lost on
|
|
||||||
# session reload, breaking multi-turn reasoning continuity for
|
|
||||||
# providers that replay reasoning (OpenRouter, OpenAI, Nous).
|
|
||||||
for col_name, col_type in [
|
|
||||||
("reasoning", "TEXT"),
|
|
||||||
("reasoning_details", "TEXT"),
|
|
||||||
("codex_reasoning_items", "TEXT"),
|
|
||||||
]:
|
|
||||||
try:
|
|
||||||
safe = col_name.replace('"', '""')
|
|
||||||
cursor.execute(
|
|
||||||
f'ALTER TABLE messages ADD COLUMN "{safe}" {col_type}'
|
|
||||||
)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass # Column already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 6")
|
|
||||||
if current_version < 7:
|
|
||||||
# v7: preserve provider-native reasoning_content separately from
|
|
||||||
# normalized reasoning text. Kimi/Moonshot replay can require
|
|
||||||
# this field on assistant tool-call messages when thinking is on.
|
|
||||||
try:
|
|
||||||
cursor.execute('ALTER TABLE messages ADD COLUMN "reasoning_content" TEXT')
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass # Column already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 7")
|
|
||||||
if current_version < 8:
|
|
||||||
# v8: add api_call_count column to sessions — tracks the number
|
|
||||||
# of individual LLM API calls made within a session (as opposed
|
|
||||||
# to the session count itself).
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
'ALTER TABLE sessions ADD COLUMN "api_call_count" INTEGER DEFAULT 0'
|
|
||||||
)
|
|
||||||
except sqlite3.OperationalError:
|
|
||||||
pass # Column already exists
|
|
||||||
cursor.execute("UPDATE schema_version SET version = 8")
|
|
||||||
|
|
||||||
# Unique title index — always ensure it exists (safe to run after migrations
|
# Unique title index — always ensure it exists
|
||||||
# since the title column is guaranteed to exist at this point)
|
|
||||||
try:
|
try:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title_unique "
|
||||||
|
|||||||
@@ -1254,6 +1254,144 @@ class TestSchemaInit:
|
|||||||
|
|
||||||
migrated_db.close()
|
migrated_db.close()
|
||||||
|
|
||||||
|
def test_reconciliation_adds_missing_columns(self, tmp_path):
|
||||||
|
"""Columns present in SCHEMA_SQL but missing from the live table
|
||||||
|
are added by _reconcile_columns regardless of schema_version.
|
||||||
|
|
||||||
|
Regression test: commit a7d78d3b inserted a new v7 migration
|
||||||
|
(reasoning_content) and renumbered the old v7 (api_call_count)
|
||||||
|
to v8. Users already at the old v7 had schema_version >= 7,
|
||||||
|
so the new v7 block was skipped and reasoning_content was never
|
||||||
|
created — causing 'no such column' on /continue.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = tmp_path / "gap_test.db"
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
# Simulate the old v7 state: api_call_count exists, reasoning_content does NOT
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||||||
|
INSERT INTO schema_version (version) VALUES (7);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
model TEXT,
|
||||||
|
model_config TEXT,
|
||||||
|
system_prompt TEXT,
|
||||||
|
parent_session_id TEXT,
|
||||||
|
started_at REAL NOT NULL,
|
||||||
|
ended_at REAL,
|
||||||
|
end_reason TEXT,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
tool_call_count INTEGER DEFAULT 0,
|
||||||
|
input_tokens INTEGER DEFAULT 0,
|
||||||
|
output_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER DEFAULT 0,
|
||||||
|
cache_write_tokens INTEGER DEFAULT 0,
|
||||||
|
reasoning_tokens INTEGER DEFAULT 0,
|
||||||
|
billing_provider TEXT,
|
||||||
|
billing_base_url TEXT,
|
||||||
|
billing_mode TEXT,
|
||||||
|
estimated_cost_usd REAL,
|
||||||
|
actual_cost_usd REAL,
|
||||||
|
cost_status TEXT,
|
||||||
|
cost_source TEXT,
|
||||||
|
pricing_version TEXT,
|
||||||
|
title TEXT,
|
||||||
|
api_call_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
tool_call_id TEXT,
|
||||||
|
tool_calls TEXT,
|
||||||
|
tool_name TEXT,
|
||||||
|
timestamp REAL NOT NULL,
|
||||||
|
token_count INTEGER,
|
||||||
|
finish_reason TEXT,
|
||||||
|
reasoning TEXT,
|
||||||
|
reasoning_details TEXT,
|
||||||
|
codex_reasoning_items TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
|
||||||
|
("s1", "cli", 1000.0),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO messages (session_id, role, content, timestamp) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
("s1", "assistant", "hello", 1001.0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# Verify reasoning_content is absent
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||||
|
assert "reasoning_content" not in cols
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Open with SessionDB — reconciliation should add the missing column
|
||||||
|
migrated_db = SessionDB(db_path=db_path)
|
||||||
|
|
||||||
|
msg_cols = {
|
||||||
|
r[1]
|
||||||
|
for r in migrated_db._conn.execute("PRAGMA table_info(messages)").fetchall()
|
||||||
|
}
|
||||||
|
assert "reasoning_content" in msg_cols
|
||||||
|
|
||||||
|
# The query that used to crash must now work
|
||||||
|
cursor = migrated_db._conn.execute(
|
||||||
|
"SELECT role, content, reasoning, reasoning_content, "
|
||||||
|
"reasoning_details, codex_reasoning_items "
|
||||||
|
"FROM messages WHERE session_id = ?",
|
||||||
|
("s1",),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == "assistant"
|
||||||
|
assert row[3] is None # reasoning_content NULL for old rows
|
||||||
|
|
||||||
|
migrated_db.close()
|
||||||
|
|
||||||
|
def test_reconciliation_is_idempotent(self, tmp_path):
|
||||||
|
"""Opening the same database twice doesn't error or duplicate columns."""
|
||||||
|
db_path = tmp_path / "idempotent.db"
|
||||||
|
db1 = SessionDB(db_path=db_path)
|
||||||
|
cols1 = {r[1] for r in db1._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||||
|
db1.close()
|
||||||
|
|
||||||
|
db2 = SessionDB(db_path=db_path)
|
||||||
|
cols2 = {r[1] for r in db2._conn.execute("PRAGMA table_info(messages)").fetchall()}
|
||||||
|
db2.close()
|
||||||
|
|
||||||
|
assert cols1 == cols2
|
||||||
|
|
||||||
|
def test_schema_sql_is_source_of_truth(self, db):
|
||||||
|
"""Every column in SCHEMA_SQL exists in the live database.
|
||||||
|
|
||||||
|
This is the architectural invariant: SCHEMA_SQL declares the
|
||||||
|
desired schema, _reconcile_columns ensures it matches reality.
|
||||||
|
"""
|
||||||
|
from hermes_state import SCHEMA_SQL
|
||||||
|
|
||||||
|
expected = SessionDB._parse_schema_columns(SCHEMA_SQL)
|
||||||
|
for table_name, declared_cols in expected.items():
|
||||||
|
live_cols = {
|
||||||
|
r[1]
|
||||||
|
for r in db._conn.execute(
|
||||||
|
f'PRAGMA table_info("{table_name}")'
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
for col_name in declared_cols:
|
||||||
|
assert col_name in live_cols, (
|
||||||
|
f"Column {col_name} declared in SCHEMA_SQL for {table_name} "
|
||||||
|
f"but missing from live DB. Live columns: {live_cols}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTitleUniqueness:
|
class TestTitleUniqueness:
|
||||||
"""Tests for unique title enforcement and title-based lookups."""
|
"""Tests for unique title enforcement and title-based lookups."""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
410
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
410
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSubagentTree,
|
||||||
|
descendantIds,
|
||||||
|
flattenTree,
|
||||||
|
fmtCost,
|
||||||
|
fmtDuration,
|
||||||
|
fmtTokens,
|
||||||
|
formatSummary,
|
||||||
|
hotnessBucket,
|
||||||
|
peakHotness,
|
||||||
|
sparkline,
|
||||||
|
topLevelSubagents,
|
||||||
|
treeTotals,
|
||||||
|
widthByDepth
|
||||||
|
} from '../lib/subagentTree.js'
|
||||||
|
import type { SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
|
||||||
|
depth: 0,
|
||||||
|
goal: overrides.id,
|
||||||
|
notes: [],
|
||||||
|
parentId: null,
|
||||||
|
status: 'running',
|
||||||
|
taskCount: 1,
|
||||||
|
thinking: [],
|
||||||
|
toolCount: 0,
|
||||||
|
tools: [],
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aggregate: tokens, cost, files, hotness', () => {
|
||||||
|
it('sums tokens and cost across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
|
||||||
|
makeItem({
|
||||||
|
costUsd: 0.005,
|
||||||
|
depth: 1,
|
||||||
|
id: 'c1',
|
||||||
|
index: 0,
|
||||||
|
inputTokens: 500,
|
||||||
|
outputTokens: 100,
|
||||||
|
parentId: 'p'
|
||||||
|
}),
|
||||||
|
makeItem({
|
||||||
|
costUsd: 0.008,
|
||||||
|
depth: 1,
|
||||||
|
id: 'c2',
|
||||||
|
index: 1,
|
||||||
|
inputTokens: 300,
|
||||||
|
outputTokens: 200,
|
||||||
|
parentId: 'p'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate).toMatchObject({
|
||||||
|
costUsd: 0.023,
|
||||||
|
inputTokens: 1800,
|
||||||
|
outputTokens: 800
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts files read + written across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.filesTouched).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotness = totalTools / totalDuration', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({
|
||||||
|
durationSeconds: 10,
|
||||||
|
id: 'p',
|
||||||
|
index: 0,
|
||||||
|
status: 'completed',
|
||||||
|
toolCount: 20
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotness is zero when duration is zero', () => {
|
||||||
|
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.hotness).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hotnessBucket + peakHotness', () => {
|
||||||
|
it('peakHotness walks subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
|
||||||
|
makeItem({
|
||||||
|
depth: 1,
|
||||||
|
durationSeconds: 1,
|
||||||
|
id: 'c',
|
||||||
|
index: 0,
|
||||||
|
parentId: 'p',
|
||||||
|
status: 'completed',
|
||||||
|
toolCount: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(peakHotness(tree)).toBeGreaterThan(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotnessBucket clamps and normalizes', () => {
|
||||||
|
expect(hotnessBucket(0, 10, 4)).toBe(0)
|
||||||
|
expect(hotnessBucket(10, 10, 4)).toBe(3)
|
||||||
|
expect(hotnessBucket(5, 10, 4)).toBe(2)
|
||||||
|
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
|
||||||
|
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fmtCost + fmtTokens', () => {
|
||||||
|
it('fmtCost handles ranges', () => {
|
||||||
|
expect(fmtCost(0)).toBe('')
|
||||||
|
expect(fmtCost(0.001)).toBe('<$0.01')
|
||||||
|
expect(fmtCost(0.42)).toBe('$0.42')
|
||||||
|
expect(fmtCost(1.23)).toBe('$1.23')
|
||||||
|
expect(fmtCost(12.5)).toBe('$12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fmtTokens handles ranges', () => {
|
||||||
|
expect(fmtTokens(0)).toBe('0')
|
||||||
|
expect(fmtTokens(542)).toBe('542')
|
||||||
|
expect(fmtTokens(1234)).toBe('1.2k')
|
||||||
|
expect(fmtTokens(45678)).toBe('46k')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatSummary with tokens + cost', () => {
|
||||||
|
it('includes token + cost when present', () => {
|
||||||
|
expect(
|
||||||
|
formatSummary({
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0.42,
|
||||||
|
descendantCount: 3,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 8000,
|
||||||
|
maxDepthFromHere: 2,
|
||||||
|
outputTokens: 2000,
|
||||||
|
totalDuration: 30,
|
||||||
|
totalTools: 14
|
||||||
|
})
|
||||||
|
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildSubagentTree', () => {
|
||||||
|
it('returns empty list for empty input', () => {
|
||||||
|
expect(buildSubagentTree([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats flat list as top-level when no parentId is given', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(3)
|
||||||
|
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
|
||||||
|
expect(tree.every(n => n.children.length === 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests children under their parent by subagent_id', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'parent', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
|
||||||
|
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(1)
|
||||||
|
expect(tree[0]!.children).toHaveLength(2)
|
||||||
|
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds multi-level nesting', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
|
||||||
|
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
|
||||||
|
expect(tree[0]!.aggregate.descendantCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes orphaned children (missing parent) to top level', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(2)
|
||||||
|
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stable sort: children ordered by (depth, index) not insert order', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aggregate', () => {
|
||||||
|
it('sums tool counts and durations across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
|
||||||
|
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
|
||||||
|
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate).toMatchObject({
|
||||||
|
activeCount: 0,
|
||||||
|
descendantCount: 2,
|
||||||
|
totalDuration: 16,
|
||||||
|
totalTools: 9
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts queued + running as active', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0, status: 'running' }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.activeCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('widthByDepth', () => {
|
||||||
|
it('returns empty array for empty tree', () => {
|
||||||
|
expect(widthByDepth([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tallies nodes at each depth', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p1', index: 0 }),
|
||||||
|
makeItem({ id: 'p2', index: 1 }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('treeTotals', () => {
|
||||||
|
it('folds a full tree into a single rollup', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
|
||||||
|
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const totals = treeTotals(buildSubagentTree(items))
|
||||||
|
expect(totals.descendantCount).toBe(3)
|
||||||
|
expect(totals.totalTools).toBe(10)
|
||||||
|
expect(totals.maxDepthFromHere).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns zeros for empty tree', () => {
|
||||||
|
expect(treeTotals([])).toEqual({
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0,
|
||||||
|
descendantCount: 0,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
maxDepthFromHere: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalTools: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('flattenTree + descendantIds', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
it('flattens in visit order (depth-first, pre-order)', () => {
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects descendant ids excluding the node itself', () => {
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sparkline', () => {
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(sparkline([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zeroes as spaces (not bottom glyph)', () => {
|
||||||
|
expect(sparkline([0, 0])).toBe(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales to the max value', () => {
|
||||||
|
const out = sparkline([1, 8])
|
||||||
|
expect(out).toHaveLength(2)
|
||||||
|
expect(out[1]).toBe('█')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sparse widths render as expected', () => {
|
||||||
|
const out = sparkline([2, 3, 7, 4])
|
||||||
|
expect(out).toHaveLength(4)
|
||||||
|
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatSummary', () => {
|
||||||
|
const emptyTotals = {
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0,
|
||||||
|
descendantCount: 0,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
maxDepthFromHere: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalTools: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
it('collapses zero-valued components', () => {
|
||||||
|
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits rich summary with all pieces', () => {
|
||||||
|
expect(
|
||||||
|
formatSummary({
|
||||||
|
...emptyTotals,
|
||||||
|
activeCount: 2,
|
||||||
|
descendantCount: 7,
|
||||||
|
maxDepthFromHere: 3,
|
||||||
|
totalDuration: 134,
|
||||||
|
totalTools: 124
|
||||||
|
})
|
||||||
|
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fmtDuration', () => {
|
||||||
|
it('formats under a minute as plain seconds', () => {
|
||||||
|
expect(fmtDuration(0)).toBe('0s')
|
||||||
|
expect(fmtDuration(42)).toBe('42s')
|
||||||
|
expect(fmtDuration(59.4)).toBe('59s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats whole minutes without trailing seconds', () => {
|
||||||
|
expect(fmtDuration(60)).toBe('1m')
|
||||||
|
expect(fmtDuration(180)).toBe('3m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mixes minutes and seconds', () => {
|
||||||
|
expect(fmtDuration(134)).toBe('2m 14s')
|
||||||
|
expect(fmtDuration(605)).toBe('10m 5s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('topLevelSubagents', () => {
|
||||||
|
it('returns items with no parent', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes children whose parent is present', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes orphans whose parent is missing', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||||
|
import { topLevelSubagents } from '../lib/subagentTree.js'
|
||||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||||
import { fromSkin } from '../theme.js'
|
import { fromSkin } from '../theme.js'
|
||||||
import type { Msg, SubagentProgress } from '../types.js'
|
import type { Msg, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||||
import { patchOverlayState } from './overlayStore.js'
|
import { patchOverlayState } from './overlayStore.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
@@ -53,6 +55,55 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
let pendingThinkingStatus = ''
|
let pendingThinkingStatus = ''
|
||||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||||
|
|
||||||
|
// Inject the disk-save callback into turnController so recordMessageComplete
|
||||||
|
// can fire-and-forget a persist without having to plumb a gateway ref around.
|
||||||
|
turnController.persistSpawnTree = async (subagents, sessionId) => {
|
||||||
|
try {
|
||||||
|
const startedAt = subagents.reduce<number>((min, s) => {
|
||||||
|
if (!s.startedAt) {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
return min === 0 ? s.startedAt : Math.min(min, s.startedAt)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const top = topLevelSubagents(subagents)
|
||||||
|
.map(s => s.goal)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
|
||||||
|
const label = top.length ? top.join(' · ') : `${subagents.length} subagents`
|
||||||
|
|
||||||
|
await rpc('spawn_tree.save', {
|
||||||
|
finished_at: Date.now() / 1000,
|
||||||
|
label: label.slice(0, 120),
|
||||||
|
session_id: sessionId ?? 'default',
|
||||||
|
started_at: startedAt ? startedAt / 1000 : null,
|
||||||
|
subagents
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Persistence is best-effort; in-memory history is the authoritative
|
||||||
|
// same-session source. A write failure doesn't block the turn.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh delegation caps at most every 5s so the status bar HUD can
|
||||||
|
// render a /warning close to the configured cap without spamming the RPC.
|
||||||
|
let lastDelegationFetchAt = 0
|
||||||
|
|
||||||
|
const refreshDelegationStatus = (force = false) => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (!force && now - lastDelegationFetchAt < 5000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDelegationFetchAt = now
|
||||||
|
rpc<DelegationStatusResponse>('delegation.status', {})
|
||||||
|
.then(r => applyDelegationStatus(r))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const setStatus = (status: string) => {
|
const setStatus = (status: string) => {
|
||||||
pendingThinkingStatus = ''
|
pendingThinkingStatus = ''
|
||||||
|
|
||||||
@@ -85,7 +136,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
}, ms)
|
}, ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running')
|
// Terminal statuses are never overwritten by late-arriving live events —
|
||||||
|
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
||||||
|
// `failed` or `interrupted` terminal state (Copilot review #14045).
|
||||||
|
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
|
||||||
|
|
||||||
|
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
|
||||||
|
|
||||||
const handleReady = (skin?: GatewaySkin) => {
|
const handleReady = (skin?: GatewaySkin) => {
|
||||||
if (skin) {
|
if (skin) {
|
||||||
@@ -260,32 +316,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
|
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
|
||||||
|
|
||||||
return
|
return
|
||||||
|
case 'tool.complete': {
|
||||||
|
const inlineDiffText =
|
||||||
|
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
||||||
|
|
||||||
case 'tool.complete':
|
turnController.recordToolComplete(
|
||||||
{
|
ev.payload.tool_id,
|
||||||
const inlineDiffText =
|
ev.payload.name,
|
||||||
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
ev.payload.error,
|
||||||
|
inlineDiffText ? '' : ev.payload.summary
|
||||||
turnController.recordToolComplete(
|
)
|
||||||
ev.payload.tool_id,
|
|
||||||
ev.payload.name,
|
|
||||||
ev.payload.error,
|
|
||||||
inlineDiffText ? '' : ev.payload.summary
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!inlineDiffText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep inline diffs attached to the assistant completion body so
|
|
||||||
// they render in the same message flow, not as a standalone system
|
|
||||||
// artifact that can look out-of-place around tool rows.
|
|
||||||
turnController.queueInlineDiff(inlineDiffText)
|
|
||||||
|
|
||||||
|
if (!inlineDiffText) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep inline diffs attached to the assistant completion body so
|
||||||
|
// they render in the same message flow, not as a standalone system
|
||||||
|
// artifact that can look out-of-place around tool rows.
|
||||||
|
turnController.queueInlineDiff(inlineDiffText)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case 'clarify.request':
|
case 'clarify.request':
|
||||||
patchOverlayState({
|
patchOverlayState({
|
||||||
@@ -329,8 +381,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case 'subagent.spawn_requested':
|
||||||
|
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
|
||||||
|
// Preserve completed state if a later event races in before this one.
|
||||||
|
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
|
||||||
|
|
||||||
|
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
|
||||||
|
// warn as depth/concurrency approaches the configured ceiling.
|
||||||
|
if (getDelegationState().maxSpawnDepth === null) {
|
||||||
|
refreshDelegationStatus(true)
|
||||||
|
} else {
|
||||||
|
refreshDelegationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
case 'subagent.start':
|
case 'subagent.start':
|
||||||
turnController.upsertSubagent(ev.payload, () => ({ status: 'running' }))
|
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
|
||||||
|
|
||||||
return
|
return
|
||||||
case 'subagent.thinking': {
|
case 'subagent.thinking': {
|
||||||
@@ -340,10 +407,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
// Update-only: never resurrect subagents whose spawn_requested/start
|
||||||
status: keepCompletedElseRunning(c.status),
|
// we missed or that already flushed via message.complete.
|
||||||
thinking: pushThinking(c.thinking, text)
|
turnController.upsertSubagent(
|
||||||
}))
|
ev.payload,
|
||||||
|
c => ({
|
||||||
|
status: keepTerminalElseRunning(c.status),
|
||||||
|
thinking: pushThinking(c.thinking, text)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -354,10 +427,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
ev.payload.tool_preview ?? ev.payload.text ?? ''
|
ev.payload.tool_preview ?? ev.payload.text ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
status: keepCompletedElseRunning(c.status),
|
ev.payload,
|
||||||
tools: pushTool(c.tools, line)
|
c => ({
|
||||||
}))
|
status: keepTerminalElseRunning(c.status),
|
||||||
|
tools: pushTool(c.tools, line)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,20 +446,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
notes: pushNote(c.notes, text),
|
ev.payload,
|
||||||
status: keepCompletedElseRunning(c.status)
|
c => ({
|
||||||
}))
|
notes: pushNote(c.notes, text),
|
||||||
|
status: keepTerminalElseRunning(c.status)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'subagent.complete':
|
case 'subagent.complete':
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
|
ev.payload,
|
||||||
status: ev.payload.status ?? 'completed',
|
c => ({
|
||||||
summary: ev.payload.summary || ev.payload.text || c.summary
|
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
|
||||||
}))
|
status: ev.payload.status ?? 'completed',
|
||||||
|
summary: ev.payload.summary || ev.payload.text || c.summary
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
77
ui-tui/src/app/delegationStore.ts
Normal file
77
ui-tui/src/app/delegationStore.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
import type { DelegationStatusResponse } from '../gatewayTypes.js'
|
||||||
|
|
||||||
|
export interface DelegationState {
|
||||||
|
// Last known caps from `delegation.status` RPC. null until fetched.
|
||||||
|
maxConcurrentChildren: null | number
|
||||||
|
maxSpawnDepth: null | number
|
||||||
|
// True when spawning is globally paused (see tools/delegate_tool.py).
|
||||||
|
paused: boolean
|
||||||
|
// Monotonic clock of the last successful status fetch.
|
||||||
|
updatedAt: null | number
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildState = (): DelegationState => ({
|
||||||
|
maxConcurrentChildren: null,
|
||||||
|
maxSpawnDepth: null,
|
||||||
|
paused: false,
|
||||||
|
updatedAt: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $delegationState = atom<DelegationState>(buildState())
|
||||||
|
|
||||||
|
export const getDelegationState = () => $delegationState.get()
|
||||||
|
|
||||||
|
export const patchDelegationState = (next: Partial<DelegationState>) =>
|
||||||
|
$delegationState.set({ ...$delegationState.get(), ...next })
|
||||||
|
|
||||||
|
export const resetDelegationState = () => $delegationState.set(buildState())
|
||||||
|
|
||||||
|
// ── Overlay accordion open-state ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Lifted out of OverlaySection's local useState so collapse choices
|
||||||
|
// survive:
|
||||||
|
// - navigating to a different subagent (Detail remounts)
|
||||||
|
// - switching list ↔ detail mode (Detail unmounts in list mode)
|
||||||
|
// - walking history (←/→)
|
||||||
|
// Keyed by section title; missing entries fall back to the section's
|
||||||
|
// `defaultOpen` prop.
|
||||||
|
|
||||||
|
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
|
||||||
|
const state = $overlaySectionsOpen.get()
|
||||||
|
const current = title in state ? state[title]! : defaultOpen
|
||||||
|
|
||||||
|
$overlaySectionsOpen.set({ ...state, [title]: !current })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
|
||||||
|
const state = $overlaySectionsOpen.get()
|
||||||
|
|
||||||
|
return title in state ? state[title]! : defaultOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
|
||||||
|
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
|
||||||
|
|
||||||
|
if (typeof r.max_spawn_depth === 'number') {
|
||||||
|
patch.maxSpawnDepth = r.max_spawn_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof r.max_concurrent_children === 'number') {
|
||||||
|
patch.maxConcurrentChildren = r.max_concurrent_children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof r.paused === 'boolean') {
|
||||||
|
patch.paused = r.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
patchDelegationState(patch)
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ export interface GatewayProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayState {
|
export interface OverlayState {
|
||||||
|
agents: boolean
|
||||||
|
agentsInitialHistoryIndex: number
|
||||||
approval: ApprovalReq | null
|
approval: ApprovalReq | null
|
||||||
clarify: ClarifyReq | null
|
clarify: ClarifyReq | null
|
||||||
confirm: ConfirmReq | null
|
confirm: ConfirmReq | null
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { atom, computed } from 'nanostores'
|
|||||||
import type { OverlayState } from './interfaces.js'
|
import type { OverlayState } from './interfaces.js'
|
||||||
|
|
||||||
const buildOverlayState = (): OverlayState => ({
|
const buildOverlayState = (): OverlayState => ({
|
||||||
|
agents: false,
|
||||||
|
agentsInitialHistoryIndex: 0,
|
||||||
approval: null,
|
approval: null,
|
||||||
clarify: null,
|
clarify: null,
|
||||||
confirm: null,
|
confirm: null,
|
||||||
@@ -18,8 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
|||||||
|
|
||||||
export const $isBlocked = computed(
|
export const $isBlocked = computed(
|
||||||
$overlayState,
|
$overlayState,
|
||||||
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||||
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getOverlayState = () => $overlayState.get()
|
export const getOverlayState = () => $overlayState.get()
|
||||||
@@ -27,4 +29,23 @@ export const getOverlayState = () => $overlayState.get()
|
|||||||
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
||||||
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
||||||
|
|
||||||
|
/** Full reset — used by session/turn teardown and tests. */
|
||||||
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
|
||||||
|
* / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model
|
||||||
|
* picker, skills hub, session picker. Those are opened deliberately and
|
||||||
|
* shouldn't vanish when a turn ends. Called from turnController.idle() on
|
||||||
|
* every turn completion / interrupt; the old "reset everything" behaviour
|
||||||
|
* silently closed /agents the moment delegation finished.
|
||||||
|
*/
|
||||||
|
export const resetFlowOverlays = () =>
|
||||||
|
$overlayState.set({
|
||||||
|
...buildOverlayState(),
|
||||||
|
agents: $overlayState.get().agents,
|
||||||
|
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||||
|
modelPicker: $overlayState.get().modelPicker,
|
||||||
|
picker: $overlayState.get().picker,
|
||||||
|
skillsHub: $overlayState.get().skillsHub
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
import type {
|
||||||
|
DelegationPauseResponse,
|
||||||
|
SlashExecResponse,
|
||||||
|
SpawnTreeListResponse,
|
||||||
|
SpawnTreeLoadResponse,
|
||||||
|
ToolsConfigureResponse
|
||||||
|
} from '../../../gatewayTypes.js'
|
||||||
import type { PanelSection } from '../../../types.js'
|
import type { PanelSection } from '../../../types.js'
|
||||||
|
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
|
||||||
import { patchOverlayState } from '../../overlayStore.js'
|
import { patchOverlayState } from '../../overlayStore.js'
|
||||||
|
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
|
||||||
import type { SlashCommand } from '../types.js'
|
import type { SlashCommand } from '../types.js'
|
||||||
|
|
||||||
interface SkillInfo {
|
interface SkillInfo {
|
||||||
@@ -42,6 +50,163 @@ interface SkillsBrowseResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const opsCommands: SlashCommand[] = [
|
export const opsCommands: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
aliases: ['tasks'],
|
||||||
|
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
|
||||||
|
name: 'agents',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const sub = arg.trim().toLowerCase()
|
||||||
|
|
||||||
|
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
|
||||||
|
// explicit subcommands skip the overlay and act directly so scripts and
|
||||||
|
// multi-step flows can drive it without entering interactive mode.
|
||||||
|
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
|
||||||
|
const paused = sub === 'pause'
|
||||||
|
ctx.gateway.gw
|
||||||
|
.request<DelegationPauseResponse>('delegation.pause', { paused })
|
||||||
|
.then(r => {
|
||||||
|
applyDelegationStatus({ paused: r?.paused })
|
||||||
|
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
|
||||||
|
})
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
|
const d = getDelegationState()
|
||||||
|
ctx.transcript.sys(
|
||||||
|
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
|
||||||
|
name: 'replay',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const history = getSpawnHistory()
|
||||||
|
const raw = arg.trim()
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
|
||||||
|
// ── Disk-backed listing ─────────────────────────────────────
|
||||||
|
if (lower === 'list' || lower === 'ls') {
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<SpawnTreeListResponse>('spawn_tree.list', {
|
||||||
|
limit: 30,
|
||||||
|
session_id: ctx.sid ?? 'default'
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
ctx.guarded<SpawnTreeListResponse>(r => {
|
||||||
|
const entries = r.entries ?? []
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
return ctx.transcript.sys('no archived spawn trees on disk for this session')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: [string, string][] = entries.map(e => {
|
||||||
|
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
|
||||||
|
const label = e.label || `${e.count} subagents`
|
||||||
|
|
||||||
|
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.transcript.panel('Archived spawn trees', [{ rows }])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disk-backed load by path ─────────────────────────────────
|
||||||
|
if (lower.startsWith('load ')) {
|
||||||
|
const path = raw.slice(5).trim()
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return ctx.transcript.sys('usage: /replay load <path>')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<SpawnTreeLoadResponse>(r => {
|
||||||
|
if (!r.subagents?.length) {
|
||||||
|
return ctx.transcript.sys('snapshot empty or unreadable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push onto the in-memory history so the overlay picks it up
|
||||||
|
// by index 1 just like any other snapshot.
|
||||||
|
pushDiskSnapshot(r, path)
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-memory nav (same-session) ─────────────────────────────
|
||||||
|
if (!history.length) {
|
||||||
|
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 1
|
||||||
|
|
||||||
|
if (raw && lower !== 'last') {
|
||||||
|
const parsed = parseInt(raw, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
|
||||||
|
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
|
||||||
|
}
|
||||||
|
|
||||||
|
index = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
|
||||||
|
name: 'replay-diff',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const parts = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = parts
|
||||||
|
const history = getSpawnHistory()
|
||||||
|
|
||||||
|
const resolve = (token: string): null | SpawnSnapshot => {
|
||||||
|
const n = parseInt(token!, 10)
|
||||||
|
|
||||||
|
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
|
||||||
|
return history[n - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = resolve(a!)
|
||||||
|
const candidate = resolve(b!)
|
||||||
|
|
||||||
|
if (!baseline || !candidate) {
|
||||||
|
return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiffPair({ baseline, candidate })
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
help: 'browse, inspect, install skills',
|
help: 'browse, inspect, install skills',
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
|
|||||||
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
||||||
|
import type { SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
export interface SpawnSnapshot {
|
||||||
|
finishedAt: number
|
||||||
|
fromDisk?: boolean
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
path?: string
|
||||||
|
sessionId: null | string
|
||||||
|
startedAt: number
|
||||||
|
subagents: SubagentProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnDiffPair {
|
||||||
|
baseline: SpawnSnapshot
|
||||||
|
candidate: SpawnSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 10
|
||||||
|
|
||||||
|
export const $spawnHistory = atom<SpawnSnapshot[]>([])
|
||||||
|
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
||||||
|
|
||||||
|
export const getSpawnHistory = () => $spawnHistory.get()
|
||||||
|
export const getSpawnDiff = () => $spawnDiff.get()
|
||||||
|
|
||||||
|
export const clearSpawnHistory = () => $spawnHistory.set([])
|
||||||
|
export const clearDiffPair = () => $spawnDiff.set(null)
|
||||||
|
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a finished turn's spawn tree to history. Keeps the last 10
|
||||||
|
* non-empty snapshots — empty turns (no subagents) are dropped.
|
||||||
|
*
|
||||||
|
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
|
||||||
|
* it misbehaved, let me look at what happened" — same-session debugging.
|
||||||
|
* Disk persistence across process restarts is a natural extension but
|
||||||
|
* adds RPC surface for a less-common path.
|
||||||
|
*/
|
||||||
|
export const pushSnapshot = (
|
||||||
|
subagents: readonly SubagentProgress[],
|
||||||
|
meta: { sessionId?: null | string; startedAt?: null | number }
|
||||||
|
) => {
|
||||||
|
if (!subagents.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
|
||||||
|
|
||||||
|
const snap: SpawnSnapshot = {
|
||||||
|
finishedAt: now,
|
||||||
|
id: `snap-${now.toString(36)}`,
|
||||||
|
label: summarizeLabel(subagents),
|
||||||
|
sessionId: meta.sessionId ?? null,
|
||||||
|
startedAt: Number.isFinite(started) ? started : now,
|
||||||
|
subagents: subagents.map(item => ({ ...item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||||
|
$spawnHistory.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
|
||||||
|
const top = subagents
|
||||||
|
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(s => s.goal || 'subagent')
|
||||||
|
.join(' · ')
|
||||||
|
|
||||||
|
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a disk-loaded snapshot onto the front of the history stack so the
|
||||||
|
* overlay can pick it up at index 1 via /replay load. Normalises the
|
||||||
|
* server payload (arbitrary list) into the same SubagentProgress shape
|
||||||
|
* used for live data — defensive against cross-version reads.
|
||||||
|
*/
|
||||||
|
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
|
||||||
|
const raw = Array.isArray(r.subagents) ? r.subagents : []
|
||||||
|
const normalised = raw.map(normaliseSubagent)
|
||||||
|
|
||||||
|
if (!normalised.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap: SpawnSnapshot = {
|
||||||
|
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
|
||||||
|
fromDisk: true,
|
||||||
|
id: `disk-${path}`,
|
||||||
|
label: r.label || `${normalised.length} subagents`,
|
||||||
|
path,
|
||||||
|
sessionId: r.session_id ?? null,
|
||||||
|
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
|
||||||
|
subagents: normalised
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||||
|
$spawnHistory.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSubagent(raw: unknown): SubagentProgress {
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
|
||||||
|
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
|
||||||
|
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiCalls: n(o.apiCalls),
|
||||||
|
costUsd: n(o.costUsd),
|
||||||
|
depth: typeof o.depth === 'number' ? o.depth : 0,
|
||||||
|
durationSeconds: n(o.durationSeconds),
|
||||||
|
filesRead: arr<string>(o.filesRead),
|
||||||
|
filesWritten: arr<string>(o.filesWritten),
|
||||||
|
goal: s(o.goal) ?? 'subagent',
|
||||||
|
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
index: typeof o.index === 'number' ? o.index : 0,
|
||||||
|
inputTokens: n(o.inputTokens),
|
||||||
|
iteration: n(o.iteration),
|
||||||
|
model: s(o.model),
|
||||||
|
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
|
||||||
|
outputTokens: n(o.outputTokens),
|
||||||
|
parentId: s(o.parentId) ?? null,
|
||||||
|
reasoningTokens: n(o.reasoningTokens),
|
||||||
|
startedAt: n(o.startedAt),
|
||||||
|
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
|
||||||
|
summary: s(o.summary),
|
||||||
|
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
||||||
|
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
|
||||||
|
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
toolsets: arr<string>(o.toolsets)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
} from '../lib/text.js'
|
} from '../lib/text.js'
|
||||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
import { resetOverlayState } from './overlayStore.js'
|
import { resetFlowOverlays } from './overlayStore.js'
|
||||||
import { patchTurnState, resetTurnState } from './turnStore.js'
|
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||||
|
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||||
import { getUiState, patchUiState } from './uiStore.js'
|
import { getUiState, patchUiState } from './uiStore.js'
|
||||||
|
|
||||||
const INTERRUPT_COOLDOWN_MS = 1500
|
const INTERRUPT_COOLDOWN_MS = 1500
|
||||||
@@ -41,6 +42,7 @@ class TurnController {
|
|||||||
lastStatusNote = ''
|
lastStatusNote = ''
|
||||||
pendingInlineDiffs: string[] = []
|
pendingInlineDiffs: string[] = []
|
||||||
persistedToolLabels = new Set<string>()
|
persistedToolLabels = new Set<string>()
|
||||||
|
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||||
protocolWarned = false
|
protocolWarned = false
|
||||||
reasoningText = ''
|
reasoningText = ''
|
||||||
segmentMessages: Msg[] = []
|
segmentMessages: Msg[] = []
|
||||||
@@ -90,7 +92,7 @@ class TurnController {
|
|||||||
turnTrail: []
|
turnTrail: []
|
||||||
})
|
})
|
||||||
patchUiState({ busy: false })
|
patchUiState({ busy: false })
|
||||||
resetOverlayState()
|
resetFlowOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
||||||
@@ -189,9 +191,7 @@ class TurnController {
|
|||||||
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
||||||
// terminal printer). That header only makes sense as stdout dressing,
|
// terminal printer). That header only makes sense as stdout dressing,
|
||||||
// not inside a markdown ```diff block.
|
// not inside a markdown ```diff block.
|
||||||
const text = diffText
|
const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
||||||
.replace(/^\s*┊[^\n]*\n?/, '')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
if (!text || this.pendingInlineDiffs.includes(text)) {
|
||||||
return
|
return
|
||||||
@@ -249,12 +249,15 @@ class TurnController {
|
|||||||
// markdown fence of its own — otherwise we render two stacked diff
|
// markdown fence of its own — otherwise we render two stacked diff
|
||||||
// blocks for the same edit.
|
// blocks for the same edit.
|
||||||
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
||||||
|
|
||||||
const remainingInlineDiffs = assistantAlreadyHasDiff
|
const remainingInlineDiffs = assistantAlreadyHasDiff
|
||||||
? []
|
? []
|
||||||
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
||||||
|
|
||||||
const inlineDiffBlock = remainingInlineDiffs.length
|
const inlineDiffBlock = remainingInlineDiffs.length
|
||||||
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
||||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||||
@@ -276,6 +279,20 @@ class TurnController {
|
|||||||
|
|
||||||
const wasInterrupted = this.interrupted
|
const wasInterrupted = this.interrupted
|
||||||
|
|
||||||
|
// Archive the turn's spawn tree to history BEFORE idle() drops subagents
|
||||||
|
// from turnState. Lets /replay and the overlay's history nav pull up
|
||||||
|
// finished fan-outs without a round-trip to disk.
|
||||||
|
const finishedSubagents = getTurnState().subagents
|
||||||
|
const sessionId = getUiState().sid
|
||||||
|
|
||||||
|
if (finishedSubagents.length > 0) {
|
||||||
|
pushSnapshot(finishedSubagents, { sessionId, startedAt: null })
|
||||||
|
// Fire-and-forget disk persistence so /replay survives process restarts.
|
||||||
|
// The same snapshot lives in memory via spawnHistoryStore for immediate
|
||||||
|
// recall — disk is the long-term archive.
|
||||||
|
void this.persistSpawnTree?.(finishedSubagents, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
this.idle()
|
this.idle()
|
||||||
this.clearReasoning()
|
this.clearReasoning()
|
||||||
this.turnTools = []
|
this.turnTools = []
|
||||||
@@ -443,33 +460,82 @@ class TurnController {
|
|||||||
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
|
upsertSubagent(
|
||||||
const id = `sa:${p.task_index}:${p.goal || 'subagent'}`
|
p: SubagentEventPayload,
|
||||||
|
patch: (current: SubagentProgress) => Partial<SubagentProgress>,
|
||||||
|
opts: { createIfMissing?: boolean } = { createIfMissing: true }
|
||||||
|
) {
|
||||||
|
// Stable id: prefer the server-issued subagent_id (survives nested
|
||||||
|
// grandchildren + cross-tree joins). Fall back to the composite key
|
||||||
|
// for older gateways that omit the field — those produce a flat list.
|
||||||
|
const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}`
|
||||||
|
|
||||||
patchTurnState(state => {
|
patchTurnState(state => {
|
||||||
const existing = state.subagents.find(item => item.id === id)
|
const existing = state.subagents.find(item => item.id === id)
|
||||||
|
|
||||||
|
// Late events (subagent.complete/tool/progress arriving after message.complete
|
||||||
|
// has already fired idle()) would otherwise resurrect a finished
|
||||||
|
// subagent into turn.subagents and block the "finished" title on the
|
||||||
|
// /agents overlay. When `createIfMissing` is false we drop silently.
|
||||||
|
if (!existing && !opts.createIfMissing) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
const base: SubagentProgress = existing ?? {
|
const base: SubagentProgress = existing ?? {
|
||||||
|
depth: p.depth ?? 0,
|
||||||
goal: p.goal,
|
goal: p.goal,
|
||||||
id,
|
id,
|
||||||
index: p.task_index,
|
index: p.task_index,
|
||||||
|
model: p.model,
|
||||||
notes: [],
|
notes: [],
|
||||||
|
parentId: p.parent_id ?? null,
|
||||||
|
startedAt: Date.now(),
|
||||||
status: 'running',
|
status: 'running',
|
||||||
taskCount: p.task_count ?? 1,
|
taskCount: p.task_count ?? 1,
|
||||||
thinking: [],
|
thinking: [],
|
||||||
tools: []
|
toolCount: p.tool_count ?? 0,
|
||||||
|
tools: [],
|
||||||
|
toolsets: p.toolsets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map snake_case payload keys onto camelCase state. Only overwrite
|
||||||
|
// when the event actually carries the field; `??` preserves prior
|
||||||
|
// values across streaming events that emit partial payloads.
|
||||||
|
const outputTail = p.output_tail
|
||||||
|
? p.output_tail.map(e => ({
|
||||||
|
isError: Boolean(e.is_error),
|
||||||
|
preview: String(e.preview ?? ''),
|
||||||
|
tool: String(e.tool ?? 'tool')
|
||||||
|
}))
|
||||||
|
: base.outputTail
|
||||||
|
|
||||||
const next: SubagentProgress = {
|
const next: SubagentProgress = {
|
||||||
...base,
|
...base,
|
||||||
|
apiCalls: p.api_calls ?? base.apiCalls,
|
||||||
|
costUsd: p.cost_usd ?? base.costUsd,
|
||||||
|
depth: p.depth ?? base.depth,
|
||||||
|
filesRead: p.files_read ?? base.filesRead,
|
||||||
|
filesWritten: p.files_written ?? base.filesWritten,
|
||||||
goal: p.goal || base.goal,
|
goal: p.goal || base.goal,
|
||||||
|
inputTokens: p.input_tokens ?? base.inputTokens,
|
||||||
|
iteration: p.iteration ?? base.iteration,
|
||||||
|
model: p.model ?? base.model,
|
||||||
|
outputTail,
|
||||||
|
outputTokens: p.output_tokens ?? base.outputTokens,
|
||||||
|
parentId: p.parent_id ?? base.parentId,
|
||||||
|
reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens,
|
||||||
taskCount: p.task_count ?? base.taskCount,
|
taskCount: p.task_count ?? base.taskCount,
|
||||||
|
toolCount: p.tool_count ?? base.toolCount,
|
||||||
|
toolsets: p.toolsets ?? base.toolsets,
|
||||||
...patch(base)
|
...patch(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable order: by spawn (depth, parent, index) rather than insert time.
|
||||||
|
// Without it, grandchildren can shuffle relative to siblings when
|
||||||
|
// events arrive out of order under high concurrency.
|
||||||
const subagents = existing
|
const subagents = existing
|
||||||
? state.subagents.map(item => (item.id === id ? next : item))
|
? state.subagents.map(item => (item.id === id ? next : item))
|
||||||
: [...state.subagents, next].sort((a, b) => a.index - b.index)
|
: [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index)
|
||||||
|
|
||||||
return { ...state, subagents }
|
return { ...state, subagents }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
if (overlay.picker) {
|
if (overlay.picker) {
|
||||||
return patchOverlayState({ picker: false })
|
return patchOverlayState({ picker: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overlay.agents) {
|
||||||
|
return patchOverlayState({ agents: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleQueue = (dir: 1 | -1) => {
|
const cycleQueue = (dir: 1 | -1) => {
|
||||||
@@ -180,6 +184,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
if (isCtrl(key, ch, 'c')) {
|
if (isCtrl(key, ch, 'c')) {
|
||||||
cancelOverlayFromCtrlC()
|
cancelOverlayFromCtrlC()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +295,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
if (key.upArrow && !cState.inputBuf.length) {
|
if (key.upArrow && !cState.inputBuf.length) {
|
||||||
const inputSel = getInputSelection()
|
const inputSel = getInputSelection()
|
||||||
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
||||||
|
|
||||||
const noLineAbove =
|
const noLineAbove =
|
||||||
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
||||||
|
|
||||||
|
|||||||
1064
ui-tui/src/components/agentsOverlay.tsx
Normal file
1064
ui-tui/src/components/agentsOverlay.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,14 @@
|
|||||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
import { $delegationState } from '../app/delegationStore.js'
|
||||||
|
import { $turnState } from '../app/turnStore.js'
|
||||||
import { FACES } from '../content/faces.js'
|
import { FACES } from '../content/faces.js'
|
||||||
import { VERBS } from '../content/verbs.js'
|
import { VERBS } from '../content/verbs.js'
|
||||||
import { fmtDuration } from '../domain/messages.js'
|
import { fmtDuration } from '../domain/messages.js'
|
||||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||||
|
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
||||||
import { fmtK } from '../lib/text.js'
|
import { fmtK } from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { Msg, Usage } from '../types.js'
|
import type { Msg, Usage } from '../types.js'
|
||||||
@@ -60,6 +64,67 @@ function ctxBar(pct: number | undefined, w = 10) {
|
|||||||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpawnHud({ t }: { t: Theme }) {
|
||||||
|
// Tight HUD that only appears when the session is actually fanning out.
|
||||||
|
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||||
|
const delegation = useStore($delegationState)
|
||||||
|
const turn = useStore($turnState)
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
|
||||||
|
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||||
|
|
||||||
|
if (!totals.descendantCount && !delegation.paused) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDepth = delegation.maxSpawnDepth
|
||||||
|
const maxConc = delegation.maxConcurrentChildren
|
||||||
|
const depth = Math.max(0, totals.maxDepthFromHere)
|
||||||
|
const active = totals.activeCount
|
||||||
|
|
||||||
|
// `max_concurrent_children` is a per-parent cap, not a global one.
|
||||||
|
// `activeCount` sums every running agent across the tree and would
|
||||||
|
// over-warn for multi-orchestrator runs. The widest level of the tree
|
||||||
|
// is a closer proxy to "most concurrent spawns that could be hitting a
|
||||||
|
// single parent's slot budget".
|
||||||
|
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
|
||||||
|
const depthRatio = maxDepth ? depth / maxDepth : 0
|
||||||
|
const concRatio = maxConc ? widestLevel / maxConc : 0
|
||||||
|
const ratio = Math.max(depthRatio, concRatio)
|
||||||
|
|
||||||
|
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||||
|
|
||||||
|
const pieces: string[] = []
|
||||||
|
|
||||||
|
if (delegation.paused) {
|
||||||
|
pieces.push('⏸ paused')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.descendantCount > 0) {
|
||||||
|
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
|
||||||
|
pieces.push(`d${depthLabel}`)
|
||||||
|
|
||||||
|
if (active > 0) {
|
||||||
|
// Label pairs the widest-level count (drives concRatio above) with
|
||||||
|
// the total active count for context. `W/cap` triggers the warn,
|
||||||
|
// `+N` is everything else currently running across the tree.
|
||||||
|
const extra = Math.max(0, active - widestLevel)
|
||||||
|
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
|
||||||
|
const suffix = extra > 0 ? `+${extra}` : ''
|
||||||
|
pieces.push(`⚡${widthLabel}${suffix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const atCap = depthRatio >= 1 || concRatio >= 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={color}>
|
||||||
|
{atCap ? ' │ ⚠ ' : ' │ '}
|
||||||
|
{pieces.join(' ')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
@@ -145,6 +210,7 @@ export function StatusRule({
|
|||||||
<SessionDuration startedAt={sessionStartedAt} />
|
<SessionDuration startedAt={sessionStartedAt} />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SpawnHud t={t} />
|
||||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
|||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import { useGateway } from '../app/gatewayContext.js'
|
||||||
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
||||||
import { $isBlocked } from '../app/overlayStore.js'
|
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { DetailsMode } from '../types.js'
|
import type { DetailsMode } from '../types.js'
|
||||||
|
|
||||||
|
import { AgentsOverlay } from './agentsOverlay.js'
|
||||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||||
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
||||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||||
@@ -256,6 +258,21 @@ const ComposerPane = memo(function ComposerPane({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
||||||
|
const { gw } = useGateway()
|
||||||
|
const ui = useStore($uiState)
|
||||||
|
const overlay = useStore($overlayState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentsOverlay
|
||||||
|
gw={gw}
|
||||||
|
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
|
||||||
|
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
|
||||||
|
t={ui.theme}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export const AppLayout = memo(function AppLayout({
|
export const AppLayout = memo(function AppLayout({
|
||||||
actions,
|
actions,
|
||||||
composer,
|
composer,
|
||||||
@@ -264,22 +281,30 @@ export const AppLayout = memo(function AppLayout({
|
|||||||
status,
|
status,
|
||||||
transcript
|
transcript
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
|
const overlay = useStore($overlayState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlternateScreen mouseTracking={mouseTracking}>
|
<AlternateScreen mouseTracking={mouseTracking}>
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<Box flexDirection="row" flexGrow={1}>
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
{overlay.agents ? (
|
||||||
|
<AgentsOverlayPane />
|
||||||
|
) : (
|
||||||
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PromptZone
|
{!overlay.agents && (
|
||||||
cols={composer.cols}
|
<PromptZone
|
||||||
onApprovalChoice={actions.answerApproval}
|
cols={composer.cols}
|
||||||
onClarifyAnswer={actions.answerClarify}
|
onApprovalChoice={actions.answerApproval}
|
||||||
onSecretSubmit={actions.answerSecret}
|
onClarifyAnswer={actions.answerClarify}
|
||||||
onSudoSubmit={actions.answerSudo}
|
onSecretSubmit={actions.answerSecret}
|
||||||
/>
|
onSudoSubmit={actions.answerSudo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||||
</Box>
|
</Box>
|
||||||
</AlternateScreen>
|
</AlternateScreen>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -615,14 +615,7 @@ export function TextInput({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) {
|
||||||
(k.ctrl && inp === 'c') ||
|
|
||||||
k.tab ||
|
|
||||||
(k.shift && k.tab) ||
|
|
||||||
k.pageUp ||
|
|
||||||
k.pageDown ||
|
|
||||||
k.escape
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { Box, NoSelect, Text } from '@hermes/ink'
|
import { Box, NoSelect, Text } from '@hermes/ink'
|
||||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||||
|
|
||||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||||
|
import {
|
||||||
|
buildSubagentTree,
|
||||||
|
fmtCost,
|
||||||
|
fmtTokens,
|
||||||
|
formatSummary as formatSpawnSummary,
|
||||||
|
hotnessBucket,
|
||||||
|
peakHotness,
|
||||||
|
sparkline,
|
||||||
|
treeTotals,
|
||||||
|
widthByDepth
|
||||||
|
} from '../lib/subagentTree.js'
|
||||||
import {
|
import {
|
||||||
compactPreview,
|
compactPreview,
|
||||||
estimateTokensRough,
|
estimateTokensRough,
|
||||||
@@ -14,7 +25,7 @@ import {
|
|||||||
toolTrailLabel
|
toolTrailLabel
|
||||||
} from '../lib/text.js'
|
} from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
|
import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||||
|
|
||||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||||
@@ -106,6 +117,8 @@ function TreeNode({
|
|||||||
header,
|
header,
|
||||||
open,
|
open,
|
||||||
rails = [],
|
rails = [],
|
||||||
|
stemColor,
|
||||||
|
stemDim,
|
||||||
t
|
t
|
||||||
}: {
|
}: {
|
||||||
branch: TreeBranch
|
branch: TreeBranch
|
||||||
@@ -113,11 +126,13 @@ function TreeNode({
|
|||||||
header: ReactNode
|
header: ReactNode
|
||||||
open: boolean
|
open: boolean
|
||||||
rails?: TreeRails
|
rails?: TreeRails
|
||||||
|
stemColor?: string
|
||||||
|
stemDim?: boolean
|
||||||
t: Theme
|
t: Theme
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<TreeRow branch={branch} rails={rails} t={t}>
|
<TreeRow branch={branch} rails={rails} stemColor={stemColor} stemDim={stemDim} t={t}>
|
||||||
{header}
|
{header}
|
||||||
</TreeRow>
|
</TreeRow>
|
||||||
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
||||||
@@ -239,16 +254,31 @@ function Chevron({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||||||
|
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||||||
|
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||||
|
|
||||||
|
// Below the median bucket we keep the default dim stem so cool branches
|
||||||
|
// fade into the chrome — only "hot" branches draw the eye.
|
||||||
|
if (idx < 2) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette[idx]
|
||||||
|
}
|
||||||
|
|
||||||
function SubagentAccordion({
|
function SubagentAccordion({
|
||||||
branch,
|
branch,
|
||||||
expanded,
|
expanded,
|
||||||
item,
|
node,
|
||||||
|
peak,
|
||||||
rails = [],
|
rails = [],
|
||||||
t
|
t
|
||||||
}: {
|
}: {
|
||||||
branch: TreeBranch
|
branch: TreeBranch
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
item: SubagentProgress
|
node: SubagentNode
|
||||||
|
peak: number
|
||||||
rails?: TreeRails
|
rails?: TreeRails
|
||||||
t: Theme
|
t: Theme
|
||||||
}) {
|
}) {
|
||||||
@@ -257,6 +287,7 @@ function SubagentAccordion({
|
|||||||
const [openThinking, setOpenThinking] = useState(expanded)
|
const [openThinking, setOpenThinking] = useState(expanded)
|
||||||
const [openTools, setOpenTools] = useState(expanded)
|
const [openTools, setOpenTools] = useState(expanded)
|
||||||
const [openNotes, setOpenNotes] = useState(expanded)
|
const [openNotes, setOpenNotes] = useState(expanded)
|
||||||
|
const [openKids, setOpenKids] = useState(expanded)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
@@ -268,6 +299,7 @@ function SubagentAccordion({
|
|||||||
setOpenThinking(true)
|
setOpenThinking(true)
|
||||||
setOpenTools(true)
|
setOpenTools(true)
|
||||||
setOpenNotes(true)
|
setOpenNotes(true)
|
||||||
|
setOpenKids(true)
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
@@ -276,8 +308,13 @@ function SubagentAccordion({
|
|||||||
setOpenThinking(true)
|
setOpenThinking(true)
|
||||||
setOpenTools(true)
|
setOpenTools(true)
|
||||||
setOpenNotes(true)
|
setOpenNotes(true)
|
||||||
|
setOpenKids(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const item = node.item
|
||||||
|
const children = node.children
|
||||||
|
const aggregate = node.aggregate
|
||||||
|
|
||||||
const statusTone: 'dim' | 'error' | 'warn' =
|
const statusTone: 'dim' | 'error' | 'warn' =
|
||||||
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
||||||
|
|
||||||
@@ -286,10 +323,60 @@ function SubagentAccordion({
|
|||||||
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
|
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
|
||||||
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
||||||
|
|
||||||
const suffix =
|
// Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost.
|
||||||
item.status === 'running'
|
// Emphasises the numbers the user can't easily eyeball from a flat list.
|
||||||
? 'running'
|
const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status)
|
||||||
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
|
|
||||||
|
const rollupBits: string[] = [statusLabel]
|
||||||
|
|
||||||
|
if (item.durationSeconds) {
|
||||||
|
rollupBits.push(fmtElapsed(item.durationSeconds * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTools = item.toolCount ?? 0
|
||||||
|
const subtreeTools = aggregate.totalTools - localTools
|
||||||
|
|
||||||
|
if (localTools > 0) {
|
||||||
|
rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0)
|
||||||
|
|
||||||
|
if (localTokens > 0) {
|
||||||
|
rollupBits.push(`${fmtTokens(localTokens)} tok`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localCost = item.costUsd ?? 0
|
||||||
|
|
||||||
|
if (localCost > 0) {
|
||||||
|
rollupBits.push(fmtCost(localCost))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0)
|
||||||
|
|
||||||
|
if (filesLocal > 0) {
|
||||||
|
rollupBits.push(`⎘${filesLocal}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
rollupBits.push(`${aggregate.descendantCount}↓`)
|
||||||
|
|
||||||
|
if (subtreeTools > 0) {
|
||||||
|
rollupBits.push(`+${subtreeTools}t sub`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subCost = aggregate.costUsd - localCost
|
||||||
|
|
||||||
|
if (subCost >= 0.01) {
|
||||||
|
rollupBits.push(`+${fmtCost(subCost)} sub`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregate.activeCount > 0 && item.status !== 'running') {
|
||||||
|
rollupBits.push(`⚡${aggregate.activeCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = rollupBits.join(' · ')
|
||||||
|
|
||||||
const thinkingText = item.thinking.join('\n')
|
const thinkingText = item.thinking.join('\n')
|
||||||
const hasThinking = Boolean(thinkingText)
|
const hasThinking = Boolean(thinkingText)
|
||||||
@@ -418,6 +505,50 @@ function SubagentAccordion({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
// Nested grandchildren — rendered recursively via SubagentAccordion,
|
||||||
|
// sharing the same keybindings / expand semantics as top-level nodes.
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={children.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenKids(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={showChildren || openKids}
|
||||||
|
suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`}
|
||||||
|
t={t}
|
||||||
|
title="Spawned"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'subagents',
|
||||||
|
open: showChildren || openKids,
|
||||||
|
render: childRails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{children.map((child, i) => (
|
||||||
|
<SubagentAccordion
|
||||||
|
branch={i === children.length - 1 ? 'last' : 'mid'}
|
||||||
|
expanded={expanded || deep}
|
||||||
|
key={child.item.id}
|
||||||
|
node={child}
|
||||||
|
peak={peak}
|
||||||
|
rails={childRails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap: amber→error gradient on the stem when this branch is "hot"
|
||||||
|
// (high tools/sec) relative to the whole tree's peak.
|
||||||
|
const stem = heatColor(node, peak, t)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
branch={branch}
|
branch={branch}
|
||||||
@@ -447,6 +578,8 @@ function SubagentAccordion({
|
|||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
rails={rails}
|
rails={rails}
|
||||||
|
stemColor={stem}
|
||||||
|
stemDim={stem == null}
|
||||||
t={t}
|
t={t}
|
||||||
>
|
>
|
||||||
{childRails => (
|
{childRails => (
|
||||||
@@ -598,6 +731,16 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||||
|
|
||||||
|
// Spawn-tree derivations must live above any early return so React's
|
||||||
|
// rules-of-hooks sees a stable call order. Cheap O(N) builds memoised
|
||||||
|
// by subagent-list identity.
|
||||||
|
const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||||||
|
const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree])
|
||||||
|
const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree])
|
||||||
|
const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree])
|
||||||
|
const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths])
|
||||||
|
const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!busy &&
|
!busy &&
|
||||||
!trail.length &&
|
!trail.length &&
|
||||||
@@ -753,12 +896,13 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
|
|
||||||
const renderSubagentList = (rails: boolean[]) => (
|
const renderSubagentList = (rails: boolean[]) => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{subagents.map((item, index) => (
|
{spawnTree.map((node, index) => (
|
||||||
<SubagentAccordion
|
<SubagentAccordion
|
||||||
branch={index === subagents.length - 1 ? 'last' : 'mid'}
|
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||||||
expanded={detailsMode === 'expanded' || deepSubagents}
|
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||||
item={item}
|
key={node.item.id}
|
||||||
key={item.id}
|
node={node}
|
||||||
|
peak={spawnPeak}
|
||||||
rails={rails}
|
rails={rails}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
@@ -881,10 +1025,14 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasSubagents && !inlineDelegateKey) {
|
if (hasSubagents && !inlineDelegateKey) {
|
||||||
|
// Spark + summary give a one-line read on the branch shape before
|
||||||
|
// opening the subtree. `/agents` opens the full-screen audit overlay.
|
||||||
|
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
header: (
|
header: (
|
||||||
<Chevron
|
<Chevron
|
||||||
count={subagents.length}
|
count={spawnTotals.descendantCount}
|
||||||
onClick={shift => {
|
onClick={shift => {
|
||||||
if (shift) {
|
if (shift) {
|
||||||
expandAll()
|
expandAll()
|
||||||
@@ -895,8 +1043,9 @@ export const ToolTrail = memo(function ToolTrail({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={detailsMode === 'expanded' || openSubagents}
|
open={detailsMode === 'expanded' || openSubagents}
|
||||||
|
suffix={suffix}
|
||||||
t={t}
|
t={t}
|
||||||
title="Subagents"
|
title="Spawn tree"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
key: 'subagents',
|
key: 'subagents',
|
||||||
|
|||||||
@@ -280,15 +280,85 @@ export interface ReloadMcpResponse {
|
|||||||
// ── Subagent events ──────────────────────────────────────────────────
|
// ── Subagent events ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SubagentEventPayload {
|
export interface SubagentEventPayload {
|
||||||
|
api_calls?: number
|
||||||
|
cost_usd?: number
|
||||||
|
depth?: number
|
||||||
duration_seconds?: number
|
duration_seconds?: number
|
||||||
|
files_read?: string[]
|
||||||
|
files_written?: string[]
|
||||||
goal: string
|
goal: string
|
||||||
status?: 'completed' | 'failed' | 'interrupted' | 'running'
|
input_tokens?: number
|
||||||
|
iteration?: number
|
||||||
|
model?: string
|
||||||
|
output_tail?: { is_error?: boolean; preview?: string; tool?: string }[]
|
||||||
|
output_tokens?: number
|
||||||
|
parent_id?: null | string
|
||||||
|
reasoning_tokens?: number
|
||||||
|
status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||||
|
subagent_id?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
task_count?: number
|
task_count?: number
|
||||||
task_index: number
|
task_index: number
|
||||||
text?: string
|
text?: string
|
||||||
|
tool_count?: number
|
||||||
tool_name?: string
|
tool_name?: string
|
||||||
tool_preview?: string
|
tool_preview?: string
|
||||||
|
toolsets?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delegation control RPCs ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DelegationStatusResponse {
|
||||||
|
active?: {
|
||||||
|
depth?: number
|
||||||
|
goal?: string
|
||||||
|
model?: null | string
|
||||||
|
parent_id?: null | string
|
||||||
|
started_at?: number
|
||||||
|
status?: string
|
||||||
|
subagent_id?: string
|
||||||
|
tool_count?: number
|
||||||
|
}[]
|
||||||
|
max_concurrent_children?: number
|
||||||
|
max_spawn_depth?: number
|
||||||
|
paused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegationPauseResponse {
|
||||||
|
paused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentInterruptResponse {
|
||||||
|
found?: boolean
|
||||||
|
subagent_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spawn-tree snapshots ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SpawnTreeListEntry {
|
||||||
|
count: number
|
||||||
|
finished_at?: number
|
||||||
|
label?: string
|
||||||
|
path: string
|
||||||
|
session_id?: string
|
||||||
|
started_at?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeListResponse {
|
||||||
|
entries?: SpawnTreeListEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeLoadResponse {
|
||||||
|
finished_at?: number
|
||||||
|
label?: string
|
||||||
|
session_id?: string
|
||||||
|
started_at?: null | number
|
||||||
|
subagents?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeSaveResponse {
|
||||||
|
path?: string
|
||||||
|
session_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GatewayEvent =
|
export type GatewayEvent =
|
||||||
@@ -320,6 +390,7 @@ export type GatewayEvent =
|
|||||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||||
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
||||||
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
||||||
|
|||||||
355
ui-tui/src/lib/subagentTree.ts
Normal file
355
ui-tui/src/lib/subagentTree.ts
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
const ROOT_KEY = '__root__'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the subagent spawn tree from a flat event-ordered list.
|
||||||
|
*
|
||||||
|
* Grouping is by `parentId`; a missing `parentId` (or one pointing at an
|
||||||
|
* unknown subagent) is treated as a top-level spawn of the current turn.
|
||||||
|
* Children within a parent are sorted by `depth` then `index` — same key
|
||||||
|
* used in `turnController.upsertSubagent`, so render order matches spawn
|
||||||
|
* order regardless of network reordering of gateway events.
|
||||||
|
*
|
||||||
|
* Older gateways omit `parentId`; every subagent is then a top-level node
|
||||||
|
* and the tree renders flat — matching pre-observability behaviour.
|
||||||
|
*/
|
||||||
|
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
||||||
|
if (!items.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const byParent = new Map<string, SubagentProgress[]>()
|
||||||
|
const known = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
known.add(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY
|
||||||
|
const bucket = byParent.get(parentKey) ?? []
|
||||||
|
bucket.push(item)
|
||||||
|
byParent.set(parentKey, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of byParent.values()) {
|
||||||
|
bucket.sort((a, b) => a.depth - b.depth || a.index - b.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (item: SubagentProgress): SubagentNode => {
|
||||||
|
const kids = byParent.get(item.id) ?? []
|
||||||
|
const children = kids.map(build)
|
||||||
|
|
||||||
|
return { aggregate: aggregate(item, children), children, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (byParent.get(ROOT_KEY) ?? []).map(build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll up counts for a node's whole subtree. Kept pure so the live view
|
||||||
|
* and the post-hoc replay can share the same renderer unchanged.
|
||||||
|
*
|
||||||
|
* `hotness` = tools per second across the subtree — a crude proxy for
|
||||||
|
* "how much work is happening in this branch". Used to colour tree rails
|
||||||
|
* in the overlay / inline view so the eye spots the expensive branch.
|
||||||
|
*/
|
||||||
|
export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate {
|
||||||
|
let totalTools = item.toolCount ?? 0
|
||||||
|
let totalDuration = item.durationSeconds ?? 0
|
||||||
|
let descendantCount = 0
|
||||||
|
let activeCount = isRunning(item) ? 1 : 0
|
||||||
|
let maxDepthFromHere = 0
|
||||||
|
let inputTokens = item.inputTokens ?? 0
|
||||||
|
let outputTokens = item.outputTokens ?? 0
|
||||||
|
let costUsd = item.costUsd ?? 0
|
||||||
|
let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0)
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
totalTools += child.aggregate.totalTools
|
||||||
|
totalDuration += child.aggregate.totalDuration
|
||||||
|
descendantCount += child.aggregate.descendantCount + 1
|
||||||
|
activeCount += child.aggregate.activeCount
|
||||||
|
maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1)
|
||||||
|
inputTokens += child.aggregate.inputTokens
|
||||||
|
outputTokens += child.aggregate.outputTokens
|
||||||
|
costUsd += child.aggregate.costUsd
|
||||||
|
filesTouched += child.aggregate.filesTouched
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount,
|
||||||
|
costUsd,
|
||||||
|
descendantCount,
|
||||||
|
filesTouched,
|
||||||
|
hotness,
|
||||||
|
inputTokens,
|
||||||
|
maxDepthFromHere,
|
||||||
|
outputTokens,
|
||||||
|
totalDuration,
|
||||||
|
totalTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of subagents at each depth level, indexed by depth (0 = top level).
|
||||||
|
* Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD.
|
||||||
|
*/
|
||||||
|
export function widthByDepth(tree: readonly SubagentNode[]): number[] {
|
||||||
|
const widths: number[] = []
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[], depth: number) => {
|
||||||
|
if (!nodes.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widths[depth] = (widths[depth] ?? 0) + nodes.length
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
walk(node.children, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree, 0)
|
||||||
|
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat totals across the full tree — feeds the summary chip header.
|
||||||
|
*/
|
||||||
|
export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate {
|
||||||
|
let totalTools = 0
|
||||||
|
let totalDuration = 0
|
||||||
|
let descendantCount = 0
|
||||||
|
let activeCount = 0
|
||||||
|
let maxDepthFromHere = 0
|
||||||
|
let inputTokens = 0
|
||||||
|
let outputTokens = 0
|
||||||
|
let costUsd = 0
|
||||||
|
let filesTouched = 0
|
||||||
|
|
||||||
|
for (const node of tree) {
|
||||||
|
totalTools += node.aggregate.totalTools
|
||||||
|
totalDuration += node.aggregate.totalDuration
|
||||||
|
descendantCount += node.aggregate.descendantCount + 1
|
||||||
|
activeCount += node.aggregate.activeCount
|
||||||
|
maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1)
|
||||||
|
inputTokens += node.aggregate.inputTokens
|
||||||
|
outputTokens += node.aggregate.outputTokens
|
||||||
|
costUsd += node.aggregate.costUsd
|
||||||
|
filesTouched += node.aggregate.filesTouched
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount,
|
||||||
|
costUsd,
|
||||||
|
descendantCount,
|
||||||
|
filesTouched,
|
||||||
|
hotness,
|
||||||
|
inputTokens,
|
||||||
|
maxDepthFromHere,
|
||||||
|
outputTokens,
|
||||||
|
totalDuration,
|
||||||
|
totalTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the tree into visit order — useful for keyboard navigation and
|
||||||
|
* for "kill subtree" walks that fire one RPC per descendant.
|
||||||
|
*/
|
||||||
|
export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] {
|
||||||
|
const out: SubagentNode[] = []
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
out.push(node)
|
||||||
|
walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every descendant's id for a given node (excluding the node itself).
|
||||||
|
*/
|
||||||
|
export function descendantIds(node: SubagentNode): string[] {
|
||||||
|
const ids: string[] = []
|
||||||
|
|
||||||
|
const walk = (children: readonly SubagentNode[]) => {
|
||||||
|
for (const child of children) {
|
||||||
|
ids.push(child.item.id)
|
||||||
|
walk(child.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(node.children)
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRunning(item: Pick<SubagentProgress, 'status'>): boolean {
|
||||||
|
return item.status === 'running' || item.status === 'queued'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8-step unicode bar sparkline from a positive-integer array. Zeroes render
|
||||||
|
* as spaces so a sparse tree doesn't read as equal activity at every depth.
|
||||||
|
*/
|
||||||
|
export function sparkline(values: readonly number[]): string {
|
||||||
|
if (!values.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...values)
|
||||||
|
|
||||||
|
if (max <= 0) {
|
||||||
|
return ' '.repeat(values.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
.map(v => {
|
||||||
|
if (v <= 0) {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1))))
|
||||||
|
|
||||||
|
return SPARK_RAMP[idx]
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s`
|
||||||
|
*/
|
||||||
|
export function formatSummary(totals: SubagentAggregate): string {
|
||||||
|
const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`]
|
||||||
|
pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`)
|
||||||
|
|
||||||
|
if (totals.totalTools > 0) {
|
||||||
|
pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.totalDuration > 0) {
|
||||||
|
pieces.push(fmtDuration(totals.totalDuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = totals.inputTokens + totals.outputTokens
|
||||||
|
|
||||||
|
if (tokens > 0) {
|
||||||
|
pieces.push(`${fmtTokens(tokens)} tok`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.costUsd > 0) {
|
||||||
|
pieces.push(fmtCost(totals.costUsd))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.activeCount > 0) {
|
||||||
|
pieces.push(`⚡${totals.activeCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */
|
||||||
|
export function fmtCost(usd: number): string {
|
||||||
|
if (!Number.isFinite(usd) || usd <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usd < 0.01) {
|
||||||
|
return '<$0.01'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usd < 10) {
|
||||||
|
return `$${usd.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `$${usd.toFixed(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact token count: `12k`, `1.2k`, `542`. */
|
||||||
|
export function fmtTokens(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
|
return '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n < 1000) {
|
||||||
|
return String(Math.round(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n < 10_000) {
|
||||||
|
return `${(n / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(n / 1000)}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents
|
||||||
|
* overlay so the timeline + list + summary all speak the same dialect.
|
||||||
|
*/
|
||||||
|
export function fmtDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.max(0, Math.round(seconds))}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.round(seconds - m * 60)
|
||||||
|
|
||||||
|
return s === 0 ? `${m}m` : `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subagent is top-level if it has no `parentId`, or its parent isn't in
|
||||||
|
* the same snapshot (orphaned by a pruned mid-flight root). Same rule
|
||||||
|
* `buildSubagentTree` uses — keep call sites consistent across the live
|
||||||
|
* view, disk label, and diff pane.
|
||||||
|
*/
|
||||||
|
export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] {
|
||||||
|
const ids = new Set(items.map(s => s.id))
|
||||||
|
|
||||||
|
return items.filter(s => !s.parentId || !ids.has(s.parentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a node's hotness into a palette index 0..N-1 where N = buckets.
|
||||||
|
* Higher hotness = "hotter" colour. Normalized against the tree's peak hotness
|
||||||
|
* so a uniformly slow tree still shows gradient across its busiest branches.
|
||||||
|
*/
|
||||||
|
export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number {
|
||||||
|
if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = Math.min(1, hotness / peakHotness)
|
||||||
|
|
||||||
|
return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peakHotness(tree: readonly SubagentNode[]): number {
|
||||||
|
let peak = 0
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
peak = Math.max(peak, node.aggregate.hotness)
|
||||||
|
walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree)
|
||||||
|
|
||||||
|
return peak
|
||||||
|
}
|
||||||
@@ -94,7 +94,12 @@ export const DARK_THEME: Theme = {
|
|||||||
amber: '#FFBF00',
|
amber: '#FFBF00',
|
||||||
bronze: '#CD7F32',
|
bronze: '#CD7F32',
|
||||||
cornsilk: '#FFF8DC',
|
cornsilk: '#FFF8DC',
|
||||||
dim: '#B8860B',
|
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
||||||
|
// read as barely-visible on dark terminals for long body text. The
|
||||||
|
// new value sits ~60% luminance — readable without losing the "muted /
|
||||||
|
// secondary" semantic. Field labels still use `label` (65%) which
|
||||||
|
// stays brighter so hierarchy holds.
|
||||||
|
dim: '#CC9B1F',
|
||||||
completionBg: '#FFFFFF',
|
completionBg: '#FFFFFF',
|
||||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||||
|
|
||||||
@@ -104,8 +109,11 @@ export const DARK_THEME: Theme = {
|
|||||||
warn: '#ffa726',
|
warn: '#ffa726',
|
||||||
|
|
||||||
prompt: '#FFF8DC',
|
prompt: '#FFF8DC',
|
||||||
sessionLabel: '#B8860B',
|
// sessionLabel/sessionBorder intentionally track the `dim` value — they
|
||||||
sessionBorder: '#B8860B',
|
// are "same role, same colour" by design. fromSkin's banner_dim fallback
|
||||||
|
// relies on this pairing (#11300).
|
||||||
|
sessionLabel: '#CC9B1F',
|
||||||
|
sessionBorder: '#CC9B1F',
|
||||||
|
|
||||||
statusBg: '#1a1a2e',
|
statusBg: '#1a1a2e',
|
||||||
statusFg: '#C0C0C0',
|
statusFg: '#C0C0C0',
|
||||||
|
|||||||
@@ -12,16 +12,72 @@ export interface ActivityItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentProgress {
|
export interface SubagentProgress {
|
||||||
|
apiCalls?: number
|
||||||
|
costUsd?: number
|
||||||
|
depth: number
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
|
filesRead?: string[]
|
||||||
|
filesWritten?: string[]
|
||||||
goal: string
|
goal: string
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: number
|
||||||
|
inputTokens?: number
|
||||||
|
iteration?: number
|
||||||
|
model?: string
|
||||||
notes: string[]
|
notes: string[]
|
||||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
outputTail?: SubagentOutputEntry[]
|
||||||
|
outputTokens?: number
|
||||||
|
parentId: null | string
|
||||||
|
reasoningTokens?: number
|
||||||
|
startedAt?: number
|
||||||
|
status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||||
summary?: string
|
summary?: string
|
||||||
taskCount: number
|
taskCount: number
|
||||||
thinking: string[]
|
thinking: string[]
|
||||||
|
toolCount: number
|
||||||
tools: string[]
|
tools: string[]
|
||||||
|
toolsets?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentOutputEntry {
|
||||||
|
isError: boolean
|
||||||
|
preview: string
|
||||||
|
tool: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentNode {
|
||||||
|
aggregate: SubagentAggregate
|
||||||
|
children: SubagentNode[]
|
||||||
|
item: SubagentProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentAggregate {
|
||||||
|
activeCount: number
|
||||||
|
costUsd: number
|
||||||
|
descendantCount: number
|
||||||
|
filesTouched: number
|
||||||
|
hotness: number
|
||||||
|
inputTokens: number
|
||||||
|
maxDepthFromHere: number
|
||||||
|
outputTokens: number
|
||||||
|
totalDuration: number
|
||||||
|
totalTools: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegationStatus {
|
||||||
|
active: {
|
||||||
|
depth?: number
|
||||||
|
goal?: string
|
||||||
|
model?: null | string
|
||||||
|
parent_id?: null | string
|
||||||
|
started_at?: number
|
||||||
|
status?: string
|
||||||
|
subagent_id?: string
|
||||||
|
tool_count?: number
|
||||||
|
}[]
|
||||||
|
max_concurrent_children?: number
|
||||||
|
max_spawn_depth?: number
|
||||||
|
paused: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalReq {
|
export interface ApprovalReq {
|
||||||
|
|||||||
Reference in New Issue
Block a user