clementine-agent 1.18.90 → 1.18.92

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.
@@ -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
@@ -4587,6 +4587,56 @@ export async function cmdDashboard(opts) {
4587
4587
  });
4588
4588
  // SSE events handler moved before auth middleware (see above)
4589
4589
  // ── POST routes (actions) ──────────────────────────────────────
4590
+ // PRD §10 / 1.18.91: cancel an in-flight cron run. The daemon runs every
4591
+ // cron job in-process (cron-running.json's `pid` is the daemon's PID — we
4592
+ // can't SIGTERM it without crashing everything else). Instead we look up
4593
+ // the job's AbortController in the gateway registry and abort it; the SDK
4594
+ // path on runAgentCron honors the signal and unwinds cleanly. The
4595
+ // CronScheduler's own catch path then writes the closing CronRunEntry
4596
+ // with status='error' + an "AbortError" message.
4597
+ app.post('/api/cron/run/:job/cancel', async (req, res) => {
4598
+ try {
4599
+ const jobName = req.params.job;
4600
+ if (!jobName) {
4601
+ res.status(400).json({ ok: false, error: 'job required' });
4602
+ return;
4603
+ }
4604
+ const gw = await getGateway();
4605
+ if (!gw || typeof gw.cancelCronJob !== 'function') {
4606
+ res.status(503).json({ ok: false, error: 'gateway not initialized — try again in a moment' });
4607
+ return;
4608
+ }
4609
+ const runningFile = path.join(BASE_DIR, 'cron-running.json');
4610
+ let runId;
4611
+ if (existsSync(runningFile)) {
4612
+ try {
4613
+ const entries = JSON.parse(readFileSync(runningFile, 'utf-8'));
4614
+ const match = Array.isArray(entries) ? entries.find((e) => String(e.jobName ?? '').toLowerCase() === jobName.toLowerCase()) : null;
4615
+ if (match?.runId)
4616
+ runId = match.runId;
4617
+ }
4618
+ catch { /* non-fatal — cancel can still proceed without runId */ }
4619
+ }
4620
+ const aborted = gw.cancelCronJob(jobName, 'cancelled-by-dashboard');
4621
+ if (!aborted) {
4622
+ res.status(404).json({ ok: false, error: `Job "${jobName}" is not running on this daemon` });
4623
+ return;
4624
+ }
4625
+ // Broadcast so other tabs drop the running card without polling.
4626
+ try {
4627
+ broadcastEvent({ type: 'cron_cancelled', data: { job: jobName, runId } });
4628
+ }
4629
+ catch { /* non-fatal */ }
4630
+ res.json({
4631
+ ok: true,
4632
+ message: `Cancel signal sent to "${jobName}". The runner will close the run within a few seconds.`,
4633
+ runId,
4634
+ });
4635
+ }
4636
+ catch (err) {
4637
+ res.status(500).json({ ok: false, error: String(err) });
4638
+ }
4639
+ });
4590
4640
  app.post('/api/cron/run/:job', (req, res) => {
4591
4641
  const jobName = req.params.job;
4592
4642
  try {
@@ -5875,10 +5925,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5875
5925
  }
5876
5926
  });
5877
5927
  // ── Available Tools ──────────────────────────────────────────
5878
- app.get('/api/available-tools', (_req, res) => {
5928
+ app.get('/api/available-tools', async (_req, res) => {
5879
5929
  try {
5880
- const data = cached('available-tools', 30_000, () => {
5930
+ const data = await cachedAsync('available-tools', 60_000, async () => {
5881
5931
  const apiStatus = getApiConnectionStatus();
5932
+ let composioError = null;
5882
5933
  const categories = {
5883
5934
  'CLI Tools': discoverCliTools(),
5884
5935
  'Core SDK': [
@@ -5966,19 +6017,92 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5966
6017
  if (globalMcp.length > 0) {
5967
6018
  categories['Global MCP Servers'] = globalMcp;
5968
6019
  }
5969
- // Discover project-scoped MCP servers
5970
- 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.
5971
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 = {};
5972
6052
  for (const p of projects) {
5973
6053
  if (p.mcpServers.length) {
5974
6054
  for (const server of p.mcpServers) {
5975
- if (!projectMcp[server])
5976
- projectMcp[server] = [];
5977
- 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;
5978
6098
  }
5979
6099
  }
5980
6100
  }
5981
- 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 };
5982
6106
  });
5983
6107
  res.json(data);
5984
6108
  }
@@ -22682,6 +22806,24 @@ async function toggleCronJob(name) {
22682
22806
  } catch(e) { toast('Failed to toggle: ' + e, 'error'); }
22683
22807
  }
22684
22808
 
22809
+ // PRD §10 / 1.18.91: SIGTERM the in-flight runner for this cron job. The
22810
+ // daemon endpoint reads PID from cron-running.json (already written by
22811
+ // CronScheduler). The runner's signal handler closes the run gracefully —
22812
+ // the SSE cron_cancelled broadcast will refresh the card.
22813
+ async function cancelCronRun(name) {
22814
+ if (!confirm('Cancel the in-flight run for "' + name + '"? The runner will stop within a few seconds.')) return;
22815
+ try {
22816
+ var r = await apiFetch('/api/cron/run/' + encodeURIComponent(name) + '/cancel', { method: 'POST' });
22817
+ var d = await r.json();
22818
+ if (!r.ok || d.ok === false) {
22819
+ toast(d.error || ('Cancel failed (HTTP ' + r.status + ')'), 'error');
22820
+ return;
22821
+ }
22822
+ toast(d.message || ('Cancel signal sent to ' + name), 'success');
22823
+ setTimeout(refreshCron, 800);
22824
+ } catch(e) { toast('Failed to cancel: ' + e, 'error'); }
22825
+ }
22826
+
22685
22827
  async function toggleScheduledWorkflow(id) {
22686
22828
  try {
22687
22829
  var r = await apiFetch('/api/builder/workflows/' + encodeURIComponent(id));
@@ -23663,6 +23805,14 @@ function renderScheduledTaskCard(task) {
23663
23805
  badges += '<span class="badge ' + (enabled ? 'badge-green' : 'badge-gray') + '">' + (enabled ? 'Enabled' : 'Disabled') + '</span>';
23664
23806
  badges += '<span class="badge ' + (task.health === 'broken' || task.health === 'failed' ? 'badge-yellow' : 'badge-gray') + '">' + esc(task.healthLabel || task.health) + '</span>';
23665
23807
  var safeName = jsStr(task.name);
23808
+ // PRD §10 / 1.18.91: when a task is mid-flight, Run Now is meaningless and
23809
+ // would race against the concurrency lock; replace it with a Cancel button
23810
+ // that calls /api/cron/run/:job/cancel (SIGTERMs the runner via the PID
23811
+ // recorded in cron-running.json).
23812
+ var isRunning = task.health === 'running';
23813
+ var runOrCancelBtn = isRunning
23814
+ ? '<button class="btn-sm secondary btn-danger" onclick="cancelCronRun(\\x27' + safeName + '\\x27)" title="Stop this in-flight run (SIGTERM)">Cancel</button>'
23815
+ : '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>';
23666
23816
  return '<div class="' + cardCls + '" style="' + style + '">'
23667
23817
  + '<div class="task-card-header"><strong>' + esc(task.displayName || task.name) + '</strong>'
23668
23818
  + '<label class="toggle-switch"><input type="checkbox"' + (enabled ? ' checked' : '') + ' onchange="toggleCronJob(\\x27' + safeName + '\\x27)"><span class="toggle-slider"></span></label></div>'
@@ -23674,7 +23824,7 @@ function renderScheduledTaskCard(task) {
23674
23824
  + '<div class="task-card-badges">' + badges + '</div>'
23675
23825
  + '<div class="task-card-actions">'
23676
23826
  + '<button class="btn-sm primary" onclick="openEditCronModal(\\x27' + safeName + '\\x27)" title="Edit task" style="background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary)">Edit</button>'
23677
- + '<button class="btn-sm secondary btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)" title="Run this task once now">Run Now</button>'
23827
+ + runOrCancelBtn
23678
23828
  + '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
23679
23829
  + '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
23680
23830
  + '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
@@ -24740,11 +24890,21 @@ function renderRunDetailWaterfall(events, runId, jobName) {
24740
24890
 
24741
24891
  var rowId = 'run-evt-' + j;
24742
24892
  var canExpand = !!fullContent && fullContent.length > preview.length;
24743
- 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">';
24893
+ // PRD §6 Phase 4e: backfilled subagent events get an indented row + a
24894
+ // pill showing the subagent slug, so the waterfall makes it obvious
24895
+ // which spans came from a delegated agent vs the parent task.
24896
+ var isSubagent = ev.source === 'backfill' || !!ev.subagentSlug;
24897
+ var rowBg = isSubagent ? 'background:var(--bg-secondary);' : '';
24898
+ var rowPad = isSubagent ? 'padding:10px 20px 10px 48px;' : 'padding:10px 20px;';
24899
+ var subagentBadge = isSubagent
24900
+ ? '<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>'
24901
+ : '';
24902
+ html += '<div style="display:grid;grid-template-columns:90px 110px 1fr;gap:14px;' + rowPad + 'border-bottom:1px solid var(--border);align-items:start;' + rowBg + '">';
24744
24903
  html += '<div style="font-size:10px;color:var(--text-muted);font-family:\\x27JetBrains Mono\\x27,monospace;line-height:18px">' + esc(offsetLabel) + '</div>';
24745
24904
  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>';
24746
24905
  html += '<div style="min-width:0">';
24747
24906
  html += '<div style="font-size:12px;color:var(--text-primary);line-height:1.45;word-break:break-word">'
24907
+ + subagentBadge
24748
24908
  + esc(preview)
24749
24909
  + (pairedDuration ? '<span style="color:var(--text-muted);font-size:11px"> ' + esc(pairedDuration) + '</span>' : '')
24750
24910
  + '</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(),
@@ -30,6 +30,11 @@ export declare class Gateway {
30
30
  * `CronRunEntry`. Mirrors the `consumeLastTerminalReason` pattern so we
31
31
  * don't have to refactor `handleCronJob`'s positional return shape. */
32
32
  private _lastCronRunMetadata?;
33
+ /** PRD §10 / 1.18.91: registry of in-flight cron AbortControllers keyed by
34
+ * jobName. Lets the dashboard cancel endpoint abort an in-progress run
35
+ * without SIGTERMing the whole daemon. Populated/cleaned up by
36
+ * handleCronJob. */
37
+ private cronAbortControllers;
33
38
  /** Persisted set of channel keys the owner has approved. Loaded lazily. */
34
39
  private seenChannels;
35
40
  private _authFailCount;
@@ -178,6 +183,12 @@ export declare class Gateway {
178
183
  _mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string, pinnedSkills?: string[], allowedTools?: string[], allowedMcpServers?: string[],
179
184
  /** Predictable (contract) mode — runner skips memory/team/auto-skills. */
180
185
  predictable?: boolean): Promise<string>;
186
+ /**
187
+ * PRD §10 / 1.18.91 — cancel an in-flight cron run by name. Returns true if
188
+ * an AbortController was found and abort() was called, false if nothing was
189
+ * registered (job wasn't running on this daemon). Safe to call repeatedly.
190
+ */
191
+ cancelCronJob(jobName: string, reason?: string): boolean;
181
192
  /**
182
193
  * Process a team message as an autonomous task — same multi-phase execution
183
194
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -112,6 +112,11 @@ export class Gateway {
112
112
  * `CronRunEntry`. Mirrors the `consumeLastTerminalReason` pattern so we
113
113
  * don't have to refactor `handleCronJob`'s positional return shape. */
114
114
  _lastCronRunMetadata;
115
+ /** PRD §10 / 1.18.91: registry of in-flight cron AbortControllers keyed by
116
+ * jobName. Lets the dashboard cancel endpoint abort an in-progress run
117
+ * without SIGTERMing the whole daemon. Populated/cleaned up by
118
+ * handleCronJob. */
119
+ cronAbortControllers = new Map();
115
120
  /** Persisted set of channel keys the owner has approved. Loaded lazily. */
116
121
  seenChannels = null;
117
122
  // Auth circuit breaker — suppresses repeated error spam after consecutive failures
@@ -1987,6 +1992,10 @@ export class Gateway {
1987
1992
  cronAc.abort();
1988
1993
  logger.warn({ jobName, wallMs }, 'Cron job hit wall-clock cap — aborting');
1989
1994
  }, wallMs);
1995
+ // PRD §10 / 1.18.91: register so the dashboard cancel endpoint can find
1996
+ // and abort this controller. Last-write-wins if a duplicate fires (the
1997
+ // concurrency lock prevents that for manual runs, but be defensive).
1998
+ this.cronAbortControllers.set(jobName, cronAc);
1990
1999
  try {
1991
2000
  logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
1992
2001
  const cronStart = Date.now();
@@ -2047,7 +2056,29 @@ export class Gateway {
2047
2056
  finally {
2048
2057
  clearTimeout(cronTimer);
2049
2058
  releaseLane();
2059
+ // PRD §10 / 1.18.91: deregister only if we're still the owner (a
2060
+ // theoretical re-entry could have replaced us; don't clobber).
2061
+ if (this.cronAbortControllers.get(jobName) === cronAc) {
2062
+ this.cronAbortControllers.delete(jobName);
2063
+ }
2064
+ }
2065
+ }
2066
+ /**
2067
+ * PRD §10 / 1.18.91 — cancel an in-flight cron run by name. Returns true if
2068
+ * an AbortController was found and abort() was called, false if nothing was
2069
+ * registered (job wasn't running on this daemon). Safe to call repeatedly.
2070
+ */
2071
+ cancelCronJob(jobName, reason = 'cancelled-by-dashboard') {
2072
+ const ac = this.cronAbortControllers.get(jobName);
2073
+ if (!ac)
2074
+ return false;
2075
+ if (!ac.signal.aborted) {
2076
+ try {
2077
+ ac.abort(reason);
2078
+ }
2079
+ catch { /* ignore */ }
2050
2080
  }
2081
+ return true;
2051
2082
  }
2052
2083
  // ── Team task execution ──────────────────────────────────────────────
2053
2084
  /**
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.90",
3
+ "version": "1.18.92",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",