chapterhouse 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/dist/api/server.js +40 -2
- package/dist/config.js +3 -0
- package/dist/copilot/orchestrator.js +113 -4
- package/dist/copilot/orchestrator.test.js +205 -0
- package/dist/copilot/session-manager.js +32 -2
- package/dist/copilot/session-manager.test.js +66 -0
- package/dist/copilot/workiq-installer.js +91 -0
- package/dist/copilot/workiq-installer.test.js +148 -0
- package/dist/daemon.js +10 -0
- package/dist/store/db.js +58 -5
- package/dist/store/db.test.js +69 -0
- package/package.json +3 -1
- package/web/dist/assets/index-BkB7gY18.css +10 -0
- package/web/dist/assets/{index-NmxVWGY1.js → index-DSqc46G_.js} +65 -62
- package/web/dist/assets/index-DSqc46G_.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Dpt-MCe8.css +0 -10
- package/web/dist/assets/index-NmxVWGY1.js.map +0 -1
|
@@ -233,6 +233,72 @@ test("SessionRegistry: close is deferred when session is processing", async () =
|
|
|
233
233
|
unblock();
|
|
234
234
|
await new Promise((r) => setTimeout(r, 5));
|
|
235
235
|
});
|
|
236
|
+
test("SessionRegistry: pendingClose evicts session within ms of turn completion", async () => {
|
|
237
|
+
let unblock;
|
|
238
|
+
const disconnectLog = [];
|
|
239
|
+
const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
240
|
+
const t = makeFakeSession();
|
|
241
|
+
t.session.disconnect = async () => { disconnectLog.push(sk); };
|
|
242
|
+
const worker = () => new Promise((res) => { unblock = () => res("done"); });
|
|
243
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
244
|
+
});
|
|
245
|
+
const m = registry.getOrCreate("session-a");
|
|
246
|
+
await m.ensureSession();
|
|
247
|
+
const { item } = makeDeferred();
|
|
248
|
+
m.enqueue(item);
|
|
249
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
250
|
+
assert.equal(m.isProcessing, true, "should be processing");
|
|
251
|
+
// Close while busy — should set pendingClose, not evict yet
|
|
252
|
+
registry.close("session-a", "explicit-close");
|
|
253
|
+
assert.equal(m.pendingClose, true, "pendingClose must be set");
|
|
254
|
+
assert.ok(registry.get("session-a"), "session must remain in registry until turn finishes");
|
|
255
|
+
assert.equal(disconnectLog.length, 0, "must not disconnect mid-turn");
|
|
256
|
+
// Unblock the turn — session should be evicted within ms
|
|
257
|
+
unblock();
|
|
258
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
259
|
+
assert.ok(!registry.get("session-a"), "session must be evicted after turn completes");
|
|
260
|
+
assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected");
|
|
261
|
+
});
|
|
262
|
+
test("SessionRegistry: pendingClose waits for full queue drain before evicting", async () => {
|
|
263
|
+
let unblock1;
|
|
264
|
+
let unblock2;
|
|
265
|
+
const disconnectLog = [];
|
|
266
|
+
let callCount = 0;
|
|
267
|
+
const registry = new SessionRegistry({ idleTtlMs: 60_000, maxActive: 10 }, (sk) => {
|
|
268
|
+
const t = makeFakeSession();
|
|
269
|
+
t.session.disconnect = async () => { disconnectLog.push(sk); };
|
|
270
|
+
const worker = () => {
|
|
271
|
+
callCount++;
|
|
272
|
+
if (callCount === 1) {
|
|
273
|
+
return new Promise((res) => { unblock1 = () => res("turn1"); });
|
|
274
|
+
}
|
|
275
|
+
return new Promise((res) => { unblock2 = () => res("turn2"); });
|
|
276
|
+
};
|
|
277
|
+
return new SessionManager(sk, worker, factory(t.session));
|
|
278
|
+
});
|
|
279
|
+
const m = registry.getOrCreate("session-a");
|
|
280
|
+
await m.ensureSession();
|
|
281
|
+
const { item: item1 } = makeDeferred();
|
|
282
|
+
const { item: item2 } = makeDeferred();
|
|
283
|
+
m.enqueue(item1); // starts turn 1 immediately
|
|
284
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
285
|
+
m.enqueue(item2); // queued while turn 1 runs
|
|
286
|
+
assert.equal(m.isProcessing, true, "should be processing turn 1");
|
|
287
|
+
assert.equal(m.queueDepth, 1, "turn 2 should be queued");
|
|
288
|
+
// Close while busy — should set pendingClose
|
|
289
|
+
registry.close("session-a", "explicit-close");
|
|
290
|
+
assert.equal(m.pendingClose, true, "pendingClose must be set");
|
|
291
|
+
// Unblock turn 1 — turn 2 is still queued, session must NOT evict yet
|
|
292
|
+
unblock1();
|
|
293
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
294
|
+
assert.ok(registry.get("session-a"), "session must remain while turn 2 is still queued");
|
|
295
|
+
assert.equal(disconnectLog.length, 0, "must not disconnect until queue fully drains");
|
|
296
|
+
// Unblock turn 2 — queue is now empty, session must evict
|
|
297
|
+
unblock2();
|
|
298
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
299
|
+
assert.ok(!registry.get("session-a"), "session must be evicted after queue fully drains");
|
|
300
|
+
assert.ok(disconnectLog.includes("session-a"), "SDK session must be disconnected after drain");
|
|
301
|
+
});
|
|
236
302
|
test("SessionRegistry: TTL eviction removes sessions idle beyond the TTL", async () => {
|
|
237
303
|
const SHORT_TTL = 40;
|
|
238
304
|
const { registry, disconnectLog } = makeRegistry({ idleTtlMs: SHORT_TTL });
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { mkdirSync as fsMkdirSync, readFileSync as fsReadFileSync, writeFileSync as fsWriteFileSync } from "fs";
|
|
4
|
+
import { childLogger } from "../util/logger.js";
|
|
5
|
+
const log = childLogger("workiq-installer");
|
|
6
|
+
export const WORKIQ_SERVER_KEY = "workiq";
|
|
7
|
+
export const WORKIQ_PACKAGE = "@microsoft/workiq";
|
|
8
|
+
export const MCP_CONFIG_PATH = join(homedir(), ".copilot", "mcp-config.json");
|
|
9
|
+
/** Return true if the auto-install feature is active for the given config. */
|
|
10
|
+
export function isWorkiqAutoInstallEnabled(opts) {
|
|
11
|
+
return opts.workiqAutoInstall && opts.entraAuthEnabled && Boolean(opts.entraTenantId);
|
|
12
|
+
}
|
|
13
|
+
/** Parse the raw JSON of a mcp-config.json file. Returns an empty object on any error. */
|
|
14
|
+
export function parseMcpConfigFile(raw) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(raw);
|
|
17
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Return true if the workiq entry already exists in the config. */
|
|
27
|
+
export function workiqEntryExists(config) {
|
|
28
|
+
return Boolean(config.mcpServers &&
|
|
29
|
+
typeof config.mcpServers === "object" &&
|
|
30
|
+
WORKIQ_SERVER_KEY in config.mcpServers);
|
|
31
|
+
}
|
|
32
|
+
/** Build the workiq MCPStdioServerConfig entry. */
|
|
33
|
+
export function buildWorkiqEntry() {
|
|
34
|
+
return {
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", WORKIQ_PACKAGE],
|
|
37
|
+
tools: ["*"],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Ensure the workiq MCP server entry is present in ~/.copilot/mcp-config.json.
|
|
42
|
+
*
|
|
43
|
+
* - Idempotent: if the entry already exists, returns early without writing.
|
|
44
|
+
* - Failure-safe: any I/O error is caught; a structured warn is emitted and
|
|
45
|
+
* the function returns without throwing, so callers (daemon startup) continue.
|
|
46
|
+
*
|
|
47
|
+
* Returns "installed" | "already-present" | "skipped" for test assertions.
|
|
48
|
+
*/
|
|
49
|
+
export function ensureWorkiqMcpEntry(options = {}) {
|
|
50
|
+
const configPath = options.configPath ?? MCP_CONFIG_PATH;
|
|
51
|
+
const readFileFn = options.readFile ?? ((p, enc) => fsReadFileSync(p, enc));
|
|
52
|
+
const writeFileFn = options.writeFile ?? ((p, data, enc) => fsWriteFileSync(p, data, enc));
|
|
53
|
+
const mkdirFn = options.mkdirSync ?? ((p, opts) => fsMkdirSync(p, opts));
|
|
54
|
+
try {
|
|
55
|
+
let existingConfig = {};
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileFn(configPath, "utf-8");
|
|
58
|
+
existingConfig = parseMcpConfigFile(raw);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// File doesn't exist yet — start with empty config.
|
|
62
|
+
}
|
|
63
|
+
if (workiqEntryExists(existingConfig)) {
|
|
64
|
+
log.debug({ configPath }, "workiq MCP entry already present — skipping");
|
|
65
|
+
return "already-present";
|
|
66
|
+
}
|
|
67
|
+
// Ensure the parent directory exists
|
|
68
|
+
const parentDir = join(configPath, "..");
|
|
69
|
+
try {
|
|
70
|
+
mkdirFn(parentDir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Best effort — directory may already exist; writeFile will fail loudly if not.
|
|
74
|
+
}
|
|
75
|
+
const updated = {
|
|
76
|
+
...existingConfig,
|
|
77
|
+
mcpServers: {
|
|
78
|
+
...(existingConfig.mcpServers ?? {}),
|
|
79
|
+
[WORKIQ_SERVER_KEY]: buildWorkiqEntry(),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
writeFileFn(configPath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
|
|
83
|
+
log.info({ configPath, package: WORKIQ_PACKAGE }, "workiq MCP server auto-installed");
|
|
84
|
+
return "installed";
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
log.warn({ err: err instanceof Error ? err.message : String(err), configPath }, "workiq MCP auto-install failed — continuing without it");
|
|
88
|
+
return "skipped";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=workiq-installer.js.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { isWorkiqAutoInstallEnabled, parseMcpConfigFile, workiqEntryExists, buildWorkiqEntry, ensureWorkiqMcpEntry, WORKIQ_SERVER_KEY, WORKIQ_PACKAGE, } from "./workiq-installer.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// isWorkiqAutoInstallEnabled
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
test("isWorkiqAutoInstallEnabled: true when all conditions met", () => {
|
|
8
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: true }), true);
|
|
9
|
+
});
|
|
10
|
+
test("isWorkiqAutoInstallEnabled: false when workiqAutoInstall disabled", () => {
|
|
11
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "tenant-id", workiqAutoInstall: false }), false);
|
|
12
|
+
});
|
|
13
|
+
test("isWorkiqAutoInstallEnabled: false when entraAuthEnabled is false", () => {
|
|
14
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: false, entraTenantId: "tenant-id", workiqAutoInstall: true }), false);
|
|
15
|
+
});
|
|
16
|
+
test("isWorkiqAutoInstallEnabled: false when entraTenantId is empty", () => {
|
|
17
|
+
assert.equal(isWorkiqAutoInstallEnabled({ entraAuthEnabled: true, entraTenantId: "", workiqAutoInstall: true }), false);
|
|
18
|
+
});
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// parseMcpConfigFile
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
test("parseMcpConfigFile: parses valid JSON with mcpServers", () => {
|
|
23
|
+
const raw = JSON.stringify({ mcpServers: { existing: {} } });
|
|
24
|
+
const result = parseMcpConfigFile(raw);
|
|
25
|
+
assert.deepEqual(result, { mcpServers: { existing: {} } });
|
|
26
|
+
});
|
|
27
|
+
test("parseMcpConfigFile: returns empty object for invalid JSON", () => {
|
|
28
|
+
const result = parseMcpConfigFile("not-json");
|
|
29
|
+
assert.deepEqual(result, {});
|
|
30
|
+
});
|
|
31
|
+
test("parseMcpConfigFile: returns empty object for null", () => {
|
|
32
|
+
const result = parseMcpConfigFile("null");
|
|
33
|
+
assert.deepEqual(result, {});
|
|
34
|
+
});
|
|
35
|
+
test("parseMcpConfigFile: returns empty object for array", () => {
|
|
36
|
+
const result = parseMcpConfigFile("[]");
|
|
37
|
+
assert.deepEqual(result, {});
|
|
38
|
+
});
|
|
39
|
+
test("parseMcpConfigFile: parses JSON without mcpServers key", () => {
|
|
40
|
+
const result = parseMcpConfigFile('{"other":"value"}');
|
|
41
|
+
assert.deepEqual(result, { other: "value" });
|
|
42
|
+
});
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// workiqEntryExists
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
test("workiqEntryExists: true when workiq key present", () => {
|
|
47
|
+
assert.equal(workiqEntryExists({ mcpServers: { workiq: {} } }), true);
|
|
48
|
+
});
|
|
49
|
+
test("workiqEntryExists: false when workiq key absent", () => {
|
|
50
|
+
assert.equal(workiqEntryExists({ mcpServers: { other: {} } }), false);
|
|
51
|
+
});
|
|
52
|
+
test("workiqEntryExists: false when mcpServers missing", () => {
|
|
53
|
+
assert.equal(workiqEntryExists({}), false);
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// buildWorkiqEntry
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
test("buildWorkiqEntry: returns correct npx entry shape", () => {
|
|
59
|
+
const entry = buildWorkiqEntry();
|
|
60
|
+
assert.equal(entry.command, "npx");
|
|
61
|
+
assert.deepEqual(entry.args, ["-y", WORKIQ_PACKAGE]);
|
|
62
|
+
assert.deepEqual(entry.tools, ["*"]);
|
|
63
|
+
});
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// ensureWorkiqMcpEntry
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
function makeFs(initialJson) {
|
|
68
|
+
let stored = initialJson ?? null;
|
|
69
|
+
const written = [];
|
|
70
|
+
return {
|
|
71
|
+
readFile: (path, _enc) => {
|
|
72
|
+
if (stored === null)
|
|
73
|
+
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
74
|
+
return stored;
|
|
75
|
+
},
|
|
76
|
+
writeFile: (path, data, _enc) => {
|
|
77
|
+
stored = data;
|
|
78
|
+
written.push(data);
|
|
79
|
+
},
|
|
80
|
+
mkdirSync: (_path, _opts) => { },
|
|
81
|
+
written,
|
|
82
|
+
getStored: () => stored,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
test("ensureWorkiqMcpEntry: installs when file does not exist", () => {
|
|
86
|
+
const fs = makeFs();
|
|
87
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
88
|
+
assert.equal(result, "installed");
|
|
89
|
+
const written = JSON.parse(fs.getStored());
|
|
90
|
+
assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY]);
|
|
91
|
+
assert.equal(written.mcpServers[WORKIQ_SERVER_KEY].command, "npx");
|
|
92
|
+
});
|
|
93
|
+
test("ensureWorkiqMcpEntry: installs when file exists without workiq entry", () => {
|
|
94
|
+
const initial = JSON.stringify({ mcpServers: { other: { command: "node", args: [], tools: ["*"] } } });
|
|
95
|
+
const fs = makeFs(initial);
|
|
96
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
97
|
+
assert.equal(result, "installed");
|
|
98
|
+
const written = JSON.parse(fs.getStored());
|
|
99
|
+
assert.ok(written.mcpServers?.other, "existing entry preserved");
|
|
100
|
+
assert.ok(written.mcpServers?.[WORKIQ_SERVER_KEY], "workiq entry added");
|
|
101
|
+
});
|
|
102
|
+
test("ensureWorkiqMcpEntry: already-present when workiq entry exists", () => {
|
|
103
|
+
const initial = JSON.stringify({ mcpServers: { workiq: { command: "npx", args: ["-y", "@microsoft/workiq"], tools: ["*"] } } });
|
|
104
|
+
const fs = makeFs(initial);
|
|
105
|
+
const result = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
106
|
+
assert.equal(result, "already-present");
|
|
107
|
+
assert.equal(fs.written.length, 0, "no write performed");
|
|
108
|
+
});
|
|
109
|
+
test("ensureWorkiqMcpEntry: idempotent on second call", () => {
|
|
110
|
+
const fs = makeFs();
|
|
111
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
112
|
+
const result2 = ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
113
|
+
assert.equal(result2, "already-present");
|
|
114
|
+
assert.equal(fs.written.length, 1, "only one write total");
|
|
115
|
+
});
|
|
116
|
+
test("ensureWorkiqMcpEntry: returns skipped on write failure", () => {
|
|
117
|
+
const failingWrite = (_p, _d, _e) => {
|
|
118
|
+
throw new Error("EROFS: read-only file system");
|
|
119
|
+
};
|
|
120
|
+
const result = ensureWorkiqMcpEntry({
|
|
121
|
+
configPath: "/fake/mcp-config.json",
|
|
122
|
+
readFile: (_p, _e) => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); },
|
|
123
|
+
writeFile: failingWrite,
|
|
124
|
+
mkdirSync: () => { },
|
|
125
|
+
});
|
|
126
|
+
assert.equal(result, "skipped");
|
|
127
|
+
});
|
|
128
|
+
test("ensureWorkiqMcpEntry: preserves other mcpServers entries on install", () => {
|
|
129
|
+
const initial = JSON.stringify({
|
|
130
|
+
mcpServers: {
|
|
131
|
+
custom: { command: "node", args: ["server.js"], tools: ["myTool"] },
|
|
132
|
+
},
|
|
133
|
+
someOtherKey: true,
|
|
134
|
+
});
|
|
135
|
+
const fs = makeFs(initial);
|
|
136
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
137
|
+
const written = JSON.parse(fs.getStored());
|
|
138
|
+
assert.ok(written.mcpServers?.custom, "existing server preserved");
|
|
139
|
+
assert.equal(written.someOtherKey, true, "unrelated keys preserved");
|
|
140
|
+
});
|
|
141
|
+
test("ensureWorkiqMcpEntry: creates well-formed JSON ending in newline", () => {
|
|
142
|
+
const fs = makeFs();
|
|
143
|
+
ensureWorkiqMcpEntry({ configPath: "/fake/mcp-config.json", ...fs });
|
|
144
|
+
assert.ok(fs.getStored().endsWith("\n"), "file ends with newline");
|
|
145
|
+
// Verify it's valid JSON
|
|
146
|
+
assert.doesNotThrow(() => JSON.parse(fs.getStored()));
|
|
147
|
+
});
|
|
148
|
+
//# sourceMappingURL=workiq-installer.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -18,6 +18,7 @@ import { DecisionsSyncScheduler } from "./squad/mirror.scheduler.js";
|
|
|
18
18
|
import { registerShutdownSignals } from "./shutdown-signals.js";
|
|
19
19
|
import { logger } from "./util/logger.js";
|
|
20
20
|
import { CHAPTERHOUSE_VERSION } from "./version.js";
|
|
21
|
+
import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/workiq-installer.js";
|
|
21
22
|
const log = logger.child({ module: "daemon" });
|
|
22
23
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
24
|
/**
|
|
@@ -119,6 +120,15 @@ async function main() {
|
|
|
119
120
|
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
120
121
|
log.warn("TELEGRAM_BOT_TOKEN found in env — Telegram support was removed in v2. The web UI is now the only client.");
|
|
121
122
|
}
|
|
123
|
+
// Auto-install workiq MCP server when Entra is configured
|
|
124
|
+
if (isWorkiqAutoInstallEnabled({
|
|
125
|
+
entraAuthEnabled: config.entraAuthEnabled,
|
|
126
|
+
entraTenantId: config.entraTenantId,
|
|
127
|
+
workiqAutoInstall: config.workiqAutoInstall,
|
|
128
|
+
})) {
|
|
129
|
+
log.info("Entra auth detected — ensuring workiq MCP server is configured");
|
|
130
|
+
ensureWorkiqMcpEntry();
|
|
131
|
+
}
|
|
122
132
|
// Start Copilot SDK client
|
|
123
133
|
log.info("Starting Copilot SDK client");
|
|
124
134
|
const client = await getClient();
|
package/dist/store/db.js
CHANGED
|
@@ -150,6 +150,24 @@ export function getDb() {
|
|
|
150
150
|
if (!taskCols.some((c) => c.name === 'source')) {
|
|
151
151
|
db.exec(`ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'adhoc'`);
|
|
152
152
|
}
|
|
153
|
+
// agent_task_events: append-only per-task tool-call activity log for /workers streaming
|
|
154
|
+
db.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS agent_task_events (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
task_id TEXT NOT NULL REFERENCES agent_tasks(task_id) ON DELETE CASCADE,
|
|
158
|
+
seq INTEGER NOT NULL,
|
|
159
|
+
ts INTEGER NOT NULL,
|
|
160
|
+
kind TEXT NOT NULL CHECK(kind IN ('tool_start', 'tool_complete')),
|
|
161
|
+
tool_name TEXT,
|
|
162
|
+
summary TEXT
|
|
163
|
+
)
|
|
164
|
+
`);
|
|
165
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_agent_task_events_task_id ON agent_task_events(task_id, seq)`);
|
|
166
|
+
// Migrate: add event_seq column to agent_tasks for monotonic event numbering
|
|
167
|
+
const taskColsNow = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
168
|
+
if (!taskColsNow.some((c) => c.name === 'event_seq')) {
|
|
169
|
+
db.exec(`ALTER TABLE agent_tasks ADD COLUMN event_seq INTEGER NOT NULL DEFAULT 0`);
|
|
170
|
+
}
|
|
153
171
|
// Migrate: add last_used_at column to project_squads (epoch ms, nullable)
|
|
154
172
|
const projectCols = db.prepare(`PRAGMA table_info(project_squads)`).all();
|
|
155
173
|
if (!projectCols.some((c) => c.name === 'last_used_at')) {
|
|
@@ -313,11 +331,46 @@ export function getSessionMessages(sessionKey, limit) {
|
|
|
313
331
|
ts: r.ts,
|
|
314
332
|
}));
|
|
315
333
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Append one event to agent_task_events and return the new event.
|
|
336
|
+
* Uses a transaction so seq is monotonically incremented.
|
|
337
|
+
* Non-fatal: silently ignores DB errors (task may not exist yet due to race).
|
|
338
|
+
*/
|
|
339
|
+
export function appendTaskEvent(taskId, kind, toolName, summary) {
|
|
340
|
+
const db = getDb();
|
|
341
|
+
try {
|
|
342
|
+
return db.transaction(() => {
|
|
343
|
+
db.prepare(`UPDATE agent_tasks SET event_seq = event_seq + 1 WHERE task_id = ?`).run(taskId);
|
|
344
|
+
const row = db.prepare(`SELECT event_seq FROM agent_tasks WHERE task_id = ?`).get(taskId);
|
|
345
|
+
if (!row)
|
|
346
|
+
return undefined;
|
|
347
|
+
const seq = row.event_seq;
|
|
348
|
+
const ts = Date.now();
|
|
349
|
+
const info = db.prepare(`INSERT INTO agent_task_events (task_id, seq, ts, kind, tool_name, summary) VALUES (?, ?, ?, ?, ?, ?)`).run(taskId, seq, ts, kind, toolName, summary);
|
|
350
|
+
return { id: Number(info.lastInsertRowid), taskId, seq, ts, kind, toolName, summary };
|
|
351
|
+
})();
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Return all events for a task ordered by seq ascending.
|
|
359
|
+
*/
|
|
360
|
+
export function getTaskEvents(taskId, afterSeq = 0) {
|
|
361
|
+
const db = getDb();
|
|
362
|
+
const rows = db.prepare(`SELECT id, task_id, seq, ts, kind, tool_name, summary
|
|
363
|
+
FROM agent_task_events WHERE task_id = ? AND seq > ? ORDER BY seq ASC`).all(taskId, afterSeq);
|
|
364
|
+
return rows.map((r) => ({
|
|
365
|
+
id: r.id,
|
|
366
|
+
taskId: r.task_id,
|
|
367
|
+
seq: r.seq,
|
|
368
|
+
ts: r.ts,
|
|
369
|
+
kind: r.kind,
|
|
370
|
+
toolName: r.tool_name,
|
|
371
|
+
summary: r.summary,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
321
374
|
export function bumpProjectLastUsed(projectRoot) {
|
|
322
375
|
getDb()
|
|
323
376
|
.prepare(`UPDATE project_squads SET last_used_at = ? WHERE project_root = ?`)
|
package/dist/store/db.test.js
CHANGED
|
@@ -211,4 +211,73 @@ test("bumpProjectLastUsed is a no-op for unknown project_root (no throw)", async
|
|
|
211
211
|
dbModule.closeDb();
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// #86: agent_task_events — appendTaskEvent and getTaskEvents
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
test("#86: appendTaskEvent inserts a row and getTaskEvents returns it ordered by seq", async () => {
|
|
218
|
+
const dbModule = await loadDbModule();
|
|
219
|
+
try {
|
|
220
|
+
const db = dbModule.getDb();
|
|
221
|
+
// Insert a parent task row
|
|
222
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-001', 'kaylee', 'Fix streaming', 'running')`).run();
|
|
223
|
+
const ev1 = dbModule.appendTaskEvent("task-sse-001", "tool_start", "bash", "npm run build");
|
|
224
|
+
assert.ok(ev1, "appendTaskEvent must return the inserted event");
|
|
225
|
+
assert.equal(ev1.kind, "tool_start");
|
|
226
|
+
assert.equal(ev1.toolName, "bash");
|
|
227
|
+
assert.equal(ev1.summary, "npm run build");
|
|
228
|
+
assert.equal(ev1.seq, 1);
|
|
229
|
+
const ev2 = dbModule.appendTaskEvent("task-sse-001", "tool_complete", null, "ok");
|
|
230
|
+
assert.ok(ev2, "second appendTaskEvent must return a second event");
|
|
231
|
+
assert.equal(ev2.seq, 2, "seq must be monotonically incremented");
|
|
232
|
+
assert.equal(ev2.kind, "tool_complete");
|
|
233
|
+
const events = dbModule.getTaskEvents("task-sse-001");
|
|
234
|
+
assert.equal(events.length, 2, "getTaskEvents must return 2 events");
|
|
235
|
+
assert.equal(events[0].seq, 1);
|
|
236
|
+
assert.equal(events[1].seq, 2);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
dbModule.closeDb();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
test("#86: getTaskEvents with afterSeq filters earlier events", async () => {
|
|
243
|
+
const dbModule = await loadDbModule();
|
|
244
|
+
try {
|
|
245
|
+
const db = dbModule.getDb();
|
|
246
|
+
db.prepare(`INSERT INTO agent_tasks (task_id, agent_slug, description, status) VALUES ('task-sse-002', 'wash', 'UI fix', 'running')`).run();
|
|
247
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_start", "view", "/some/file");
|
|
248
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_complete", null, "ok");
|
|
249
|
+
dbModule.appendTaskEvent("task-sse-002", "tool_start", "bash", "git push");
|
|
250
|
+
const all = dbModule.getTaskEvents("task-sse-002");
|
|
251
|
+
assert.equal(all.length, 3, "all 3 events expected");
|
|
252
|
+
const afterFirst = dbModule.getTaskEvents("task-sse-002", 1);
|
|
253
|
+
assert.equal(afterFirst.length, 2, "afterSeq=1 must return only events with seq > 1");
|
|
254
|
+
assert.equal(afterFirst[0].seq, 2);
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
dbModule.closeDb();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
test("#86: appendTaskEvent returns undefined (non-fatal) for unknown task_id", async () => {
|
|
261
|
+
const dbModule = await loadDbModule();
|
|
262
|
+
try {
|
|
263
|
+
dbModule.getDb();
|
|
264
|
+
const result = dbModule.appendTaskEvent("no-such-task", "tool_start", "bash", "echo hi");
|
|
265
|
+
assert.equal(result, undefined, "appendTaskEvent must return undefined for unknown task_id");
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
dbModule.closeDb();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
test("#86: agent_task_events table exists in schema after getDb()", async () => {
|
|
272
|
+
const dbModule = await loadDbModule();
|
|
273
|
+
try {
|
|
274
|
+
const db = dbModule.getDb();
|
|
275
|
+
const tables = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all();
|
|
276
|
+
const tableNames = new Set(tables.map((r) => r.name));
|
|
277
|
+
assert.ok(tableNames.has("agent_task_events"), "agent_task_events table must exist");
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
dbModule.closeDb();
|
|
281
|
+
}
|
|
282
|
+
});
|
|
214
283
|
//# sourceMappingURL=db.test.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chapterhouse",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Chapterhouse — a team-level AI assistant for engineering teams, built on the GitHub Copilot SDK. Web UI only.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"chapterhouse": "dist/cli.js"
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"dev:server": "tsx --watch src/daemon.ts",
|
|
23
23
|
"dev:web": "npm --prefix web run dev",
|
|
24
24
|
"dev": "tsx --watch src/daemon.ts",
|
|
25
|
+
"lint:md": "markdownlint-cli2 'README.md' 'CHANGELOG.md' 'docs/**/*.md' '.github/**/*.md'",
|
|
25
26
|
"release:check": "if [ -n \"$(git status --porcelain)\" ]; then echo '❌ Working tree is not clean. Stage or stash changes before running npm version.'; git status --short; exit 1; fi",
|
|
26
27
|
"preversion": "npm run release:check",
|
|
27
28
|
"prepare": "husky",
|
|
@@ -73,6 +74,7 @@
|
|
|
73
74
|
"@types/jsonwebtoken": "^9.0.10",
|
|
74
75
|
"@types/node": "^25.6.0",
|
|
75
76
|
"husky": "^9.1.7",
|
|
77
|
+
"markdownlint-cli2": "^0.22.1",
|
|
76
78
|
"tsx": "^4.21.0",
|
|
77
79
|
"typescript": "^5.9.3"
|
|
78
80
|
}
|
|
@@ -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:disabled{opacity:.5;cursor:not-allowed}.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.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-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}.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}}
|