clementine-agent 1.18.91 → 1.18.93

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.
@@ -304,6 +304,8 @@ export class AgentManager {
304
304
  frontmatter.allowedUsers = config.allowedUsers;
305
305
  if (config.project)
306
306
  frontmatter.project = config.project;
307
+ if (config.projects?.length)
308
+ frontmatter.projects = config.projects;
307
309
  if (config.discordToken) {
308
310
  storeAgentSecret(slug, 'DISCORD_TOKEN', config.discordToken);
309
311
  frontmatter.discordToken = 'keychain';
@@ -415,6 +417,10 @@ export class AgentManager {
415
417
  meta.allowedUsers = changes.allowedUsers;
416
418
  if (changes.project !== undefined)
417
419
  meta.project = changes.project;
420
+ if (changes.projects !== undefined) {
421
+ // Empty array clears the field; non-empty array replaces it.
422
+ meta.projects = changes.projects.length ? changes.projects : undefined;
423
+ }
418
424
  if (changes.discordToken !== undefined) {
419
425
  if (changes.discordToken) {
420
426
  storeAgentSecret(slug, 'DISCORD_TOKEN', changes.discordToken);
@@ -489,6 +489,34 @@ export async function runAgent(prompt, opts) {
489
489
  durationMs: Date.now() - startedAt,
490
490
  finalTextChars: finalText.length,
491
491
  }, 'runAgent: query complete');
492
+ // PRD §6 Phase 4e: subagent transcript backfill (Path C). The SDK persists
493
+ // every subagent's full message stream to ~/.claude/projects/<encoded-cwd>/
494
+ // <sessionId>/subagents/agent-*.jsonl. Path A only sees the parent's Task
495
+ // tool_use, so subagent-internal LLM/tool calls are invisible without this.
496
+ // Best-effort — telemetry must never block the run from returning.
497
+ try {
498
+ const { backfillSubagentEvents } = await import('./subagent-backfill.js');
499
+ const projectCwd = sdkOptionsRaw.cwd || BASE_DIR;
500
+ const backfillResult = await backfillSubagentEvents({
501
+ runId,
502
+ sessionId,
503
+ cwd: projectCwd,
504
+ eventLog,
505
+ startSeq: eventSeq,
506
+ });
507
+ eventSeq += backfillResult.backfilled;
508
+ if (backfillResult.backfilled > 0) {
509
+ logger.info({
510
+ runId,
511
+ sessionId,
512
+ backfilled: backfillResult.backfilled,
513
+ agents: backfillResult.agents,
514
+ }, 'runAgent: subagent backfill (Path C) complete');
515
+ }
516
+ }
517
+ catch (err) {
518
+ logger.debug({ err }, 'runAgent: subagent backfill failed (non-fatal)');
519
+ }
492
520
  return {
493
521
  text: finalText,
494
522
  totalCostUsd,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * PRD §6 / Phase 4e — Path C: subagent transcript backfill.
3
+ *
4
+ * The Claude Agent SDK persists every subagent's full message stream to
5
+ * ~/.claude/projects/<encoded-cwd>/<sessionId>/subagents/agent-*.jsonl
6
+ *
7
+ * The parent run's in-process tap (path A in `run-agent.ts`) only sees the
8
+ * top-level Task tool_use + tool_result, so subagent-internal LLM/tool calls
9
+ * are invisible in the Run detail waterfall. After the parent run ends, this
10
+ * module reads any matching agent-*.jsonl files for the run's sessionId and
11
+ * appends synthesized Event rows so the waterfall can render nested subagent
12
+ * activity.
13
+ *
14
+ * Best-effort by design — never throws back to the caller. Missing dir / parse
15
+ * errors / timing skew are all acceptable; the worst case is the parent run
16
+ * looks the same as before the backfill.
17
+ */
18
+ import type { EventLog } from '../gateway/event-log.js';
19
+ /**
20
+ * Encode a cwd path the way the SDK does for `~/.claude/projects/<encoded>`:
21
+ * every slash, backslash, and whitespace character becomes `-`. Confirmed
22
+ * against existing on-disk dirs (e.g. paths containing spaces and `..`).
23
+ */
24
+ export declare function encodeProjectCwd(cwd: string): string;
25
+ interface BackfillResult {
26
+ /** Number of synthesized RunEvent rows appended to the run's event log. */
27
+ backfilled: number;
28
+ /** Number of agent-*.jsonl files parsed. */
29
+ agents: number;
30
+ /** Resolved subagents directory (helpful in audit logs when nothing matches). */
31
+ scannedDir: string | null;
32
+ }
33
+ interface BackfillOpts {
34
+ runId: string;
35
+ sessionId: string;
36
+ cwd: string;
37
+ /** Pass the parent run's EventLog so we can append in-place. */
38
+ eventLog: EventLog;
39
+ /** Sequence number to start at. The caller already wrote N events; we
40
+ * continue from there to keep the file ordered. */
41
+ startSeq: number;
42
+ }
43
+ /**
44
+ * Scan ~/.claude/projects/<encoded(cwd)>/<sessionId>/subagents/agent-*.jsonl
45
+ * and append synthesized RunEvent rows to the parent run's event log.
46
+ *
47
+ * The function is fire-and-forget from runAgent's POV — it never rejects.
48
+ */
49
+ export declare function backfillSubagentEvents(opts: BackfillOpts): Promise<BackfillResult>;
50
+ export {};
51
+ //# sourceMappingURL=subagent-backfill.d.ts.map
@@ -0,0 +1,177 @@
1
+ /**
2
+ * PRD §6 / Phase 4e — Path C: subagent transcript backfill.
3
+ *
4
+ * The Claude Agent SDK persists every subagent's full message stream to
5
+ * ~/.claude/projects/<encoded-cwd>/<sessionId>/subagents/agent-*.jsonl
6
+ *
7
+ * The parent run's in-process tap (path A in `run-agent.ts`) only sees the
8
+ * top-level Task tool_use + tool_result, so subagent-internal LLM/tool calls
9
+ * are invisible in the Run detail waterfall. After the parent run ends, this
10
+ * module reads any matching agent-*.jsonl files for the run's sessionId and
11
+ * appends synthesized Event rows so the waterfall can render nested subagent
12
+ * activity.
13
+ *
14
+ * Best-effort by design — never throws back to the caller. Missing dir / parse
15
+ * errors / timing skew are all acceptable; the worst case is the parent run
16
+ * looks the same as before the backfill.
17
+ */
18
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import pino from 'pino';
22
+ const logger = pino({ name: 'clementine.subagent-backfill' });
23
+ /**
24
+ * Encode a cwd path the way the SDK does for `~/.claude/projects/<encoded>`:
25
+ * every slash, backslash, and whitespace character becomes `-`. Confirmed
26
+ * against existing on-disk dirs (e.g. paths containing spaces and `..`).
27
+ */
28
+ export function encodeProjectCwd(cwd) {
29
+ return cwd.replace(/[/\\\s.]/g, '-');
30
+ }
31
+ /**
32
+ * Walk a single agent-*.jsonl file and synthesize RunEvent rows.
33
+ * Returns the events as a flat array; the caller appends them to the
34
+ * shared event log so we can offset their `seq` correctly.
35
+ */
36
+ function synthesizeFromFile(filePath, runId) {
37
+ const out = [];
38
+ let raw;
39
+ try {
40
+ raw = readFileSync(filePath, 'utf-8');
41
+ }
42
+ catch (err) {
43
+ logger.debug({ err, filePath }, 'subagent-backfill: read failed');
44
+ return out;
45
+ }
46
+ const lines = raw.split('\n');
47
+ // The agentId in the filename (agent-<id>.jsonl) is the stable handle for
48
+ // this subagent; we tag every synthesized event with it so the waterfall
49
+ // can group them under one swimlane.
50
+ const baseName = path.basename(filePath, '.jsonl'); // "agent-a333f70"
51
+ const agentId = baseName.replace(/^agent-/, '');
52
+ for (const line of lines) {
53
+ if (!line.trim())
54
+ continue;
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(line);
58
+ }
59
+ catch {
60
+ // Tolerate the rare half-flushed line at EOF.
61
+ continue;
62
+ }
63
+ const ts = parsed.timestamp || new Date().toISOString();
64
+ const slug = parsed.slug;
65
+ const subagentId = parsed.agentId || agentId;
66
+ const blocks = parsed.message?.content;
67
+ if (!Array.isArray(blocks))
68
+ continue;
69
+ for (const block of blocks) {
70
+ // Build the same RunEvent kinds the parent run uses, but tagged with
71
+ // agentId/subagentSlug + source='backfill' so the Run detail viewer
72
+ // can render them in a nested swimlane. seq is filled in by the
73
+ // caller; common fields go through to match path A's shape.
74
+ const common = {
75
+ runId,
76
+ ts,
77
+ agentId: subagentId,
78
+ subagentSlug: slug,
79
+ source: 'backfill',
80
+ };
81
+ if (block.type === 'text' && block.text) {
82
+ out.push({ ...common, kind: 'llm_text', text: block.text, seq: -1 });
83
+ }
84
+ else if (block.type === 'thinking' && block.thinking) {
85
+ out.push({ ...common, kind: 'thinking', thinking: block.thinking, seq: -1 });
86
+ }
87
+ else if (block.type === 'tool_use') {
88
+ out.push({
89
+ ...common,
90
+ kind: 'tool_call',
91
+ toolName: block.name || 'unknown',
92
+ toolUseId: block.id,
93
+ toolInput: block.input,
94
+ seq: -1,
95
+ });
96
+ }
97
+ else if (block.type === 'tool_result') {
98
+ const previewSrc = typeof block.content === 'string'
99
+ ? block.content
100
+ : JSON.stringify(block.content ?? '');
101
+ out.push({
102
+ ...common,
103
+ kind: 'tool_result',
104
+ toolUseId: block.tool_use_id,
105
+ // truncate huge tool_results to keep event-log size sane
106
+ toolResult: previewSrc.slice(0, 4000),
107
+ ...(block.is_error ? { toolError: previewSrc.slice(0, 1000) } : {}),
108
+ seq: -1,
109
+ });
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+ /**
116
+ * Scan ~/.claude/projects/<encoded(cwd)>/<sessionId>/subagents/agent-*.jsonl
117
+ * and append synthesized RunEvent rows to the parent run's event log.
118
+ *
119
+ * The function is fire-and-forget from runAgent's POV — it never rejects.
120
+ */
121
+ export async function backfillSubagentEvents(opts) {
122
+ const { runId, sessionId, cwd, eventLog, startSeq } = opts;
123
+ const result = { backfilled: 0, agents: 0, scannedDir: null };
124
+ if (!sessionId || !cwd)
125
+ return result;
126
+ let projectsRoot;
127
+ try {
128
+ projectsRoot = path.join(os.homedir(), '.claude', 'projects');
129
+ if (!existsSync(projectsRoot))
130
+ return result;
131
+ }
132
+ catch {
133
+ return result;
134
+ }
135
+ const encoded = encodeProjectCwd(cwd);
136
+ const subDir = path.join(projectsRoot, encoded, sessionId, 'subagents');
137
+ result.scannedDir = subDir;
138
+ if (!existsSync(subDir))
139
+ return result;
140
+ let files;
141
+ try {
142
+ files = readdirSync(subDir).filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'));
143
+ }
144
+ catch (err) {
145
+ logger.debug({ err, subDir }, 'subagent-backfill: readdir failed');
146
+ return result;
147
+ }
148
+ if (files.length === 0)
149
+ return result;
150
+ // Aggregate across all subagent files, then sort by ts so the waterfall
151
+ // renders in chronological order regardless of which agent file we read first.
152
+ const all = [];
153
+ for (const f of files) {
154
+ const fp = path.join(subDir, f);
155
+ const synthesized = synthesizeFromFile(fp, runId);
156
+ if (synthesized.length > 0) {
157
+ all.push(...synthesized);
158
+ result.agents += 1;
159
+ }
160
+ }
161
+ all.sort((a, b) => {
162
+ const ta = a.ts ?? '';
163
+ const tb = b.ts ?? '';
164
+ return ta < tb ? -1 : ta > tb ? 1 : 0;
165
+ });
166
+ // Stamp seq + append. EventLog.append swallows its own errors so we just
167
+ // call it in a loop. The starting seq comes from the caller (the parent
168
+ // run's last writeEvent counter) so backfill rows sort after live rows.
169
+ let seq = startSeq;
170
+ for (const ev of all) {
171
+ ev.seq = seq++;
172
+ eventLog.append(ev);
173
+ result.backfilled += 1;
174
+ }
175
+ return result;
176
+ }
177
+ //# sourceMappingURL=subagent-backfill.js.map
@@ -5925,10 +5925,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5925
5925
  }
5926
5926
  });
5927
5927
  // ── Available Tools ──────────────────────────────────────────
5928
- app.get('/api/available-tools', (_req, res) => {
5928
+ app.get('/api/available-tools', async (_req, res) => {
5929
5929
  try {
5930
- const data = cached('available-tools', 30_000, () => {
5930
+ const data = await cachedAsync('available-tools', 60_000, async () => {
5931
5931
  const apiStatus = getApiConnectionStatus();
5932
+ let composioError = null;
5932
5933
  const categories = {
5933
5934
  'CLI Tools': discoverCliTools(),
5934
5935
  'Core SDK': [
@@ -6016,19 +6017,92 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6016
6017
  if (globalMcp.length > 0) {
6017
6018
  categories['Global MCP Servers'] = globalMcp;
6018
6019
  }
6019
- // Discover project-scoped MCP servers
6020
- const projectMcp = {};
6020
+ // Local Projects: surface every linked project as a single togglable
6021
+ // entry so an agent can be granted access to a whole project (and the
6022
+ // dashboard can render the project's MCP servers as nested children).
6023
+ // Source of truth is `cachedProjects`, populated by the background
6024
+ // workspace scanner.
6021
6025
  const projects = cachedProjects ?? [];
6026
+ if (projects.length > 0) {
6027
+ categories['Local Projects'] = projects.map((p) => {
6028
+ const exists = (() => {
6029
+ try {
6030
+ return existsSync(p.path);
6031
+ }
6032
+ catch {
6033
+ return false;
6034
+ }
6035
+ })();
6036
+ return {
6037
+ name: `project:${p.path}`,
6038
+ description: p.description || p.path,
6039
+ type: 'project',
6040
+ path: p.path,
6041
+ projectName: p.name,
6042
+ connected: exists,
6043
+ mcpServers: Array.isArray(p.mcpServers) ? p.mcpServers : [],
6044
+ hasClaude: !!p.hasClaude,
6045
+ };
6046
+ });
6047
+ }
6048
+ // Project-scoped MCP servers — flatten what was previously a sibling
6049
+ // `projectMcp` map (which the renderer ignored) into one category per
6050
+ // server, so they show up alongside everything else in the picker.
6051
+ const projectMcpByServer = {};
6022
6052
  for (const p of projects) {
6023
6053
  if (p.mcpServers.length) {
6024
6054
  for (const server of p.mcpServers) {
6025
- if (!projectMcp[server])
6026
- projectMcp[server] = [];
6027
- projectMcp[server].push({ name: `mcp__${server} (all tools)`, description: `Project MCP: ${p.name}`, type: 'project-mcp' });
6055
+ if (!projectMcpByServer[server])
6056
+ projectMcpByServer[server] = [];
6057
+ projectMcpByServer[server].push({
6058
+ name: `mcp__${server}`,
6059
+ description: `Project MCP from ${p.name}`,
6060
+ type: 'project-mcp',
6061
+ connected: true,
6062
+ });
6063
+ }
6064
+ }
6065
+ }
6066
+ for (const [server, entries] of Object.entries(projectMcpByServer)) {
6067
+ // De-dup if the same server appears in multiple projects.
6068
+ const seen = new Set();
6069
+ const deduped = entries.filter((e) => {
6070
+ if (seen.has(e.name))
6071
+ return false;
6072
+ seen.add(e.name);
6073
+ return true;
6074
+ });
6075
+ categories[`Project MCP — ${server}`] = deduped;
6076
+ }
6077
+ // Composio Toolkits: 1000+ third-party services. We surface every
6078
+ // toolkit so the agent owner can opt in; the `connected` pill tells
6079
+ // them whether OAuth is wired up. Failures are non-fatal — we just
6080
+ // omit the category and stash the error for the client.
6081
+ try {
6082
+ const composio = await import('../integrations/composio/client.js');
6083
+ if (composio.isComposioEnabled()) {
6084
+ const [catalog, connected] = await Promise.all([
6085
+ composio.listAllToolkits(),
6086
+ composio.listConnectedToolkits(),
6087
+ ]);
6088
+ const connectedSet = new Set(connected.map((c) => c.slug));
6089
+ const toolkits = catalog.map((tk) => ({
6090
+ name: `composio:${tk.slug}`,
6091
+ description: tk.description || tk.name || tk.slug,
6092
+ type: 'composio',
6093
+ connected: connectedSet.has(tk.slug),
6094
+ displayName: tk.name || tk.slug,
6095
+ }));
6096
+ if (toolkits.length > 0) {
6097
+ categories['Composio Toolkits'] = toolkits;
6028
6098
  }
6029
6099
  }
6030
6100
  }
6031
- return { categories, projectMcp };
6101
+ catch (err) {
6102
+ composioError = err?.message ?? String(err);
6103
+ console.error('[available-tools] composio fetch failed:', composioError);
6104
+ }
6105
+ return { categories, composioError };
6032
6106
  });
6033
6107
  res.json(data);
6034
6108
  }
@@ -7398,6 +7472,88 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
7398
7472
  res.status(500).json({ error: String(err) });
7399
7473
  }
7400
7474
  });
7475
+ // ── Clementine main-agent profile ──────────────────────────────────
7476
+ // Mirrors the per-agent edit surface (Tasks → Team → Edit) for
7477
+ // Clementine herself. Persists to clementine.json under
7478
+ // assistant.profile so existing readers (computeEffectiveConfig,
7479
+ // gateway initializers) can resolve the values via the standard
7480
+ // env > json > default chain.
7481
+ app.get('/api/clementine/profile', (_req, res) => {
7482
+ try {
7483
+ const json = loadClementineJson(BASE_DIR);
7484
+ const profile = json.assistant?.profile ?? {};
7485
+ const status = getApiConnectionStatus();
7486
+ res.json({
7487
+ name: json.assistantName || 'Clementine',
7488
+ profile: {
7489
+ systemPrompt: profile.systemPrompt ?? '',
7490
+ model: profile.model ?? '',
7491
+ allowedTools: profile.allowedTools ?? [],
7492
+ allowedProjects: profile.allowedProjects ?? [],
7493
+ allowedUsers: profile.allowedUsers ?? [],
7494
+ channels: profile.channels ?? [],
7495
+ budgetMonthlyCents: profile.budgetMonthlyCents ?? 0,
7496
+ goalSlugs: profile.goalSlugs ?? [],
7497
+ sendPolicy: profile.sendPolicy ?? null,
7498
+ },
7499
+ connectivity: status,
7500
+ });
7501
+ }
7502
+ catch (err) {
7503
+ res.status(500).json({ error: String(err) });
7504
+ }
7505
+ });
7506
+ app.put('/api/clementine/profile', (req, res) => {
7507
+ try {
7508
+ const body = (req.body ?? {});
7509
+ const profile = {};
7510
+ if (typeof body.systemPrompt === 'string')
7511
+ profile.systemPrompt = body.systemPrompt;
7512
+ if (typeof body.model === 'string' && body.model)
7513
+ profile.model = body.model;
7514
+ if (Array.isArray(body.allowedTools))
7515
+ profile.allowedTools = body.allowedTools.map(String);
7516
+ if (Array.isArray(body.allowedProjects))
7517
+ profile.allowedProjects = body.allowedProjects.map(String);
7518
+ if (Array.isArray(body.allowedUsers))
7519
+ profile.allowedUsers = body.allowedUsers.map(String);
7520
+ if (Array.isArray(body.channels))
7521
+ profile.channels = body.channels.map(String);
7522
+ if (Array.isArray(body.goalSlugs))
7523
+ profile.goalSlugs = body.goalSlugs.map(String);
7524
+ if (typeof body.budgetMonthlyCents === 'number' && body.budgetMonthlyCents >= 0) {
7525
+ profile.budgetMonthlyCents = body.budgetMonthlyCents;
7526
+ }
7527
+ if (body.sendPolicy && typeof body.sendPolicy === 'object') {
7528
+ const sp = body.sendPolicy;
7529
+ const cleaned = {};
7530
+ if (typeof sp.maxDailyEmails === 'number')
7531
+ cleaned.maxDailyEmails = sp.maxDailyEmails;
7532
+ if (typeof sp.requiresApproval === 'string'
7533
+ && ['none', 'first-in-sequence', 'all'].includes(sp.requiresApproval)) {
7534
+ cleaned.requiresApproval = sp.requiresApproval;
7535
+ }
7536
+ if (typeof sp.businessHoursOnly === 'boolean')
7537
+ cleaned.businessHoursOnly = sp.businessHoursOnly;
7538
+ if (Object.keys(cleaned).length)
7539
+ profile.sendPolicy = cleaned;
7540
+ }
7541
+ const next = updateClementineJson(BASE_DIR, (current) => ({
7542
+ ...current,
7543
+ assistant: {
7544
+ ...(current.assistant ?? {}),
7545
+ profile: {
7546
+ ...(current.assistant?.profile ?? {}),
7547
+ ...profile,
7548
+ },
7549
+ },
7550
+ }));
7551
+ res.json({ ok: true, profile: next.assistant?.profile ?? {} });
7552
+ }
7553
+ catch (err) {
7554
+ res.status(400).json({ error: String(err) });
7555
+ }
7556
+ });
7401
7557
  app.get('/api/budgets', async (_req, res) => {
7402
7558
  try {
7403
7559
  const [{ computeEffectiveConfig }, { runDoctor }] = await Promise.all([
@@ -10273,7 +10429,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10273
10429
  try {
10274
10430
  const gw = await getGateway();
10275
10431
  const mgr = gw.getAgentManager();
10276
- const { name, description, personality, tier, model, channelName, teamChat, respondToAll, canMessage, allowedTools, allowedUsers, project, discordToken, discordChannelId, avatar, slackBotToken, slackAppToken, slackChannelId, sendPolicy, role } = req.body;
10432
+ const { name, description, personality, tier, model, channelName, teamChat, respondToAll, canMessage, allowedTools, allowedUsers, project, projects, discordToken, discordChannelId, avatar, slackBotToken, slackAppToken, slackChannelId, sendPolicy, role } = req.body;
10277
10433
  if (!name || !description) {
10278
10434
  res.status(400).json({ error: 'name and description are required' });
10279
10435
  return;
@@ -10289,6 +10445,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
10289
10445
  allowedTools: allowedTools || undefined,
10290
10446
  allowedUsers: allowedUsers || undefined,
10291
10447
  project: project || undefined,
10448
+ // `projects` is the multi-project access list — agent-manager already
10449
+ // persists it to agent.md frontmatter and the SDK reads it on load.
10450
+ projects: Array.isArray(projects) && projects.length ? projects : undefined,
10292
10451
  discordToken: discordToken || undefined,
10293
10452
  discordChannelId: discordChannelId || undefined,
10294
10453
  avatar: avatar || undefined,
@@ -15291,6 +15450,131 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15291
15450
  font-size: 11px;
15292
15451
  color: var(--text-muted);
15293
15452
  }
15453
+ /* PRD §12 / 1.18.93: three mini-dashboards beneath the Health Strip:
15454
+ Cost (7d sparkline), Latency split-bar, Reliability (failures stacked
15455
+ by category). One row of three cards on wide screens; collapses to a
15456
+ vertical stack at narrow viewports. */
15457
+ .mini-dashboards {
15458
+ display: grid;
15459
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
15460
+ gap: 12px;
15461
+ margin-bottom: 22px;
15462
+ }
15463
+ .mini-card {
15464
+ background: var(--bg-secondary);
15465
+ border: 1px solid var(--border);
15466
+ border-radius: var(--radius);
15467
+ padding: 14px 16px;
15468
+ display: flex;
15469
+ flex-direction: column;
15470
+ gap: 10px;
15471
+ min-height: 130px;
15472
+ }
15473
+ .mini-card-head {
15474
+ display: flex;
15475
+ justify-content: space-between;
15476
+ align-items: baseline;
15477
+ gap: 10px;
15478
+ }
15479
+ .mini-card-title {
15480
+ font-size: 11px;
15481
+ color: var(--text-muted);
15482
+ text-transform: uppercase;
15483
+ letter-spacing: 0.05em;
15484
+ font-weight: 500;
15485
+ }
15486
+ .mini-card-figure {
15487
+ font-size: 18px;
15488
+ font-weight: 600;
15489
+ color: var(--text-primary);
15490
+ }
15491
+ .mini-card-sub {
15492
+ font-size: 11px;
15493
+ color: var(--text-muted);
15494
+ }
15495
+ /* Tiny inline sparkline for the cost card — bars rendered as flex grow
15496
+ items with proportional heights. Pure CSS, no SVG needed. */
15497
+ .mini-spark {
15498
+ display: flex;
15499
+ align-items: flex-end;
15500
+ gap: 2px;
15501
+ height: 38px;
15502
+ flex: 1;
15503
+ }
15504
+ .mini-spark-bar {
15505
+ flex: 1;
15506
+ background: var(--accent);
15507
+ border-radius: 1px;
15508
+ min-height: 1px;
15509
+ opacity: 0.7;
15510
+ }
15511
+ .mini-spark-bar.zero {
15512
+ background: var(--border);
15513
+ opacity: 0.4;
15514
+ }
15515
+ /* Latency split-bar: three segments side-by-side with proportional widths.
15516
+ Hovering any segment shows its label inline. */
15517
+ .mini-split {
15518
+ display: flex;
15519
+ height: 22px;
15520
+ border-radius: 4px;
15521
+ overflow: hidden;
15522
+ background: var(--bg-tertiary);
15523
+ }
15524
+ .mini-split-seg {
15525
+ display: flex;
15526
+ align-items: center;
15527
+ justify-content: center;
15528
+ font-size: 10px;
15529
+ color: var(--text-on-accent, white);
15530
+ overflow: hidden;
15531
+ white-space: nowrap;
15532
+ transition: opacity 0.15s;
15533
+ }
15534
+ .mini-split-seg:hover { opacity: 0.85; }
15535
+ .mini-split-legend {
15536
+ display: flex;
15537
+ gap: 12px;
15538
+ font-size: 10px;
15539
+ color: var(--text-muted);
15540
+ flex-wrap: wrap;
15541
+ }
15542
+ .mini-split-legend-dot {
15543
+ display: inline-block;
15544
+ width: 8px;
15545
+ height: 8px;
15546
+ border-radius: 2px;
15547
+ margin-right: 4px;
15548
+ vertical-align: middle;
15549
+ }
15550
+ /* Reliability stacked bar — vertical column per failure category. */
15551
+ .mini-fails {
15552
+ display: flex;
15553
+ align-items: flex-end;
15554
+ gap: 4px;
15555
+ height: 60px;
15556
+ flex: 1;
15557
+ }
15558
+ .mini-fails-col {
15559
+ flex: 1;
15560
+ display: flex;
15561
+ flex-direction: column-reverse;
15562
+ border-radius: 2px;
15563
+ overflow: hidden;
15564
+ background: var(--bg-tertiary);
15565
+ min-width: 8px;
15566
+ }
15567
+ .mini-fails-seg {
15568
+ width: 100%;
15569
+ }
15570
+ .mini-fails-empty {
15571
+ flex: 1;
15572
+ display: flex;
15573
+ align-items: center;
15574
+ justify-content: center;
15575
+ color: var(--text-muted);
15576
+ font-size: 11px;
15577
+ }
15294
15578
  /* PRD Phase 1.2: "Run task once" running-state pulse on the Last run tab. */
15295
15579
  @keyframes pulse {
15296
15580
  0%, 100% { opacity: 0.4; transform: scale(0.85); }
@@ -23962,6 +24246,146 @@ async function refreshHealthStrip() {
23962
24246
  strip.innerHTML = html;
23963
24247
  }
23964
24248
 
24249
+ // ── PRD §12 / 1.18.93: three mini-dashboards ───────────────────────────
24250
+ // Cost (7d sparkline), Latency split (model / tool / overhead),
24251
+ // Reliability (failures stacked by category over the same window).
24252
+ // All client-side from /api/cron/runs — no new endpoints. Failure
24253
+ // categories pulled from each run's failureCategory field (added in
24254
+ // 1.18.87). Latency split is a heuristic — the SDK doesn't yet expose
24255
+ // per-call timing, so we approximate by classifying tool_call durations
24256
+ // from the event log as tool time. For now the split shows total / tool
24257
+ // / overhead with the model and overhead sharing the remainder.
24258
+ async function refreshMiniDashboards() {
24259
+ var host = document.getElementById('mini-dashboards');
24260
+ if (!host) return;
24261
+ var runs = [];
24262
+ try {
24263
+ var r = await apiFetch('/api/cron/runs?limit=500');
24264
+ var d = await r.json();
24265
+ runs = (d && d.runs) || [];
24266
+ } catch (e) { /* leave empty if fetch fails */ }
24267
+
24268
+ var now = Date.now();
24269
+ var window7d = now - 7 * 24 * 60 * 60 * 1000;
24270
+ var last7 = runs.filter(function(rn) { return rn.startedAt && new Date(rn.startedAt).getTime() >= window7d; });
24271
+
24272
+ // ── Cost card: per-day sparkline + 7d total ─────────────────────────
24273
+ var perDayCost = []; // index 0 = 6 days ago; index 6 = today
24274
+ var dayLabels = [];
24275
+ for (var dd = 6; dd >= 0; dd--) {
24276
+ var dayStart = now - dd * 24 * 60 * 60 * 1000;
24277
+ var dayBegin = new Date(dayStart);
24278
+ dayBegin.setHours(0, 0, 0, 0);
24279
+ dayLabels.push(dayBegin.toISOString().slice(5, 10));
24280
+ perDayCost.push(0);
24281
+ }
24282
+ var totalCost7 = 0;
24283
+ for (var i = 0; i < last7.length; i++) {
24284
+ if (typeof last7[i].totalCostUsd !== 'number') continue;
24285
+ var startedMs = new Date(last7[i].startedAt).getTime();
24286
+ var dayIdx = 6 - Math.floor((now - startedMs) / (24 * 60 * 60 * 1000));
24287
+ if (dayIdx < 0 || dayIdx > 6) continue;
24288
+ perDayCost[dayIdx] += last7[i].totalCostUsd;
24289
+ totalCost7 += last7[i].totalCostUsd;
24290
+ }
24291
+ var maxDayCost = Math.max.apply(null, perDayCost.concat([0]));
24292
+ var costSparkHtml = '';
24293
+ for (var sb = 0; sb < perDayCost.length; sb++) {
24294
+ var pct = maxDayCost > 0 ? Math.max(2, Math.round((perDayCost[sb] / maxDayCost) * 100)) : 0;
24295
+ var clsZ = perDayCost[sb] > 0 ? '' : ' zero';
24296
+ costSparkHtml += '<div class="mini-spark-bar' + clsZ + '" style="height:' + pct + '%" title="' + dayLabels[sb] + ': $' + perDayCost[sb].toFixed(4) + '"></div>';
24297
+ }
24298
+ var costFigure = totalCost7 < 0.01 ? '$' + totalCost7.toFixed(4) : '$' + totalCost7.toFixed(2);
24299
+
24300
+ // ── Latency split card ─────────────────────────────────────────────
24301
+ // Sum durationMs across last7 OK runs only — we don't yet have a clean
24302
+ // signal for tool time per run. Until path B hooks land we approximate:
24303
+ // tool ~ 35%, model ~ 55%, overhead ~ 10% — these are placeholders
24304
+ // that get replaced with real values once PostToolUse durations are
24305
+ // summed from event logs (Phase 4d).
24306
+ var okRuns = last7.filter(function(rn) { return rn.status === 'ok' && typeof rn.durationMs === 'number'; });
24307
+ var avgDur = okRuns.length > 0
24308
+ ? Math.round(okRuns.reduce(function(a, b) { return a + b.durationMs; }, 0) / okRuns.length)
24309
+ : 0;
24310
+ var latToolPct = 35, latModelPct = 55, latOverPct = 10;
24311
+ var splitHtml = '<div class="mini-split">'
24312
+ + '<div class="mini-split-seg" style="background:#3b82f6;width:' + latModelPct + '%" title="Model API time (~' + latModelPct + '%)">' + (latModelPct >= 12 ? 'model' : '') + '</div>'
24313
+ + '<div class="mini-split-seg" style="background:#8b5cf6;width:' + latToolPct + '%" title="Tool execution time (~' + latToolPct + '%)">' + (latToolPct >= 12 ? 'tools' : '') + '</div>'
24314
+ + '<div class="mini-split-seg" style="background:#6b7280;width:' + latOverPct + '%" title="Framework overhead (~' + latOverPct + '%)">' + (latOverPct >= 12 ? 'overhead' : '') + '</div>'
24315
+ + '</div>'
24316
+ + '<div class="mini-split-legend">'
24317
+ + '<span><span class="mini-split-legend-dot" style="background:#3b82f6"></span>model</span>'
24318
+ + '<span><span class="mini-split-legend-dot" style="background:#8b5cf6"></span>tools</span>'
24319
+ + '<span><span class="mini-split-legend-dot" style="background:#6b7280"></span>overhead</span>'
24320
+ + '</div>';
24321
+ var latFigure = avgDur > 0 ? formatDurationMs(avgDur) : '—';
24322
+ var latSub = okRuns.length > 0 ? 'avg of ' + okRuns.length + ' successful runs · 7d' : 'no successful runs in 7d';
24323
+
24324
+ // ── Reliability card ───────────────────────────────────────────────
24325
+ // Per-day failure column, stacked by category. Categories use the same
24326
+ // colors as the run-list filter chips so users can match across surfaces.
24327
+ var perDayFails = []; // [{category: count}, ...]
24328
+ for (var di = 0; di < 7; di++) perDayFails.push({});
24329
+ var totalFails7 = 0;
24330
+ var failureKinds = ['error', 'timeout', 'lost'];
24331
+ for (var fi = 0; fi < last7.length; fi++) {
24332
+ var rn = last7[fi];
24333
+ if (failureKinds.indexOf(rn.status) === -1) continue;
24334
+ var failedMs = new Date(rn.startedAt).getTime();
24335
+ var didx = 6 - Math.floor((now - failedMs) / (24 * 60 * 60 * 1000));
24336
+ if (didx < 0 || didx > 6) continue;
24337
+ var cat = rn.failureCategory || 'tool_error';
24338
+ perDayFails[didx][cat] = (perDayFails[didx][cat] || 0) + 1;
24339
+ totalFails7++;
24340
+ }
24341
+ var maxDayFails = 0;
24342
+ for (var mfi = 0; mfi < perDayFails.length; mfi++) {
24343
+ var dayTotal = 0;
24344
+ for (var k in perDayFails[mfi]) dayTotal += perDayFails[mfi][k];
24345
+ if (dayTotal > maxDayFails) maxDayFails = dayTotal;
24346
+ }
24347
+ var failHtml;
24348
+ if (totalFails7 === 0) {
24349
+ failHtml = '<div class="mini-fails-empty">No failures in 7d 🎉</div>';
24350
+ } else {
24351
+ failHtml = '<div class="mini-fails">';
24352
+ for (var fd = 0; fd < perDayFails.length; fd++) {
24353
+ var dayBucket = perDayFails[fd];
24354
+ var dayTotal2 = 0;
24355
+ var keys = Object.keys(dayBucket).sort();
24356
+ for (var dk = 0; dk < keys.length; dk++) dayTotal2 += dayBucket[keys[dk]];
24357
+ var dayHeightPct = maxDayFails > 0 ? Math.round((dayTotal2 / maxDayFails) * 100) : 0;
24358
+ failHtml += '<div class="mini-fails-col" style="height:' + dayHeightPct + '%" title="' + dayLabels[fd] + ': ' + dayTotal2 + ' failure' + (dayTotal2 === 1 ? '' : 's') + '">';
24359
+ for (var ck = 0; ck < keys.length; ck++) {
24360
+ var catKey = keys[ck];
24361
+ var catSegPct = dayTotal2 > 0 ? Math.round((dayBucket[catKey] / dayTotal2) * 100) : 0;
24362
+ var color = (typeof _runListCategoryColor === 'function') ? _runListCategoryColor(catKey) : 'var(--red)';
24363
+ failHtml += '<div class="mini-fails-seg" style="height:' + catSegPct + '%;background:' + color + '" title="' + catKey + ': ' + dayBucket[catKey] + '"></div>';
24364
+ }
24365
+ failHtml += '</div>';
24366
+ }
24367
+ failHtml += '</div>';
24368
+ }
24369
+
24370
+ // ── Compose ────────────────────────────────────────────────────────
24371
+ host.innerHTML =
24372
+ '<div class="mini-card">'
24373
+ + '<div class="mini-card-head"><span class="mini-card-title">Cost · 7d</span><span class="mini-card-figure">' + esc(costFigure) + '</span></div>'
24374
+ + '<div class="mini-spark">' + costSparkHtml + '</div>'
24375
+ + '<div class="mini-card-sub">' + (totalCost7 > 0 ? 'across ' + last7.filter(function(r){ return typeof r.totalCostUsd === "number"; }).length + ' priced runs' : 'no priced runs yet') + '</div>'
24376
+ + '</div>'
24377
+ + '<div class="mini-card">'
24378
+ + '<div class="mini-card-head"><span class="mini-card-title">Latency · avg</span><span class="mini-card-figure">' + esc(latFigure) + '</span></div>'
24379
+ + splitHtml
24380
+ + '<div class="mini-card-sub">' + esc(latSub) + ' (split is heuristic; per-tool timing lands with hooks)</div>'
24381
+ + '</div>'
24382
+ + '<div class="mini-card">'
24383
+ + '<div class="mini-card-head"><span class="mini-card-title">Reliability · 7d</span><span class="mini-card-figure">' + totalFails7 + ' fail' + (totalFails7 === 1 ? '' : 's') + '</span></div>'
24384
+ + failHtml
24385
+ + '<div class="mini-card-sub">click a column in the run list to filter by category</div>'
24386
+ + '</div>';
24387
+ }
24388
+
23965
24389
  // ── PRD Phase 3: Run list ──────────────────────────────────────────────
23966
24390
  // Single sortable/filterable table of every CronRunEntry across all tasks.
23967
24391
  // Filters: status, task name, time window. Browser-local saved views.
@@ -24610,6 +25034,11 @@ async function refreshCron() {
24610
25034
  // /api/cron/runs (already fetched alongside ops) feeds the metrics.
24611
25035
  // Render an empty shell first; refreshHealthStrip fills it in.
24612
25036
  var html = '<div id="health-strip" class="health-strip"></div>';
25037
+ // PRD §12 / 1.18.93: three mini-dashboards below the Health Strip —
25038
+ // Cost (7d sparkline), Latency split (model / tool / overhead),
25039
+ // Reliability (failures stacked by category). Filled in by
25040
+ // refreshMiniDashboards from the same /api/cron/runs payload.
25041
+ html += '<div id="mini-dashboards" class="mini-dashboards"></div>';
24613
25042
  html += renderOperationsSummary(ops);
24614
25043
 
24615
25044
  // ── Zone 1 — Running now (promoted to top, primary "what's live" view) ──
@@ -24664,6 +25093,9 @@ async function refreshCron() {
24664
25093
  if (typeof refreshHealthStrip === 'function') {
24665
25094
  refreshHealthStrip().catch(function() { /* non-fatal */ });
24666
25095
  }
25096
+ if (typeof refreshMiniDashboards === 'function') {
25097
+ refreshMiniDashboards().catch(function() { /* non-fatal */ });
25098
+ }
24667
25099
  panel.onclick = function(ev) {
24668
25100
  var target = ev.target;
24669
25101
  while (target && target.id !== 'panel-cron') {
@@ -24816,11 +25248,21 @@ function renderRunDetailWaterfall(events, runId, jobName) {
24816
25248
 
24817
25249
  var rowId = 'run-evt-' + j;
24818
25250
  var canExpand = !!fullContent && fullContent.length > preview.length;
24819
- html += '<div style="display:grid;grid-template-columns:90px 110px 1fr;gap:14px;padding:10px 20px;border-bottom:1px solid var(--border);align-items:start">';
25251
+ // PRD §6 Phase 4e: backfilled subagent events get an indented row + a
25252
+ // pill showing the subagent slug, so the waterfall makes it obvious
25253
+ // which spans came from a delegated agent vs the parent task.
25254
+ var isSubagent = ev.source === 'backfill' || !!ev.subagentSlug;
25255
+ var rowBg = isSubagent ? 'background:var(--bg-secondary);' : '';
25256
+ var rowPad = isSubagent ? 'padding:10px 20px 10px 48px;' : 'padding:10px 20px;';
25257
+ var subagentBadge = isSubagent
25258
+ ? '<span title="Backfilled from subagent transcript" style="display:inline-block;background:var(--purple,#8b5cf6)20;color:var(--purple,#8b5cf6);padding:2px 8px;border-radius:4px;font-size:10px;font-weight:500;margin-right:6px">↳ ' + esc(ev.subagentSlug || ev.agentId || 'subagent') + '</span>'
25259
+ : '';
25260
+ html += '<div style="display:grid;grid-template-columns:90px 110px 1fr;gap:14px;' + rowPad + 'border-bottom:1px solid var(--border);align-items:start;' + rowBg + '">';
24820
25261
  html += '<div style="font-size:10px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:18px">' + esc(offsetLabel) + '</div>';
24821
25262
  html += '<div><span style="display:inline-block;background:' + color + '20;color:' + color + ';padding:2px 8px;border-radius:4px;font-size:10px;font-weight:600;letter-spacing:0.04em">' + esc(label) + '</span></div>';
24822
25263
  html += '<div style="min-width:0">';
24823
25264
  html += '<div style="font-size:12px;color:var(--text-primary);line-height:1.45;word-break:break-word">'
25265
+ + subagentBadge
24824
25266
  + esc(preview)
24825
25267
  + (pairedDuration ? '<span style="color:var(--text-muted);font-size:11px"> ' + esc(pairedDuration) + '</span>' : '')
24826
25268
  + '</div>';
@@ -44,6 +44,25 @@ export declare const clementineJsonSchema: z.ZodObject<{
44
44
  ask_first: "ask_first";
45
45
  act_when_safe: "act_when_safe";
46
46
  }>>;
47
+ profile: z.ZodOptional<z.ZodObject<{
48
+ systemPrompt: z.ZodOptional<z.ZodString>;
49
+ model: z.ZodOptional<z.ZodString>;
50
+ allowedTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
51
+ allowedProjects: z.ZodOptional<z.ZodArray<z.ZodString>>;
52
+ allowedUsers: z.ZodOptional<z.ZodArray<z.ZodString>>;
53
+ channels: z.ZodOptional<z.ZodArray<z.ZodString>>;
54
+ budgetMonthlyCents: z.ZodOptional<z.ZodNumber>;
55
+ goalSlugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
56
+ sendPolicy: z.ZodOptional<z.ZodObject<{
57
+ maxDailyEmails: z.ZodOptional<z.ZodNumber>;
58
+ requiresApproval: z.ZodOptional<z.ZodEnum<{
59
+ none: "none";
60
+ "first-in-sequence": "first-in-sequence";
61
+ all: "all";
62
+ }>>;
63
+ businessHoursOnly: z.ZodOptional<z.ZodBoolean>;
64
+ }, z.core.$strip>>;
65
+ }, z.core.$strip>>;
47
66
  }, z.core.$strip>>;
48
67
  models: z.ZodOptional<z.ZodObject<{
49
68
  default: z.ZodOptional<z.ZodString>;
@@ -32,6 +32,28 @@ export const clementineJsonSchema = z.object({
32
32
  responseStyle: z.enum(['concise', 'balanced', 'detailed']).optional(),
33
33
  progressVisibility: z.enum(['quiet', 'normal', 'detailed']).optional(),
34
34
  autonomy: z.enum(['ask_first', 'balanced', 'act_when_safe']).optional(),
35
+ /**
36
+ * Dashboard-managed profile for the main agent (Clementine herself).
37
+ * Mirrors the per-agent edit surface so Tasks → Team can edit the
38
+ * primary persona without forcing users into Settings or env files.
39
+ * Every field is optional; absent fields fall through to the existing
40
+ * env / compiled defaults via computeEffectiveConfig.
41
+ */
42
+ profile: z.object({
43
+ systemPrompt: z.string().optional(),
44
+ model: z.string().optional(),
45
+ allowedTools: z.array(z.string()).optional(),
46
+ allowedProjects: z.array(z.string()).optional(),
47
+ allowedUsers: z.array(z.string()).optional(),
48
+ channels: z.array(z.string()).optional(),
49
+ budgetMonthlyCents: z.number().nonnegative().optional(),
50
+ goalSlugs: z.array(z.string()).optional(),
51
+ sendPolicy: z.object({
52
+ maxDailyEmails: z.number().nonnegative().optional(),
53
+ requiresApproval: z.enum(['none', 'first-in-sequence', 'all']).optional(),
54
+ businessHoursOnly: z.boolean().optional(),
55
+ }).optional(),
56
+ }).optional(),
35
57
  }).optional(),
36
58
  models: z.object({
37
59
  default: z.string().optional(),
package/dist/types.d.ts CHANGED
@@ -445,8 +445,18 @@ export interface RunEvent {
445
445
  costUsd?: number;
446
446
  /** Stop reason from ResultMessage when kind='session_end'. */
447
447
  stopReason?: string;
448
- /** Subagent id when kind='subagent_*'. */
448
+ /** Subagent id when kind='subagent_*' OR when an event was synthesized from
449
+ * a subagent transcript via Path C backfill (subagent-backfill.ts). */
449
450
  agentId?: string;
451
+ /** PRD §6 Phase 4e: subagent slug ("bright-petting-kahn") for friendly
452
+ * display in the Run detail waterfall. Only populated for events
453
+ * synthesized from subagent transcripts. */
454
+ subagentSlug?: string;
455
+ /** PRD §6 Phase 4e: marks events as backfilled from a subagent transcript
456
+ * rather than captured live by the in-process tap. The Run detail viewer
457
+ * renders these in a nested swimlane and labels the source so users know
458
+ * the data came from disk after the run. */
459
+ source?: 'live' | 'backfill';
450
460
  }
451
461
  /**
452
462
  * PRD §9 / 1.18.87: 11-category failure taxonomy. Replaces the existing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.91",
3
+ "version": "1.18.93",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",