chapterhouse 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/server.js +13 -6
- package/dist/copilot/orchestrator.js +3 -0
- package/dist/copilot/task-event-log.js +162 -0
- package/dist/copilot/task-event-log.test.js +275 -0
- package/package.json +1 -1
- package/web/dist/assets/index-BtAcw3EP.css +10 -0
- package/web/dist/assets/{index-0dDxvEWK.js → index-vL9s_H8H.js} +66 -64
- package/web/dist/assets/index-vL9s_H8H.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-0dDxvEWK.js.map +0 -1
- package/web/dist/assets/index-26ooi9MH.css +0 -10
package/dist/api/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { existsSync, statSync, readdirSync } from "fs";
|
|
|
5
5
|
import { join, dirname } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey
|
|
8
|
+
import { sendToOrchestrator, getAgentInfo, cancelCurrentMessage, getLastRouteResult, getCurrentSessionKey } from "../copilot/orchestrator.js";
|
|
9
9
|
import { squadEventBus } from "../copilot/squad-event-bus.js";
|
|
10
10
|
import { getAgentRegistry } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
@@ -21,6 +21,7 @@ import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
|
21
21
|
import { restartDaemon } from "../daemon.js";
|
|
22
22
|
import { API_TOKEN_PATH, resolveWikiRelativePath } from "../paths.js";
|
|
23
23
|
import { getDb, getSessionMessages, getTaskEvents, normalizeSqliteTsToIso } from "../store/db.js";
|
|
24
|
+
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
24
25
|
import { getStatus, onStatusChange } from "../status.js";
|
|
25
26
|
import { formatSseData, formatSseEvent } from "./sse.js";
|
|
26
27
|
import { syncDecisionsFileToWiki } from "../squad/mirror.js";
|
|
@@ -284,7 +285,9 @@ app.get("/api/workers/:taskId", (req, res) => {
|
|
|
284
285
|
completedAt: row.completed_at,
|
|
285
286
|
});
|
|
286
287
|
});
|
|
287
|
-
// Historical event log for a task (catch-up on page load)
|
|
288
|
+
// Historical event log for a task (catch-up on page load / SSE reconnect).
|
|
289
|
+
// Ring buffer (fast, in-memory) is checked first; falls back to SQLite for
|
|
290
|
+
// completed tasks whose buffer has been cleared.
|
|
288
291
|
app.get("/api/workers/:taskId/events", (req, res) => {
|
|
289
292
|
const taskId = req.params.taskId;
|
|
290
293
|
const afterSeqRaw = req.query.afterSeq;
|
|
@@ -295,10 +298,13 @@ app.get("/api/workers/:taskId/events", (req, res) => {
|
|
|
295
298
|
if (!taskRow) {
|
|
296
299
|
throw new NotFoundError("Task not found");
|
|
297
300
|
}
|
|
298
|
-
const
|
|
301
|
+
const ringEvents = getTaskLogEvents(taskId, afterSeq);
|
|
302
|
+
const events = ringEvents.length > 0 ? ringEvents : getTaskEvents(taskId, afterSeq);
|
|
299
303
|
res.json({ taskId, events });
|
|
300
304
|
});
|
|
301
|
-
// SSE stream for per-task live tool-call activity
|
|
305
|
+
// SSE stream for per-task live tool-call activity.
|
|
306
|
+
// Uses the ring-buffer subscriber (subscribeTaskLog) so events fire immediately
|
|
307
|
+
// from in-memory state rather than needing an extra SQLite read.
|
|
302
308
|
app.get("/api/workers/:taskId/events/stream", (req, res) => {
|
|
303
309
|
const taskId = req.params.taskId;
|
|
304
310
|
const taskRow = getDb()
|
|
@@ -314,8 +320,9 @@ app.get("/api/workers/:taskId/events/stream", (req, res) => {
|
|
|
314
320
|
});
|
|
315
321
|
res.write(formatSseData({ type: "connected", taskId }));
|
|
316
322
|
const heartbeat = setInterval(() => { res.write(`:ping\n\n`); }, 20_000);
|
|
317
|
-
|
|
318
|
-
|
|
323
|
+
// subscribeTaskLog delivers ring-buffer-sourced events as they arrive.
|
|
324
|
+
const unsub = subscribeTaskLog(taskId, (event) => {
|
|
325
|
+
res.write(formatSseData({ type: "task_event", ...event }));
|
|
319
326
|
});
|
|
320
327
|
req.on("close", () => {
|
|
321
328
|
clearInterval(heartbeat);
|
|
@@ -19,6 +19,7 @@ import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
|
|
|
19
19
|
import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
|
|
20
20
|
import { childLogger } from "../util/logger.js";
|
|
21
21
|
import { squadEventBus } from "./squad-event-bus.js";
|
|
22
|
+
import { initTaskEventLog } from "./task-event-log.js";
|
|
22
23
|
import { SessionManager, SessionRegistry, SESSION_IDLE_TTL_MS, SESSION_MAX_ACTIVE, } from "./session-manager.js";
|
|
23
24
|
const log = childLogger("orchestrator");
|
|
24
25
|
const MAX_RETRIES = 3;
|
|
@@ -299,6 +300,8 @@ export async function initOrchestrator(client) {
|
|
|
299
300
|
copilotClient = client;
|
|
300
301
|
// Initialize governance hook pipeline before any session is created.
|
|
301
302
|
initHookPipeline();
|
|
303
|
+
// Initialize per-task ring buffer — subscribes to squadEventBus for session:tool_call events.
|
|
304
|
+
initTaskEventLog();
|
|
302
305
|
// (Re-)create the registry — supports multiple initOrchestrator calls in tests
|
|
303
306
|
if (registry) {
|
|
304
307
|
await registry.shutdown();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task in-memory event ring buffer — #116.
|
|
3
|
+
*
|
|
4
|
+
* Maintains a `Map<taskId, RingBuffer<TaskEvent>>` in the daemon process,
|
|
5
|
+
* capped at 500 events per task. Subscribes to the process-wide
|
|
6
|
+
* `squadEventBus` for `session:tool_call` events so the ring buffer stays
|
|
7
|
+
* in sync without the caller needing to wire anything extra.
|
|
8
|
+
*
|
|
9
|
+
* Consumers:
|
|
10
|
+
* 1. `GET /api/workers/:taskId/events` — REST hydration on page-load / SSE reconnect.
|
|
11
|
+
* The ring buffer is checked first (fast path, in-memory); SQLite is the fallback
|
|
12
|
+
* for completed tasks whose ring buffer has been cleared.
|
|
13
|
+
* 2. Per-task SSE subscribers — `subscribeTaskLog` delivers events as they arrive
|
|
14
|
+
* so the SSE frame fires immediately (no SQLite round-trip).
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle:
|
|
17
|
+
* - `initTaskEventLog()` must be called once from `initOrchestrator()`.
|
|
18
|
+
* It returns an unsub function; call it on shutdown / test teardown.
|
|
19
|
+
* - `clearTaskLog(taskId)` should be called when a task is destroyed so the
|
|
20
|
+
* Map doesn't grow unbounded.
|
|
21
|
+
*
|
|
22
|
+
* @module copilot/task-event-log
|
|
23
|
+
*/
|
|
24
|
+
import { squadEventBus } from "./squad-event-bus.js";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Ring buffer
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
export const RING_BUFFER_CAPACITY = 500;
|
|
29
|
+
export class RingBuffer {
|
|
30
|
+
_capacity;
|
|
31
|
+
_items;
|
|
32
|
+
constructor(capacity) {
|
|
33
|
+
if (capacity < 1)
|
|
34
|
+
throw new RangeError("RingBuffer capacity must be >= 1");
|
|
35
|
+
this._capacity = capacity;
|
|
36
|
+
this._items = [];
|
|
37
|
+
}
|
|
38
|
+
push(item) {
|
|
39
|
+
if (this._items.length >= this._capacity) {
|
|
40
|
+
this._items.shift(); // evict oldest
|
|
41
|
+
}
|
|
42
|
+
this._items.push(item);
|
|
43
|
+
}
|
|
44
|
+
getAll() {
|
|
45
|
+
return this._items.slice();
|
|
46
|
+
}
|
|
47
|
+
get size() {
|
|
48
|
+
return this._items.length;
|
|
49
|
+
}
|
|
50
|
+
get capacity() {
|
|
51
|
+
return this._capacity;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Singleton state
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const taskBuffers = new Map();
|
|
58
|
+
const taskListeners = new Map();
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Internal helpers
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
function getOrCreateBuffer(taskId) {
|
|
63
|
+
let buf = taskBuffers.get(taskId);
|
|
64
|
+
if (!buf) {
|
|
65
|
+
buf = new RingBuffer(RING_BUFFER_CAPACITY);
|
|
66
|
+
taskBuffers.set(taskId, buf);
|
|
67
|
+
}
|
|
68
|
+
return buf;
|
|
69
|
+
}
|
|
70
|
+
function notifyListeners(taskId, event) {
|
|
71
|
+
const listeners = taskListeners.get(taskId);
|
|
72
|
+
if (!listeners)
|
|
73
|
+
return;
|
|
74
|
+
for (const fn of listeners) {
|
|
75
|
+
try {
|
|
76
|
+
fn(event);
|
|
77
|
+
}
|
|
78
|
+
catch { /* non-fatal */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Public API
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
/**
|
|
85
|
+
* Subscribe to the squadEventBus and maintain the ring buffer.
|
|
86
|
+
* Call once from initOrchestrator(). Returns an unsub / cleanup function.
|
|
87
|
+
*/
|
|
88
|
+
export function initTaskEventLog() {
|
|
89
|
+
const unsubToolCall = squadEventBus.subscribe("session:tool_call", (event) => {
|
|
90
|
+
const taskId = event.sessionId;
|
|
91
|
+
if (!taskId)
|
|
92
|
+
return;
|
|
93
|
+
const p = event.payload;
|
|
94
|
+
const taskEvent = {
|
|
95
|
+
id: 0, // not a DB row — id is meaningless for ring-buffer entries
|
|
96
|
+
taskId,
|
|
97
|
+
seq: p._seq ?? 0,
|
|
98
|
+
ts: p._ts ?? Date.now(),
|
|
99
|
+
kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
|
|
100
|
+
toolName: p.toolName ?? null,
|
|
101
|
+
summary: p._summary ?? null,
|
|
102
|
+
};
|
|
103
|
+
const buf = getOrCreateBuffer(taskId);
|
|
104
|
+
buf.push(taskEvent);
|
|
105
|
+
notifyListeners(taskId, taskEvent);
|
|
106
|
+
});
|
|
107
|
+
const unsubDestroyed = squadEventBus.subscribe("session:destroyed", (event) => {
|
|
108
|
+
if (event.sessionId)
|
|
109
|
+
clearTaskLog(event.sessionId);
|
|
110
|
+
});
|
|
111
|
+
return () => {
|
|
112
|
+
unsubToolCall();
|
|
113
|
+
unsubDestroyed();
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Return all ring-buffered events for a task, optionally filtered to seq > afterSeq.
|
|
118
|
+
* Returns an empty array if the task has no buffer (task unknown or already cleared).
|
|
119
|
+
*/
|
|
120
|
+
export function getTaskLogEvents(taskId, afterSeq = 0) {
|
|
121
|
+
const buf = taskBuffers.get(taskId);
|
|
122
|
+
if (!buf)
|
|
123
|
+
return [];
|
|
124
|
+
const all = buf.getAll();
|
|
125
|
+
return afterSeq === 0 ? all : all.filter((e) => e.seq > afterSeq);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Subscribe to live events for a specific task.
|
|
129
|
+
* Listener is called synchronously when a new event arrives (from the bus subscriber above).
|
|
130
|
+
* Returns an unsubscribe function.
|
|
131
|
+
*/
|
|
132
|
+
export function subscribeTaskLog(taskId, listener) {
|
|
133
|
+
let listeners = taskListeners.get(taskId);
|
|
134
|
+
if (!listeners) {
|
|
135
|
+
listeners = new Set();
|
|
136
|
+
taskListeners.set(taskId, listeners);
|
|
137
|
+
}
|
|
138
|
+
listeners.add(listener);
|
|
139
|
+
return () => {
|
|
140
|
+
const ls = taskListeners.get(taskId);
|
|
141
|
+
if (ls) {
|
|
142
|
+
ls.delete(listener);
|
|
143
|
+
if (ls.size === 0)
|
|
144
|
+
taskListeners.delete(taskId);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Remove the ring buffer and any listeners for a task.
|
|
150
|
+
* Called automatically when `session:destroyed` fires; also callable manually.
|
|
151
|
+
*/
|
|
152
|
+
export function clearTaskLog(taskId) {
|
|
153
|
+
taskBuffers.delete(taskId);
|
|
154
|
+
taskListeners.delete(taskId);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Number of tasks currently tracked. Useful for testing and diagnostics.
|
|
158
|
+
*/
|
|
159
|
+
export function taskLogSize() {
|
|
160
|
+
return taskBuffers.size;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=task-event-log.js.map
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/copilot/task-event-log.ts — #116
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. RingBuffer capacity and eviction
|
|
6
|
+
* 2. RingBuffer getAll returns a copy
|
|
7
|
+
* 3. Ring buffer singleton: bus events populate the buffer
|
|
8
|
+
* 4. getTaskLogEvents returns [] for unknown taskId
|
|
9
|
+
* 5. getTaskLogEvents filters by afterSeq
|
|
10
|
+
* 6. subscribeTaskLog delivers events as they arrive
|
|
11
|
+
* 7. subscribeTaskLog unsub stops delivery
|
|
12
|
+
* 8. clearTaskLog removes buffer and listeners
|
|
13
|
+
* 9. session:destroyed auto-clears the ring buffer
|
|
14
|
+
* 10. Multiple tasks tracked independently
|
|
15
|
+
* 11. RING_BUFFER_CAPACITY constant: 500 items, evicts oldest
|
|
16
|
+
*
|
|
17
|
+
* Uses node:test (same pattern as hooks.test.ts, agents.squad.test.ts) so the
|
|
18
|
+
* daemon's `npm test` runner (node --test) picks these up from dist/.
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { EventBus } from "@bradygaster/squad-sdk/runtime/event-bus";
|
|
23
|
+
import { RingBuffer, RING_BUFFER_CAPACITY, } from "./task-event-log.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Helpers
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
function makeToolCallEvent(opts) {
|
|
28
|
+
return {
|
|
29
|
+
type: "session:tool_call",
|
|
30
|
+
sessionId: opts.sessionId,
|
|
31
|
+
payload: {
|
|
32
|
+
toolName: opts.toolName ?? "bash",
|
|
33
|
+
toolArgs: {},
|
|
34
|
+
_kind: opts.kind ?? "tool_start",
|
|
35
|
+
_seq: opts.seq ?? 1,
|
|
36
|
+
_ts: opts.ts ?? Date.now(),
|
|
37
|
+
_summary: opts.summary ?? null,
|
|
38
|
+
},
|
|
39
|
+
timestamp: new Date(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function makeDestroyedEvent(sessionId) {
|
|
43
|
+
return {
|
|
44
|
+
type: "session:destroyed",
|
|
45
|
+
sessionId,
|
|
46
|
+
agentName: "test-agent",
|
|
47
|
+
payload: { agentName: "test-agent", reason: "complete" },
|
|
48
|
+
timestamp: new Date(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Builds an isolated task-event-log wired to a fresh EventBus so tests don't
|
|
53
|
+
* share state through the process-level squadEventBus singleton.
|
|
54
|
+
*/
|
|
55
|
+
function buildIsolatedLog(bus) {
|
|
56
|
+
const CAPACITY = RING_BUFFER_CAPACITY;
|
|
57
|
+
const buffers = new Map();
|
|
58
|
+
const listeners = new Map();
|
|
59
|
+
function getOrCreate(taskId) {
|
|
60
|
+
let b = buffers.get(taskId);
|
|
61
|
+
if (!b) {
|
|
62
|
+
b = new RingBuffer(CAPACITY);
|
|
63
|
+
buffers.set(taskId, b);
|
|
64
|
+
}
|
|
65
|
+
return b;
|
|
66
|
+
}
|
|
67
|
+
function notify(taskId, event) {
|
|
68
|
+
const ls = listeners.get(taskId);
|
|
69
|
+
if (!ls)
|
|
70
|
+
return;
|
|
71
|
+
for (const fn of ls) {
|
|
72
|
+
try {
|
|
73
|
+
fn(event);
|
|
74
|
+
}
|
|
75
|
+
catch { /* non-fatal */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const unsubToolCall = bus.subscribe("session:tool_call", (event) => {
|
|
79
|
+
const taskId = event.sessionId;
|
|
80
|
+
if (!taskId)
|
|
81
|
+
return;
|
|
82
|
+
const p = event.payload;
|
|
83
|
+
const ev = {
|
|
84
|
+
id: 0, taskId,
|
|
85
|
+
seq: p._seq ?? 0,
|
|
86
|
+
ts: p._ts ?? Date.now(),
|
|
87
|
+
kind: p._kind === "tool_complete" ? "tool_complete" : "tool_start",
|
|
88
|
+
toolName: p.toolName ?? null,
|
|
89
|
+
summary: p._summary ?? null,
|
|
90
|
+
};
|
|
91
|
+
getOrCreate(taskId).push(ev);
|
|
92
|
+
notify(taskId, ev);
|
|
93
|
+
});
|
|
94
|
+
const unsubDestroyed = bus.subscribe("session:destroyed", (event) => {
|
|
95
|
+
if (event.sessionId) {
|
|
96
|
+
buffers.delete(event.sessionId);
|
|
97
|
+
listeners.delete(event.sessionId);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
getEvents: (taskId, afterSeq = 0) => {
|
|
102
|
+
const buf = buffers.get(taskId);
|
|
103
|
+
if (!buf)
|
|
104
|
+
return [];
|
|
105
|
+
const all = buf.getAll();
|
|
106
|
+
return afterSeq === 0 ? all : all.filter((e) => e.seq > afterSeq);
|
|
107
|
+
},
|
|
108
|
+
subscribe: (taskId, fn) => {
|
|
109
|
+
let ls = listeners.get(taskId);
|
|
110
|
+
if (!ls) {
|
|
111
|
+
ls = new Set();
|
|
112
|
+
listeners.set(taskId, ls);
|
|
113
|
+
}
|
|
114
|
+
ls.add(fn);
|
|
115
|
+
return () => {
|
|
116
|
+
const lss = listeners.get(taskId);
|
|
117
|
+
if (lss) {
|
|
118
|
+
lss.delete(fn);
|
|
119
|
+
if (lss.size === 0)
|
|
120
|
+
listeners.delete(taskId);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
clear: (taskId) => {
|
|
125
|
+
buffers.delete(taskId);
|
|
126
|
+
listeners.delete(taskId);
|
|
127
|
+
},
|
|
128
|
+
size: () => buffers.size,
|
|
129
|
+
shutdown: () => { unsubToolCall(); unsubDestroyed(); },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// RingBuffer — pure class tests
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
describe("RingBuffer", () => {
|
|
136
|
+
it("starts empty", () => {
|
|
137
|
+
const buf = new RingBuffer(3);
|
|
138
|
+
assert.equal(buf.size, 0);
|
|
139
|
+
assert.deepEqual(buf.getAll(), []);
|
|
140
|
+
});
|
|
141
|
+
it("pushes items up to capacity", () => {
|
|
142
|
+
const buf = new RingBuffer(3);
|
|
143
|
+
buf.push(1);
|
|
144
|
+
buf.push(2);
|
|
145
|
+
buf.push(3);
|
|
146
|
+
assert.equal(buf.size, 3);
|
|
147
|
+
assert.deepEqual(buf.getAll(), [1, 2, 3]);
|
|
148
|
+
});
|
|
149
|
+
it("evicts oldest item when capacity is exceeded", () => {
|
|
150
|
+
const buf = new RingBuffer(3);
|
|
151
|
+
buf.push(1);
|
|
152
|
+
buf.push(2);
|
|
153
|
+
buf.push(3);
|
|
154
|
+
buf.push(4);
|
|
155
|
+
assert.equal(buf.size, 3);
|
|
156
|
+
assert.deepEqual(buf.getAll(), [2, 3, 4]);
|
|
157
|
+
});
|
|
158
|
+
it("evicts multiple oldest for many pushes", () => {
|
|
159
|
+
const buf = new RingBuffer(3);
|
|
160
|
+
for (let i = 1; i <= 10; i++)
|
|
161
|
+
buf.push(i);
|
|
162
|
+
assert.equal(buf.size, 3);
|
|
163
|
+
assert.deepEqual(buf.getAll(), [8, 9, 10]);
|
|
164
|
+
});
|
|
165
|
+
it("getAll returns a copy, not the internal array", () => {
|
|
166
|
+
const buf = new RingBuffer(3);
|
|
167
|
+
buf.push(1);
|
|
168
|
+
const snapshot = buf.getAll();
|
|
169
|
+
buf.push(2);
|
|
170
|
+
assert.deepEqual(snapshot, [1]);
|
|
171
|
+
});
|
|
172
|
+
it("capacity constant is 500", () => {
|
|
173
|
+
const buf = new RingBuffer(RING_BUFFER_CAPACITY);
|
|
174
|
+
assert.equal(buf.capacity, 500);
|
|
175
|
+
});
|
|
176
|
+
it("throws RangeError for capacity < 1", () => {
|
|
177
|
+
assert.throws(() => new RingBuffer(0), { name: "RangeError" });
|
|
178
|
+
});
|
|
179
|
+
it("501 pushes: only last 500 remain, oldest evicted", () => {
|
|
180
|
+
const buf = new RingBuffer(RING_BUFFER_CAPACITY);
|
|
181
|
+
for (let i = 1; i <= 501; i++)
|
|
182
|
+
buf.push(i);
|
|
183
|
+
assert.equal(buf.size, 500);
|
|
184
|
+
assert.equal(buf.getAll()[0], 2); // 1 was evicted
|
|
185
|
+
assert.equal(buf.getAll()[499], 501); // newest retained
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Task event log (bus-wired) tests
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
describe("task event log — bus-wired", () => {
|
|
192
|
+
let bus;
|
|
193
|
+
let log;
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
bus = new EventBus();
|
|
196
|
+
log = buildIsolatedLog(bus);
|
|
197
|
+
});
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
log.shutdown();
|
|
200
|
+
});
|
|
201
|
+
it("returns [] for an unknown taskId", () => {
|
|
202
|
+
assert.deepEqual(log.getEvents("no-such-task"), []);
|
|
203
|
+
});
|
|
204
|
+
it("populates ring buffer on session:tool_call bus event", async () => {
|
|
205
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-001", kind: "tool_start", seq: 1, toolName: "view" }));
|
|
206
|
+
const events = log.getEvents("task-001");
|
|
207
|
+
assert.equal(events.length, 1);
|
|
208
|
+
assert.equal(events[0].kind, "tool_start");
|
|
209
|
+
assert.equal(events[0].toolName, "view");
|
|
210
|
+
assert.equal(events[0].taskId, "task-001");
|
|
211
|
+
assert.equal(events[0].seq, 1);
|
|
212
|
+
});
|
|
213
|
+
it("accumulates multiple events in order", async () => {
|
|
214
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 1, toolName: "bash" }));
|
|
215
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_complete", seq: 2, summary: "ok" }));
|
|
216
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-002", kind: "tool_start", seq: 3, toolName: "view" }));
|
|
217
|
+
const events = log.getEvents("task-002");
|
|
218
|
+
assert.equal(events.length, 3);
|
|
219
|
+
assert.equal(events[0].seq, 1);
|
|
220
|
+
assert.equal(events[2].seq, 3);
|
|
221
|
+
assert.equal(events[1].kind, "tool_complete");
|
|
222
|
+
});
|
|
223
|
+
it("getEvents filters by afterSeq", async () => {
|
|
224
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 1 }));
|
|
225
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 2 }));
|
|
226
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-003", seq: 3 }));
|
|
227
|
+
assert.equal(log.getEvents("task-003", 1).length, 2);
|
|
228
|
+
assert.equal(log.getEvents("task-003", 2).length, 1);
|
|
229
|
+
assert.equal(log.getEvents("task-003", 3).length, 0);
|
|
230
|
+
});
|
|
231
|
+
it("subscribe delivers events as they arrive", async () => {
|
|
232
|
+
const received = [];
|
|
233
|
+
log.subscribe("task-004", (e) => received.push(e.toolName ?? "?"));
|
|
234
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "bash" }));
|
|
235
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-004", toolName: "view" }));
|
|
236
|
+
assert.deepEqual(received, ["bash", "view"]);
|
|
237
|
+
});
|
|
238
|
+
it("subscribe unsub stops delivery", async () => {
|
|
239
|
+
const received = [];
|
|
240
|
+
const unsub = log.subscribe("task-005", (e) => received.push(e.toolName ?? "?"));
|
|
241
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "bash" }));
|
|
242
|
+
unsub();
|
|
243
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-005", toolName: "view" }));
|
|
244
|
+
assert.equal(received.length, 1);
|
|
245
|
+
assert.equal(received[0], "bash");
|
|
246
|
+
});
|
|
247
|
+
it("clearTaskLog removes buffer and listeners", async () => {
|
|
248
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-006", seq: 1 }));
|
|
249
|
+
assert.equal(log.getEvents("task-006").length, 1);
|
|
250
|
+
log.clear("task-006");
|
|
251
|
+
assert.equal(log.getEvents("task-006").length, 0);
|
|
252
|
+
});
|
|
253
|
+
it("session:destroyed auto-clears ring buffer", async () => {
|
|
254
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 1 }));
|
|
255
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-007", seq: 2 }));
|
|
256
|
+
assert.equal(log.getEvents("task-007").length, 2);
|
|
257
|
+
await bus.emit(makeDestroyedEvent("task-007"));
|
|
258
|
+
assert.equal(log.getEvents("task-007").length, 0);
|
|
259
|
+
});
|
|
260
|
+
it("tracks multiple tasks independently", async () => {
|
|
261
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 1, toolName: "bash" }));
|
|
262
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-B", seq: 1, toolName: "view" }));
|
|
263
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-A", seq: 2, toolName: "edit" }));
|
|
264
|
+
assert.equal(log.getEvents("task-A").length, 2);
|
|
265
|
+
assert.equal(log.getEvents("task-B").length, 1);
|
|
266
|
+
assert.equal(log.getEvents("task-A")[0].toolName, "bash");
|
|
267
|
+
assert.equal(log.getEvents("task-B")[0].toolName, "view");
|
|
268
|
+
assert.equal(log.size(), 2);
|
|
269
|
+
});
|
|
270
|
+
it("events for wrong taskId are not visible to other tasks", async () => {
|
|
271
|
+
await bus.emit(makeToolCallEvent({ sessionId: "task-X", seq: 1 }));
|
|
272
|
+
assert.equal(log.getEvents("task-Y").length, 0);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
//# sourceMappingURL=task-event-log.test.js.map
|
package/package.json
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
|
2
|
+
Theme: GitHub Dark
|
|
3
|
+
Description: Dark theme as seen on github.com
|
|
4
|
+
Author: github.com
|
|
5
|
+
Maintainer: @Hirse
|
|
6
|
+
Updated: 2021-05-15
|
|
7
|
+
|
|
8
|
+
Outdated base version: https://github.com/primer/github-syntax-dark
|
|
9
|
+
Current colors taken from GitHub's CSS
|
|
10
|
+
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}:root{color-scheme:dark;--bg: #0e1116;--bg-elev: #161b22;--bg-elev-2: #21262d;--fg: #e6edf3;--fg-dim: #8b949e;--border: #30363d;--accent: #3b82f6;--accent-fg: #ffffff;--danger: #f87171;--user-bubble: #1e293b}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{background:var(--bg);color:var(--fg);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,sans-serif;font-size:14px;line-height:1.5}a,.link{color:var(--accent);text-decoration:none}a:hover,.link:hover{text-decoration:underline}button,input,textarea,select{font:inherit}button:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible{outline:2px solid var(--accent);outline-offset:2px}code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em;background:var(--bg-elev-2);padding:1px 5px;border-radius:4px}.dim{color:var(--fg-dim)}.small{font-size:12px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.skip-link{position:absolute;left:16px;top:-48px;z-index:10;padding:10px 14px;border-radius:8px;background:var(--accent);color:var(--accent-fg)}.skip-link:focus{top:16px}.layout{display:grid;grid-template-columns:220px 1fr;height:100%}.sidebar{background:var(--bg-elev);border-right:1px solid var(--border);padding:16px 0;display:flex;flex-direction:column}.sidebar-brand{display:flex;align-items:center;gap:10px;padding:0 18px 18px;font-weight:600;font-size:16px;border-bottom:1px solid var(--border);margin-bottom:12px}.sidebar nav{display:flex;flex-direction:column}.nav-link{padding:9px 18px;color:var(--fg);border-left:2px solid transparent}.nav-link:hover{background:var(--bg-elev-2);text-decoration:none}.nav-link.active{background:var(--bg-elev-2);border-left-color:var(--accent);color:var(--fg)}.nav-group{display:flex;flex-direction:column}.nav-group-row{display:flex;align-items:stretch}.nav-group-label{flex:1}.nav-group-toggle{background:none;border:none;cursor:pointer;padding:0 14px 0 4px;color:var(--fg-muted, var(--fg));display:flex;align-items:center;justify-content:center;border-left:2px solid transparent}.nav-group-toggle:hover{background:var(--bg-elev-2)}.nav-chevron{display:inline-block;font-size:18px;line-height:1;transition:transform .18s ease;transform:rotate(0)}.nav-chevron-open{transform:rotate(90deg)}.nav-recents{list-style:none;margin:0;padding:0}.nav-recent-link{display:flex;align-items:baseline;justify-content:space-between;gap:6px;width:100%;background:none;border:none;border-left:2px solid transparent;padding:6px 18px 6px 28px;cursor:pointer;color:var(--fg);text-align:left;font-size:13px}.nav-recent-link:hover{background:var(--bg-elev-2);text-decoration:none;border-left-color:var(--accent)}.nav-recent-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.nav-recent-hint{font-size:11px;flex-shrink:0;opacity:.6}.nav-recents-empty{padding:4px 28px 6px;font-size:12px;margin:0}.main{overflow:hidden;display:flex;flex-direction:column}.app-header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 24px;border-bottom:1px solid var(--border);background:var(--bg-elev)}.app-header-title{font-size:16px;font-weight:600;margin:0}.app-header-title-row{display:inline-flex;align-items:center;gap:10px}.app-header-user{color:var(--fg-dim);font-size:13px}.mode-badge{display:inline-flex;align-items:center;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600;line-height:1;border:1px solid transparent}.mode-standalone{background:var(--bg-elev-2);border-color:var(--border);color:var(--fg-dim)}.mode-team{background:color-mix(in srgb,var(--accent) 14%,transparent);border-color:color-mix(in srgb,var(--accent) 35%,var(--border));color:var(--accent)}.sse-badge{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:3px 10px;font-size:12px;font-weight:600;line-height:1;border:1px solid transparent}.sse-badge__dot{display:inline-block;width:7px;height:7px;border-radius:50%;flex-shrink:0}.sse-badge--reconnecting{background:color-mix(in srgb,#f59e0b 12%,transparent);border-color:color-mix(in srgb,#f59e0b 35%,var(--border));color:#b45309}.sse-badge--reconnecting .sse-badge__dot{background:#f59e0b;animation:sse-pulse 1.2s ease-in-out infinite}.sse-badge--disconnected{background:color-mix(in srgb,#ef4444 12%,transparent);border-color:color-mix(in srgb,#ef4444 35%,var(--border));color:#b91c1c}.sse-badge--disconnected .sse-badge__dot{background:#ef4444}.sse-badge__reconnect-btn{background:none;border:none;padding:0;margin-left:4px;font-size:12px;font-weight:600;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}.sse-badge__reconnect-btn:hover{opacity:.8}@keyframes sse-pulse{0%,to{opacity:1}50%{opacity:.35}}max-width: 760px; margin: 0 auto; padding: 32px; } .loading,.empty-state{padding:32px;color:var(--fg-dim)}.empty-state h2{color:var(--fg);margin-top:0;margin-bottom:8px}.empty-state p{margin:0 0 12px}.empty-state-icon{font-size:28px;margin-bottom:8px;line-height:1}.empty-state-action{margin-top:4px}.auth-screen{min-height:100%;display:grid;place-items:center;padding:32px}.auth-card{width:min(420px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;padding:24px}.auth-card h1{margin-top:0;margin-bottom:8px}.auth-card p{margin-top:0;margin-bottom:20px;color:var(--fg-dim)}.page{padding:24px 32px;overflow:auto;flex:1;min-width:0}.page-header{margin-bottom:16px}.page-header h1{margin:0 0 4px;font-size:22px}.error-notice{background:#f871711a;border:1px solid var(--danger);color:var(--danger);padding:12px 14px;border-radius:8px;margin-bottom:16px}.error-notice.inline{margin-bottom:12px}.error-notice-title{margin:0 0 4px;font-size:16px}.error-notice-message{margin:0}.error-notice-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.error-details{background:var(--bg-elev-2);padding:12px;border-radius:8px;overflow:auto}.loading-state{display:flex;align-items:flex-start;gap:12px;padding:16px 0;color:var(--fg-dim)}.loading-state.inline{padding:10px 0}.loading-state.centered{justify-content:center;padding:48px 32px}.loading-spinner{width:18px;height:18px;border:2px solid rgba(59,130,246,.25);border-top-color:var(--accent);border-radius:999px;flex:none;margin-top:2px;animation:spin .9s linear infinite}.loading-state-label{color:var(--fg);font-weight:500}.loading-state-detail{margin-top:2px}.btn{background:var(--bg-elev-2);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:6px 14px;font-size:13px;cursor:pointer}.btn:hover{background:var(--bg-elev)}.btn.primary{background:var(--accent);color:var(--accent-fg);border-color:var(--accent)}.btn.primary:hover{filter:brightness(1.1)}.btn.danger{border-color:var(--danger);color:var(--danger)}.btn.cancel{background:var(--danger);border-color:var(--danger);color:var(--accent-fg)}.chat{display:flex;flex-direction:column;height:100%}.chat-scroll{flex:1;overflow:auto;padding:24px 32px 0}.chat-log{display:flex;flex-direction:column}.turn-wrapper{margin-bottom:18px}.turn-wrapper+.turn-wrapper:not(.has-separator) .bubble{border-top:1px solid var(--border);padding-top:14px}.turn-header{display:flex;align-items:center;gap:8px;margin-bottom:6px;font-size:12px}.turn-header--user{justify-content:flex-end}.turn-actor-badge{display:inline-flex;align-items:center;gap:4px;background:var(--bg-elev);border:1px solid var(--border);border-radius:999px;padding:2px 8px;font-size:11px;font-weight:500;-webkit-user-select:none;user-select:none}.turn-actor-icon{font-size:11px;line-height:1}.turn-ts{font-size:11px;color:var(--fg-dim);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;cursor:default;-webkit-user-select:none;user-select:none}.time-separator{display:flex;align-items:center;gap:10px;margin:16px 0 10px;color:var(--fg-dim);font-size:11px;font-style:italic;-webkit-user-select:none;user-select:none}.time-separator-line{flex:1;height:1px;background:var(--border);opacity:.5}.time-separator-label{white-space:nowrap;opacity:.7;letter-spacing:.02em}.bubble{max-width:800px}.bubble.user{margin-left:auto;text-align:right}.bubble--agent-activity{opacity:.88;border-left:2px solid var(--accent, #6366f1);padding-left:10px}.bubble.user .user-text{display:inline-block;background:var(--user-bubble);border:1px solid var(--border);padding:8px 14px;border-radius:14px;white-space:pre-wrap;text-align:left;margin:0}.route-tag{font-size:11px;color:var(--fg-dim);margin-top:4px}.copy-btn-wrap{position:relative}.copy-btn{position:absolute;top:6px;right:6px;display:flex;align-items:center;justify-content:center;padding:4px;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg-dim);cursor:pointer;z-index:1;line-height:0;transition:color .15s,background .15s}.copy-btn:hover{background:var(--bg-elev-2);color:var(--fg)}.copy-btn--copied{color:#4ade80;border-color:#4ade80}@media (hover: hover){.copy-btn{opacity:0;pointer-events:none;transition:opacity .15s,color .15s,background .15s}.copy-btn-wrap:hover .copy-btn,.copy-btn-wrap:focus-within .copy-btn{opacity:1;pointer-events:auto}}.copy-btn--code{top:8px;right:8px}.activity-heartbeat{display:flex;align-items:center;gap:6px;padding:4px 8px;margin:0 0 6px;border-radius:6px;border:1px solid rgba(59,130,246,.35);background:var(--bg-elev-2);font-size:12px;color:var(--fg);transition:opacity .3s}.activity-heartbeat.stale{opacity:.5;border-color:var(--border);color:var(--fg-dim)}.heartbeat-spinner{font-family:ui-monospace,monospace;font-size:11px;color:var(--accent);display:inline-block;animation:spin 1.4s linear infinite}.activity-heartbeat.stale .heartbeat-spinner{animation:none;color:var(--fg-dim)}.heartbeat-agent{font-weight:600}.heartbeat-sep{color:var(--fg-dim)}.heartbeat-action{color:var(--fg-dim);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.heartbeat-elapsed{font-family:ui-monospace,monospace;font-size:11px;color:var(--fg-dim);white-space:nowrap}.activity-strip{margin:0 0 8px;font-size:12px}.activity-summary{display:flex;flex-wrap:wrap;gap:6px}.activity-pill{display:inline-flex;align-items:center;gap:6px;background:var(--bg-elev);border:1px solid var(--border);color:var(--fg-dim);padding:3px 10px;border-radius:999px;cursor:pointer;font-size:12px}.activity-pill:hover{background:var(--bg-elev-2)}.activity-pill.running{color:var(--accent);border-color:#3b82f673}.activity-pill .glyph{font-family:ui-monospace,monospace;font-size:11px}.activity-pill.running .glyph{display:inline-block;animation:spin 1s linear infinite}.activity-pill .caret{color:var(--fg-dim);font-size:10px}.activity-headlines{display:flex;flex-direction:column;gap:2px;margin-top:6px}.activity-headline{display:inline-flex;align-items:center;gap:6px;padding:2px 4px;color:var(--fg-dim)}.activity-headline.status-running{color:var(--accent)}.activity-headline.status-failed{color:var(--danger)}.activity-headline .glyph{font-family:ui-monospace,monospace;font-size:11px;width:12px;text-align:center}.activity-headline.status-running .glyph{animation:spin 1s linear infinite}.agent-tag{font-size:10px;text-transform:lowercase;background:#3b82f629;color:#93c5fd;border:1px solid rgba(59,130,246,.35);padding:1px 6px;border-radius:4px;letter-spacing:.02em}.activity-thinking,.activity-details{margin-top:8px;padding:10px 12px;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px}.activity-details{display:flex;flex-direction:column;gap:6px}.thinking-block{margin:0;padding:8px;background:var(--bg-elev-2);border-radius:4px;white-space:pre-wrap;font-size:12px;line-height:1.5;max-height:280px;overflow:auto}.activity-row{border:1px solid var(--border);border-radius:6px;background:var(--bg-elev-2)}.activity-row.status-running{border-color:#3b82f673}.activity-row.status-failed{border-color:var(--danger)}.activity-row-head{width:100%;display:flex;align-items:center;gap:8px;background:transparent;border:0;color:var(--fg);text-align:left;padding:6px 10px;cursor:pointer;font-size:12px}.activity-row.status-running .activity-row-head .glyph{animation:spin 1s linear infinite;color:var(--accent)}.activity-row.status-failed .activity-row-head .glyph{color:var(--danger)}.activity-row .glyph{font-family:ui-monospace,monospace;width:12px;text-align:center}.activity-row .caret{margin-left:auto;color:var(--fg-dim)}.activity-row-body{padding:0 10px 10px;display:flex;flex-direction:column;gap:6px}.row-label{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--fg-dim)}.composer{border-top:1px solid var(--border);background:var(--bg-elev);padding:14px 32px;display:flex;flex-direction:column;gap:8px}.composer textarea{width:100%;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:10px;resize:vertical}.composer-help{margin-top:-2px}.dreaming-indicator{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--fg-dim);padding:4px 0;animation:pulse 2s ease-in-out infinite}.dreaming-indicator-glyph{color:#c4b5fd}.composer-actions{display:flex;justify-content:flex-end;gap:6px}.md{line-height:1.55}.md p:first-child{margin-top:0}.md p:last-child{margin-bottom:0}.md pre{background:var(--bg-elev-2);border-radius:6px;padding:12px;overflow:auto}.md pre code{background:transparent;padding:0}.md table{border-collapse:collapse;margin:1em 0}.md th,.md td{border:1px solid var(--border);padding:6px 10px}.workers-layout{display:grid;grid-template-columns:320px 1fr;gap:18px;align-items:start}.workers-list{display:flex;flex-direction:column;gap:6px}.worker-row{text-align:left;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:10px 12px;cursor:pointer;color:var(--fg)}.worker-row.selected,.worker-row:hover{background:var(--bg-elev-2)}.worker-row-head{display:flex;justify-content:space-between;align-items:center}.worker-status{font-size:11px;font-weight:600;padding:2px 7px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em}.worker-status--running{background:color-mix(in srgb,var(--accent) 15%,transparent);color:var(--accent)}.worker-status--completed{background:color-mix(in srgb,#4caf50 15%,transparent);color:#4caf50}.worker-status--error{background:color-mix(in srgb,#f44336 15%,transparent);color:#f44336}.worker-row-desc{margin-top:4px;font-size:13px;color:var(--fg)}.workers-detail{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:18px}.worker-detail-description{margin:4px 0 8px;font-size:15px}.worker-detail-slug,.worker-detail-taskid{font-size:.75em;font-family:var(--font-mono, monospace)}.worker-detail-meta{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:8px}.worker-events{display:flex;flex-direction:column;gap:4px;max-height:320px;overflow-y:auto;background:var(--bg-elev-2);border-radius:6px;padding:10px 12px;margin-bottom:12px;font-size:12px;font-family:var(--font-mono, monospace)}.worker-event{display:flex;gap:8px;align-items:baseline;line-height:1.5}.worker-event-ts{color:var(--text-dim, #888);flex-shrink:0;font-size:11px}.worker-event-body{display:flex;gap:4px;align-items:baseline;flex-wrap:wrap;overflow:hidden}.worker-event-icon{flex-shrink:0}.worker-event--tool_complete .worker-event-icon{opacity:.7}.msg-queued-indicator{font-size:.8em;opacity:.6;vertical-align:middle;-webkit-user-select:none;user-select:none}.msg-queued-backend-indicator{display:inline-block;font-size:.78em;opacity:.75;margin-top:4px;color:var(--fg-dim);-webkit-user-select:none;user-select:none}.output{background:var(--bg-elev-2);padding:12px;border-radius:6px;overflow:auto;white-space:pre-wrap;font-size:13px}.projects-toolbar{margin-bottom:16px}.projects-register-form{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.projects-path-input{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:13px;padding:6px 10px;width:380px;max-width:100%}.projects-path-input:focus{outline:none;border-color:var(--accent)}.projects-register-error{font-size:12px}.projects-disabled{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:18px}.projects-empty{padding:24px 0}.projects-list{display:flex;flex-direction:column;gap:8px}.project-row{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px}.project-row-info{display:flex;flex-direction:column;gap:4px;min-width:0}.project-root{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.project-meta{display:flex;gap:12px;font-size:12px;flex-wrap:wrap}.project-badge{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:10px;padding:1px 8px;font-size:11px;color:var(--fg)}.project-row-actions{display:flex;gap:6px;flex-shrink:0}.project-context-banner{display:flex;align-items:center;gap:8px;padding:6px 16px;background:color-mix(in srgb,var(--accent) 10%,var(--bg-elev));border-bottom:1px solid color-mix(in srgb,var(--accent) 25%,var(--border));font-size:12px;color:var(--fg-dim);flex-shrink:0}.project-context-icon{font-size:13px;flex-shrink:0}.project-context-name{font-weight:600;color:var(--fg);flex-shrink:0}.project-context-path{color:var(--fg-dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.project-context-clear{background:none;border:none;color:var(--fg-dim);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;flex-shrink:0;border-radius:4px}.project-context-clear:hover{color:var(--fg);background:var(--bg-hover)}.project-chat-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 16px;background:color-mix(in srgb,var(--accent) 8%,var(--bg-elev));border-bottom:1px solid color-mix(in srgb,var(--accent) 20%,var(--border));flex-shrink:0}.project-chat-header-identity{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden}.project-chat-icon{font-size:16px;flex-shrink:0}.project-chat-title{font-size:14px;white-space:nowrap;flex-shrink:0}.project-chat-path{font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.wiki{display:flex;flex-direction:column;min-height:100%}.wiki-layout{display:grid;grid-template-columns:minmax(320px,360px) minmax(0,1fr);gap:20px;flex:1;min-height:0}.wiki-sidebar,.wiki-main{min-height:0}.wiki-sidebar{display:flex;flex-direction:column;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;overflow:hidden}.wiki-sidebar-header{position:sticky;top:0;z-index:1;display:flex;flex-direction:column;gap:14px;padding:16px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-elev) 0%,rgba(22,27,34,.98) 100%)}.wiki-sidebar-header-row{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}.wiki-sidebar-header-row h2{margin:0 0 4px;font-size:16px}.wiki-sidebar-header-row p{margin:0}.wiki-search{display:flex;flex-direction:column;gap:12px}.wiki-search-field input,.wiki-filter select{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--fg);padding:9px 10px;border-radius:8px}.wiki-filter{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--fg-dim)}.wiki-search-meta,.wiki-shortcuts,.wiki-scope-legend{color:var(--fg-dim)}.wiki-scope-header-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.wiki-scope-header-row h1{margin:0}.wiki-shortcuts{border-top:1px solid var(--border);padding-top:12px}.wiki-scope-legend{display:flex;flex-wrap:wrap;gap:8px}.wiki-scope-legend>span{display:inline-flex;align-items:center;gap:4px}.wiki-sidebar-body{flex:1;min-height:0;overflow:auto;padding:12px}.wiki-tree,.wiki-tree-children{list-style:none;margin:0;padding:0}.wiki-tree-children{margin-top:4px}.wiki-node{margin:2px 0}.wiki-node-button{width:100%;display:flex;align-items:center;gap:8px;padding:7px 10px;background:transparent;border:1px solid transparent;border-radius:8px;color:var(--fg);text-align:left;cursor:pointer}.wiki-node-folder-button{color:var(--fg-dim)}.wiki-node-folder-button:hover,.wiki-node-folder-button.expanded,.wiki-node-page-button:hover{background:var(--bg-elev-2);border-color:var(--border);color:var(--fg)}.wiki-node-page-button{align-items:flex-start}.wiki-node-page-button.selected{background:#3b82f61f;border-color:#3b82f659;box-shadow:inset 2px 0 0 var(--accent)}.wiki-node-icon{width:14px;flex:none;text-align:center;color:var(--fg-dim)}.wiki-node-page-button.selected .wiki-node-icon{color:#93c5fd}.wiki-node-page-button.selected .wiki-node-scope-icon-personal{color:#ddd6fe}.wiki-node-page-button.selected .wiki-node-scope-icon-team{color:#a7f3d0}.wiki-node-content{min-width:0;display:flex;flex:1;flex-direction:column;gap:4px}.wiki-node-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.wiki-node-meta{display:flex;flex-wrap:wrap;gap:6px;font-size:11px}.wiki-node-count{margin-left:auto;border:1px solid var(--border);border-radius:999px;padding:0 6px;font-size:11px;color:var(--fg-dim)}.wiki-main{min-width:0;display:flex;background:var(--bg-elev);border:1px solid var(--border);border-radius:12px;overflow:hidden}.wiki-main>.wiki-empty-state{width:100%}.wiki-document{width:100%;min-height:0;display:flex;flex-direction:column}.wiki-page-header{position:sticky;top:0;z-index:1;padding:18px 22px 16px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-elev) 0%,rgba(22,27,34,.98) 100%)}.wiki-page-header-main{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.wiki-page-title-block h2{margin:0;font-size:28px;line-height:1.2}.wiki-page-summary{margin:8px 0 0;max-width:72ch;color:var(--fg-dim)}.wiki-page-actions{display:flex;gap:8px;flex:none}.wiki-breadcrumbs ol{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0 0 12px;padding:0}.wiki-breadcrumbs li{display:flex;align-items:center}.wiki-breadcrumbs li+li:before{content:"/";margin-right:8px;color:var(--fg-dim)}.wiki-breadcrumb-button{padding:0;border:0;background:transparent;color:var(--fg-dim);cursor:pointer}.wiki-breadcrumb-button:hover{color:var(--fg);text-decoration:underline}.wiki-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:14px;font-size:12px;color:var(--fg-dim)}.wiki-badge,.wiki-tag,.wiki-meta-item{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:3px 8px;background:var(--bg-elev-2)}.wiki-badge{color:#93c5fd;border-color:#3b82f659}.wiki-scope-badge{display:inline-flex;align-items:center;gap:4px}.wiki-scope-badge-personal{color:#c4b5fd;border-color:#c4b5fd59;background:#c4b5fd14}.wiki-scope-badge-team{color:#6ee7b7;border-color:#6ee7b759;background:#6ee7b714}.wiki-node-scope-icon-personal{color:#c4b5fd}.wiki-node-scope-icon-team{color:#6ee7b7}.wiki-tag{color:var(--fg)}.wiki-meta-path{max-width:100%;overflow:auto;white-space:nowrap}.wiki-document-body{flex:1;min-height:0;overflow:auto}.wiki-article{max-width:76ch;padding:24px 22px 32px}.wiki-empty-state{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:12px;margin:auto;max-width:56ch;padding:32px}.wiki-empty-state.compact{margin:0;max-width:none;padding:20px 12px}.wiki-empty-state h2{margin:0;font-size:20px}.wiki-empty-state p{margin:0;color:var(--fg-dim)}.wiki-empty-state-actions{display:flex;flex-wrap:wrap;gap:8px}@media (max-width: 960px){.wiki-layout{grid-template-columns:1fr}.wiki-sidebar{max-height:50vh}.wiki-page-header-main,.wiki-sidebar-header-row,.wiki-scope-legend{flex-direction:column}.wiki-page-actions{width:100%}.wiki-page-actions .btn{flex:1}}.wiki-edit .row{display:flex;gap:12px;margin-bottom:12px}.wiki-edit input[type=text]{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--fg);padding:8px;border-radius:6px}.wiki-edit label{display:block;width:100%;font-size:12px;color:var(--fg-dim)}.wiki-editor{margin-bottom:16px}.skill-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:14px}.skill-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px}.skill-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}.tag{font-size:10px;text-transform:uppercase;padding:2px 6px;border-radius:4px;letter-spacing:.05em;background:var(--bg-elev-2);color:var(--fg-dim)}.tag-bundled{color:#93c5fd}.tag-local{color:#86efac}.tag-global{color:#fcd34d}.history-list{list-style:none;padding:0}.history-list li{padding:6px 0;border-bottom:1px solid var(--border)}.settings section{margin-bottom:28px}.settings-field{display:flex;flex-direction:column;gap:6px}.settings-field-label{font-size:12px;color:var(--fg-dim)}.settings select{background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:6px 10px}.row{display:flex;align-items:center;gap:8px}.settings-row{align-items:flex-end}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes pulse{0%,to{opacity:.4}50%{opacity:1}}.ralph{max-width:760px}.ralph-section{margin-top:24px}.ralph-section h2{font-size:15px;font-weight:600;margin-bottom:10px;color:var(--fg)}.ralph-status-card{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:8px}.ralph-status-row{display:flex;align-items:center;gap:10px;font-size:14px}.ralph-status-label{min-width:90px;color:var(--text-dim, #888);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.ralph-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;text-transform:uppercase;letter-spacing:.04em}.ralph-badge--running{background:color-mix(in srgb,var(--accent) 15%,transparent);color:var(--accent)}.ralph-badge--stopped{background:color-mix(in srgb,#888 15%,transparent);color:#888}.ralph-mono{font-family:var(--font-mono, monospace);font-size:13px}.ralph-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:400px}.ralph-controls{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:12px}.ralph-form-row{display:flex;align-items:center;gap:10px;font-size:14px}.ralph-form-row label{min-width:140px;color:var(--text-dim, #888);font-size:12px;text-transform:uppercase;letter-spacing:.04em}.ralph-select,.ralph-input{background:var(--bg-elev-2, var(--bg-elev));border:1px solid var(--border);border-radius:5px;color:var(--fg);font-size:13px;padding:5px 8px;min-width:240px}.ralph-input--narrow{min-width:80px;width:80px}.ralph-select:focus,.ralph-input:focus{outline:2px solid var(--accent);outline-offset:1px}.btn{border:none;border-radius:6px;font-size:13px;font-weight:600;padding:6px 14px;cursor:pointer;transition:opacity .1s;align-self:flex-start}.btn:disabled{opacity:.5;cursor:not-allowed}.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover:not(:disabled){opacity:.85}.btn-danger{background:#f44336;color:#fff}.btn-danger:hover:not(:disabled){opacity:.85}.ralph-queue-count{font-weight:400;color:var(--text-dim, #888);font-size:13px}.ralph-queue-source{margin-bottom:8px}.ralph-queue{display:flex;flex-direction:column;gap:6px}.ralph-issue{display:block;text-decoration:none;color:inherit;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:10px 12px;transition:background .1s}.ralph-issue:hover{background:var(--bg-elev-2)}.ralph-issue-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px}.ralph-issue-number{font-family:var(--font-mono, monospace);font-size:12px;color:var(--text-dim, #888);flex-shrink:0}.ralph-issue-title{font-size:14px;font-weight:500}.ralph-issue-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.ralph-labels{display:flex;flex-wrap:wrap;gap:4px}.ralph-label{font-size:11px;padding:1px 6px;border-radius:8px;background:color-mix(in srgb,#888 12%,transparent);color:var(--fg)}.ralph-label--agent{background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent)}.ralph-label--triage{background:color-mix(in srgb,#9c27b0 12%,transparent);color:#9c27b0}.ralph-label--urgent{background:color-mix(in srgb,#f44336 12%,transparent);color:#f44336}.link-btn{background:none;border:none;color:var(--accent);font-size:inherit;cursor:pointer;padding:0;text-decoration:underline}
|