clementine-agent 1.18.83 → 1.18.85

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.
@@ -70,8 +70,6 @@ export declare class PersonalAssistant {
70
70
  private memoryStore;
71
71
  private _lastUserMessage?;
72
72
  onSkillProposed: ((skill: import('../types.js').SkillDocument) => void) | null;
73
- private _lastMcpStatus;
74
- private _lastMcpStatusTime;
75
73
  /** Terminal reason from the last SDK query — consumed by cron scheduler for precise error classification. */
76
74
  private _lastTerminalReason?;
77
75
  /** Per-session stall nudge — set after a query shows stall signals, consumed on the next query. */
@@ -17,6 +17,7 @@ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE,
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
18
  import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, KNOWN_SERVICES, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
20
+ import { getLatestMcpStatusSnapshot, recordMcpStatusFromSystemInit, invalidateMcpStatusEntry } from './run-agent.js';
20
21
  import { detectFrustrationSignals, detectRepeatedTopics } from './insight-engine.js';
21
22
  import { DEFAULT_CHANNEL_CAPABILITIES } from '../types.js';
22
23
  import { enforceToolPermissions, getSecurityPrompt, getHeartbeatSecurityPrompt, getCronSecurityPrompt, getHeartbeatDisallowedTools, logToolUse, logAuditJsonl, } from './hooks.js';
@@ -710,8 +711,10 @@ export class PersonalAssistant {
710
711
  memoryStore = null; // Typed as any — MemoryStore may not be available yet
711
712
  _lastUserMessage;
712
713
  onSkillProposed = null;
713
- _lastMcpStatus = [];
714
- _lastMcpStatusTime = '';
714
+ // PRD Phase 2 / 1.18.84: superseded by the shared module-level cache in
715
+ // run-agent.ts (getLatestMcpStatusSnapshot / recordMcpStatusFromSystemInit).
716
+ // The fields below were declared but never written pre-1.18.84; the
717
+ // module cache populates from every system/init message instead.
715
718
  /** Terminal reason from the last SDK query — consumed by cron scheduler for precise error classification. */
716
719
  _lastTerminalReason;
717
720
  /** Per-session stall nudge — set after a query shows stall signals, consumed on the next query. */
@@ -808,7 +811,12 @@ export class PersonalAssistant {
808
811
  this.onSkillProposed = cb;
809
812
  }
810
813
  getMcpStatus() {
811
- return { servers: this._lastMcpStatus, updatedAt: this._lastMcpStatusTime };
814
+ // 1.18.84 correctness fix: delegate to the shared module-level cache.
815
+ // Pre-1.18.84 we returned this._lastMcpStatus, which was declared but
816
+ // never written — getMcpStatus() always returned empty. Now run-agent
817
+ // and assistant query streams record into a shared snapshot via
818
+ // recordMcpStatusFromSystemInit when the SDK init message lands.
819
+ return getLatestMcpStatusSnapshot();
812
820
  }
813
821
  /**
814
822
  * PRD Phase 2.1: clear the cached status for one server so the next query
@@ -818,12 +826,9 @@ export class PersonalAssistant {
818
826
  * stale error/auth state. Returns the post-clear cached snapshot.
819
827
  */
820
828
  invalidateMcpStatus(serverName) {
821
- const beforeLen = this._lastMcpStatus.length;
822
- this._lastMcpStatus = this._lastMcpStatus.filter((s) => s.name !== serverName);
823
- const cleared = this._lastMcpStatus.length < beforeLen;
824
- if (cleared)
825
- this._lastMcpStatusTime = new Date().toISOString();
826
- return { servers: this._lastMcpStatus, updatedAt: this._lastMcpStatusTime, cleared };
829
+ const result = invalidateMcpStatusEntry(serverName);
830
+ const snapshot = getLatestMcpStatusSnapshot();
831
+ return { servers: snapshot.servers, updatedAt: snapshot.updatedAt, cleared: result.cleared };
827
832
  }
828
833
  /** Inject a background work result into the session as silent follow-up context. */
829
834
  injectPendingContext(sessionKey, userPrompt, result) {
@@ -2999,6 +3004,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2999
3004
  const trace = [];
3000
3005
  const stream = query({ prompt, options: sdkOptions });
3001
3006
  for await (const message of stream) {
3007
+ // 1.18.84 correctness: capture MCP server status from SDK init.
3008
+ if (message.type === 'system' && message.subtype === 'init') {
3009
+ const mcpServersRaw = message.mcp_servers;
3010
+ if (mcpServersRaw)
3011
+ try {
3012
+ recordMcpStatusFromSystemInit(mcpServersRaw);
3013
+ }
3014
+ catch { /* non-fatal */ }
3015
+ }
3002
3016
  if (message.type === 'assistant') {
3003
3017
  const blocks = getContentBlocks(message);
3004
3018
  for (const block of blocks) {
@@ -22,6 +22,21 @@
22
22
  * long-task preflight, NO mode=unleashed wrapper.
23
23
  */
24
24
  import { type AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
25
+ /** Read the latest MCP status snapshot. Safe to call from any module. */
26
+ export declare function getLatestMcpStatusSnapshot(): {
27
+ servers: Array<{
28
+ name: string;
29
+ status: string;
30
+ }>;
31
+ updatedAt: string;
32
+ };
33
+ /** Write a fresh snapshot. Called from system/init handlers. */
34
+ export declare function recordMcpStatusFromSystemInit(rawMcpServers: unknown): void;
35
+ /** Drop one server from the cache so the next query repopulates it. */
36
+ export declare function invalidateMcpStatusEntry(name: string): {
37
+ cleared: boolean;
38
+ updatedAt: string;
39
+ };
25
40
  import type { AgentProfile } from '../types.js';
26
41
  import type { AgentManager } from './agent-manager.js';
27
42
  import type { MemoryStore } from '../memory/store.js';
@@ -102,6 +117,10 @@ export interface RunAgentResult {
102
117
  cache_read_input_tokens?: number;
103
118
  cache_creation_input_tokens?: number;
104
119
  };
120
+ /** PRD §6 / 1.18.85: stable run UUID. The Event store keys off this id;
121
+ * callers (cron-scheduler, cron CLI) stamp it on CronRunEntry so the
122
+ * Run detail page can join run row → events. */
123
+ runId: string;
105
124
  }
106
125
  /**
107
126
  * Run a single agent invocation via the canonical SDK pattern.
@@ -22,8 +22,74 @@
22
22
  * long-task preflight, NO mode=unleashed wrapper.
23
23
  */
24
24
  import path from 'node:path';
25
+ import { randomUUID } from 'node:crypto';
25
26
  import { query } from '@anthropic-ai/claude-agent-sdk';
26
27
  import pino from 'pino';
28
+ import { EventLog } from '../gateway/event-log.js';
29
+ /**
30
+ * Module-level cache of MCP server statuses from the most recent SDK
31
+ * init message. Populated by every runAgent / PersonalAssistant query
32
+ * stream that captures `system/init`. Read by Assistant.getMcpStatus()
33
+ * and the dashboard's Tools & MCP catalog page.
34
+ *
35
+ * Pre-1.18.84 the assistant declared a private _lastMcpStatus but no
36
+ * code wrote to it — getMcpStatus() always returned empty, making the
37
+ * catalog status pills misleading. The shared module cache fixes that
38
+ * without coupling assistant.ts to runAgent's stream loop.
39
+ */
40
+ let _lastMcpStatusSnapshot = {
41
+ servers: [],
42
+ updatedAt: '',
43
+ };
44
+ /** Read the latest MCP status snapshot. Safe to call from any module. */
45
+ export function getLatestMcpStatusSnapshot() {
46
+ return { servers: [..._lastMcpStatusSnapshot.servers], updatedAt: _lastMcpStatusSnapshot.updatedAt };
47
+ }
48
+ /** Write a fresh snapshot. Called from system/init handlers. */
49
+ export function recordMcpStatusFromSystemInit(rawMcpServers) {
50
+ if (!Array.isArray(rawMcpServers))
51
+ return;
52
+ const servers = [];
53
+ for (const entry of rawMcpServers) {
54
+ if (!entry || typeof entry !== 'object')
55
+ continue;
56
+ const e = entry;
57
+ if (typeof e.name !== 'string' || !e.name)
58
+ continue;
59
+ servers.push({ name: e.name, status: typeof e.status === 'string' ? e.status : 'unknown' });
60
+ }
61
+ _lastMcpStatusSnapshot = { servers, updatedAt: new Date().toISOString() };
62
+ }
63
+ /**
64
+ * Truncate a value for the Event store so a single huge tool input/output
65
+ * doesn't blow out the JSONL line. Object/array shapes are JSON-stringified
66
+ * and capped; primitive strings are sliced; everything else is returned as-is.
67
+ */
68
+ function truncateForLog(value, maxBytes) {
69
+ if (value == null)
70
+ return value;
71
+ if (typeof value === 'string') {
72
+ return value.length > maxBytes ? value.slice(0, maxBytes) + '...[truncated]' : value;
73
+ }
74
+ try {
75
+ const json = JSON.stringify(value);
76
+ if (json.length <= maxBytes)
77
+ return value;
78
+ return { _truncated: true, preview: json.slice(0, maxBytes) + '...[truncated]', _bytes: json.length };
79
+ }
80
+ catch {
81
+ return { _unstringifiable: true };
82
+ }
83
+ }
84
+ /** Drop one server from the cache so the next query repopulates it. */
85
+ export function invalidateMcpStatusEntry(name) {
86
+ const before = _lastMcpStatusSnapshot.servers.length;
87
+ _lastMcpStatusSnapshot = {
88
+ servers: _lastMcpStatusSnapshot.servers.filter((s) => s.name !== name),
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ return { cleared: _lastMcpStatusSnapshot.servers.length < before, updatedAt: _lastMcpStatusSnapshot.updatedAt };
92
+ }
27
93
  import { BASE_DIR, PKG_DIR, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
28
94
  import { buildAgentMap } from './agent-definitions.js';
29
95
  const MCP_SERVER_SCRIPT = path.join(PKG_DIR, 'dist', 'tools', 'mcp-server.js');
@@ -218,6 +284,23 @@ export async function runAgent(prompt, opts) {
218
284
  agentCount: Object.keys(agents).length,
219
285
  allowedToolCount: allowedTools.length,
220
286
  }, 'runAgent: starting query');
287
+ // PRD §6 / 1.18.85: path A in-process tap. One Event row per significant
288
+ // SDK message, written to ~/.clementine/events/<runId>.jsonl. The Run
289
+ // detail page (Phase 4b) reads from this file. EventLog.append never
290
+ // throws back to the caller — telemetry is best-effort.
291
+ const runId = randomUUID();
292
+ const eventLog = new EventLog();
293
+ let eventSeq = 0;
294
+ const writeEvent = (e) => {
295
+ try {
296
+ eventLog.append({
297
+ ...e,
298
+ runId,
299
+ seq: eventSeq++,
300
+ });
301
+ }
302
+ catch { /* never block */ }
303
+ };
221
304
  let finalText = '';
222
305
  let sessionId = '';
223
306
  let totalCostUsd = 0;
@@ -229,15 +312,38 @@ export async function runAgent(prompt, opts) {
229
312
  for await (const message of stream) {
230
313
  if (message.type === 'system' && message.subtype === 'init') {
231
314
  sessionId = message.session_id ?? '';
232
- logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId }, 'runAgent: SDK session initialized');
315
+ // PRD Phase 2 / 1.18.84 correctness: capture the SDK-reported MCP
316
+ // server status so getMcpStatus() (and the dashboard's Tools & MCP
317
+ // catalog) actually has data. The init message includes
318
+ // mcp_servers: Array<{ name, status }> per the SDK protocol.
319
+ const mcpServersRaw = message.mcp_servers;
320
+ if (mcpServersRaw) {
321
+ try {
322
+ recordMcpStatusFromSystemInit(mcpServersRaw);
323
+ }
324
+ catch { /* non-fatal */ }
325
+ }
326
+ // PRD Phase 4a / 1.18.85: write the session_start Event row.
327
+ writeEvent({ kind: 'session_start', ts: new Date().toISOString(), sessionId });
328
+ logger.debug({ sessionKey: opts.sessionKey, sdkSessionId: sessionId, runId }, 'runAgent: SDK session initialized');
233
329
  continue;
234
330
  }
235
331
  if (message.type === 'assistant') {
236
332
  const am = message;
333
+ // SDK content blocks include text, tool_use, and (when extended-thinking
334
+ // is enabled) thinking. We tap each kind into the Event store.
237
335
  const blocks = (am.message?.content ?? []);
238
336
  for (const block of blocks) {
239
337
  if (block.type === 'text' && typeof block.text === 'string') {
240
338
  finalText += block.text;
339
+ // PRD Phase 4a / 1.18.85: llm_text Event. Truncate at 8KB to keep
340
+ // the JSONL light — full text is reachable via the SDK transcript.
341
+ writeEvent({
342
+ kind: 'llm_text',
343
+ ts: new Date().toISOString(),
344
+ sessionId,
345
+ text: block.text.slice(0, 8000),
346
+ });
241
347
  if (opts.onText) {
242
348
  try {
243
349
  await opts.onText(block.text);
@@ -245,7 +351,29 @@ export async function runAgent(prompt, opts) {
245
351
  catch { /* streaming is best-effort */ }
246
352
  }
247
353
  }
354
+ else if (block.type === 'thinking' && typeof block.thinking === 'string') {
355
+ // Extended-thinking block — captured separately so the Run detail
356
+ // page can render thinking distinctly from final text.
357
+ writeEvent({
358
+ kind: 'thinking',
359
+ ts: new Date().toISOString(),
360
+ sessionId,
361
+ thinking: block.thinking.slice(0, 8000),
362
+ });
363
+ }
248
364
  else if (block.type === 'tool_use' && typeof block.name === 'string') {
365
+ // PRD Phase 4a: tool_call Event. The tool_use id pairs with the
366
+ // tool_result Event written when the SDK reports back. Inputs
367
+ // truncated at 8KB; the dashboard can fetch the full transcript
368
+ // if a deeper drill-down is needed.
369
+ writeEvent({
370
+ kind: 'tool_call',
371
+ ts: new Date().toISOString(),
372
+ sessionId,
373
+ toolName: block.name,
374
+ toolUseId: block.id,
375
+ toolInput: truncateForLog(block.input ?? {}, 8000),
376
+ });
249
377
  if (opts.onToolActivity) {
250
378
  try {
251
379
  await opts.onToolActivity({ tool: block.name, input: block.input ?? {} });
@@ -256,6 +384,26 @@ export async function runAgent(prompt, opts) {
256
384
  }
257
385
  continue;
258
386
  }
387
+ // SDK user messages carry tool_result blocks back from tool execution.
388
+ // We pair them with the earlier tool_call Event via toolUseId so the
389
+ // Run detail waterfall renders call → result side by side.
390
+ if (message.type === 'user') {
391
+ const um = message;
392
+ const blocks = um.message?.content ?? [];
393
+ for (const block of blocks) {
394
+ if (block.type === 'tool_result') {
395
+ writeEvent({
396
+ kind: 'tool_result',
397
+ ts: new Date().toISOString(),
398
+ sessionId,
399
+ toolUseId: block.tool_use_id,
400
+ toolResult: truncateForLog(block.content, 16000),
401
+ ...(block.is_error ? { toolError: 'tool reported is_error' } : {}),
402
+ });
403
+ }
404
+ }
405
+ continue;
406
+ }
259
407
  if (message.type === 'result') {
260
408
  const result = message;
261
409
  sessionId = sessionId || (result.session_id ?? '');
@@ -271,6 +419,15 @@ export async function runAgent(prompt, opts) {
271
419
  if (r)
272
420
  finalText = r;
273
421
  }
422
+ // PRD Phase 4a / 1.18.85: session_end Event — closes the run in the
423
+ // event store and stamps the cost + stop reason for the Run detail page.
424
+ writeEvent({
425
+ kind: 'session_end',
426
+ ts: new Date().toISOString(),
427
+ sessionId,
428
+ costUsd: totalCostUsd,
429
+ stopReason: subtype,
430
+ });
274
431
  // Mirror cost to usage_log. Same shape as the existing
275
432
  // logQueryResult, but standalone so we don't depend on
276
433
  // PersonalAssistant's instance state.
@@ -299,13 +456,22 @@ export async function runAgent(prompt, opts) {
299
456
  }
300
457
  }
301
458
  catch (err) {
459
+ // PRD Phase 4a / 1.18.85: error Event closes the run if the SDK throws.
460
+ // Lets the Run detail page render an explicit failure span instead of a
461
+ // run that just trails off after the last successful tool_call.
462
+ const errMsg = String(err?.message ?? err).slice(0, 1000);
463
+ writeEvent({
464
+ kind: 'error',
465
+ ts: new Date().toISOString(),
466
+ sessionId,
467
+ toolError: errMsg,
468
+ });
302
469
  // Translate the SDK's budget-exhaustion throw into a message that
303
470
  // tells the user (a) what cap tripped and (b) how to raise it.
304
471
  // The raw SDK string ("Claude Code returned an error result:
305
472
  // Reached maximum budget ($0.5)") leaks through the channel layer
306
473
  // as a generic "Something went wrong:" with no actionable hint.
307
- const msg = String(err?.message ?? err);
308
- if (/Reached maximum budget|error_max_budget_usd/i.test(msg)) {
474
+ if (/Reached maximum budget|error_max_budget_usd/i.test(errMsg)) {
309
475
  const cap = maxBudgetUsd?.toFixed(2) ?? '?';
310
476
  const envKey = `BUDGET_${source.toUpperCase().replace(/-/g, '_')}_USD`;
311
477
  throw new Error(`Hit the $${cap} ${source} budget cap before finishing. ` +
@@ -330,6 +496,7 @@ export async function runAgent(prompt, opts) {
330
496
  sessionId,
331
497
  subtype,
332
498
  ...(usage ? { usage } : {}),
499
+ runId,
333
500
  };
334
501
  }
335
502
  //# sourceMappingURL=run-agent.js.map
package/dist/cli/cron.js CHANGED
@@ -140,6 +140,15 @@ export async function cmdCronRun(jobName) {
140
140
  try {
141
141
  const response = await gateway.handleCronJob(job.name, job.prompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours);
142
142
  const finishedAt = new Date();
143
+ // 1.18.84: trigger source comes from the CRON_RUN_TRIGGER env var set
144
+ // by the dashboard's manual-run endpoint. Defaults to 'scheduled' for
145
+ // any other invocation path (CLI, daemon-internal callsites).
146
+ const trigger = process.env.CRON_RUN_TRIGGER || 'scheduled';
147
+ // 1.18.85: pull the run UUID from the gateway's per-run metadata
148
+ // side-channel. runAgent stamps it; consume here to link CronRunEntry
149
+ // to the Event store.
150
+ const sideChannel = gateway.consumeLastCronRunMetadata?.();
151
+ const runIdFromGateway = sideChannel?.runId;
143
152
  const entry = {
144
153
  jobName: job.name,
145
154
  startedAt: startedAt.toISOString(),
@@ -148,6 +157,8 @@ export async function cmdCronRun(jobName) {
148
157
  durationMs: finishedAt.getTime() - startedAt.getTime(),
149
158
  attempt: 1,
150
159
  outputPreview: response ? response.slice(0, 200) : undefined,
160
+ trigger,
161
+ ...(runIdFromGateway ? { id: runIdFromGateway } : {}),
151
162
  };
152
163
  // PRD Phase 1.1: goal-orientation evaluator (mirrors the daemon path).
153
164
  if (job.successSchema || (job.successCriteriaText && job.successCriteriaText.trim())) {
@@ -170,6 +181,7 @@ export async function cmdCronRun(jobName) {
170
181
  }
171
182
  catch (err) {
172
183
  const finishedAt = new Date();
184
+ const trigger = process.env.CRON_RUN_TRIGGER || 'scheduled';
173
185
  runLog.append({
174
186
  jobName: job.name,
175
187
  startedAt: startedAt.toISOString(),
@@ -179,6 +191,7 @@ export async function cmdCronRun(jobName) {
179
191
  error: String(err).slice(0, 500),
180
192
  errorType: classifyError(err),
181
193
  attempt: 1,
194
+ trigger,
182
195
  });
183
196
  console.error(`Error: ${err}`);
184
197
  process.exit(1);
@@ -4613,7 +4613,8 @@ export async function cmdDashboard(opts) {
4613
4613
  detached: true,
4614
4614
  stdio: ['ignore', 'pipe', 'pipe'],
4615
4615
  cwd: BASE_DIR,
4616
- env: { ...process.env, CLEMENTINE_HOME: BASE_DIR },
4616
+ // 1.18.84: pass the trigger source so cron.ts stamps it on the run entry.
4617
+ env: { ...process.env, CLEMENTINE_HOME: BASE_DIR, CRON_RUN_TRIGGER: 'manual' },
4617
4618
  });
4618
4619
  // Capture stderr for error reporting
4619
4620
  let stderr = '';
@@ -5669,6 +5670,26 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5669
5670
  res.status(500).json({ error: String(err) });
5670
5671
  }
5671
5672
  });
5673
+ // ── PRD Phase 4a / 1.18.85: per-run Event store reader ─────────
5674
+ // Returns every event captured by path A (in-process tap in runAgent)
5675
+ // for one run. Used by the new Run detail page (Phase 4b).
5676
+ app.get('/api/runs/:runId/events', async (req, res) => {
5677
+ try {
5678
+ const rawId = req.params.runId;
5679
+ const runId = Array.isArray(rawId) ? rawId[0] : rawId;
5680
+ if (!runId || typeof runId !== 'string') {
5681
+ res.status(400).json({ ok: false, error: 'runId required' });
5682
+ return;
5683
+ }
5684
+ const { EventLog } = await import('../gateway/event-log.js');
5685
+ const log = new EventLog();
5686
+ const events = log.readByRun(runId);
5687
+ res.json({ ok: true, runId, events });
5688
+ }
5689
+ catch (err) {
5690
+ res.status(500).json({ ok: false, error: String(err) });
5691
+ }
5692
+ });
5672
5693
  // ── Recent runs across ALL cron jobs ───────────────────────────
5673
5694
  // Powers the "Recent History" zone on the Tasks page. Returns the most
5674
5695
  // recent N CronRunEntry rows merged from every per-job .jsonl, sorted
@@ -6675,6 +6696,24 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6675
6696
  if (purge) {
6676
6697
  const safe = bareJobName.replace(/[^a-zA-Z0-9_-]/g, '_');
6677
6698
  const runLog = path.join(BASE_DIR, 'cron', 'runs', `${safe}.jsonl`);
6699
+ // Read run UUIDs BEFORE we drop the run log — the Event store is
6700
+ // keyed by run UUID, not by job name, so the only way to know which
6701
+ // event files to delete is by reading the JSONL first.
6702
+ const runIdsToPurge = [];
6703
+ try {
6704
+ if (existsSync(runLog)) {
6705
+ const lines = readFileSync(runLog, 'utf-8').trim().split('\n').filter(Boolean);
6706
+ for (const l of lines) {
6707
+ try {
6708
+ const entry = JSON.parse(l);
6709
+ if (entry && typeof entry.id === 'string' && entry.id)
6710
+ runIdsToPurge.push(entry.id);
6711
+ }
6712
+ catch { /* skip malformed */ }
6713
+ }
6714
+ }
6715
+ }
6716
+ catch { /* non-fatal */ }
6678
6717
  try {
6679
6718
  if (existsSync(runLog)) {
6680
6719
  unlinkSync(runLog);
@@ -6697,6 +6736,30 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
6697
6736
  }
6698
6737
  }
6699
6738
  catch { /* non-fatal */ }
6739
+ // PRD Phase 4a / 1.18.85: drop the new Event store entries linked
6740
+ // to this job's runs. One file per run UUID.
6741
+ try {
6742
+ if (runIdsToPurge.length > 0) {
6743
+ const eventsDir = path.join(BASE_DIR, 'events');
6744
+ if (existsSync(eventsDir)) {
6745
+ let dropped = 0;
6746
+ for (const rid of runIdsToPurge) {
6747
+ const safeRid = String(rid).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
6748
+ const evtFile = path.join(eventsDir, `${safeRid}.jsonl`);
6749
+ try {
6750
+ if (existsSync(evtFile)) {
6751
+ unlinkSync(evtFile);
6752
+ dropped++;
6753
+ }
6754
+ }
6755
+ catch { /* skip */ }
6756
+ }
6757
+ if (dropped > 0)
6758
+ purged.push(`${dropped} event log${dropped === 1 ? '' : 's'}`);
6759
+ }
6760
+ }
6761
+ }
6762
+ catch { /* non-fatal */ }
6700
6763
  try {
6701
6764
  const uploadsDir = path.join(BASE_DIR, 'uploads', `cron-${safe}`);
6702
6765
  if (existsSync(uploadsDir)) {
@@ -23811,12 +23874,13 @@ function renderRunListBody(allRuns) {
23811
23874
  var startedAt = entry.startedAt ? new Date(entry.startedAt) : null;
23812
23875
  var startedLabel = startedAt ? startedAt.toLocaleString() : '—';
23813
23876
  var durationLabel = entry.durationMs != null ? formatDurationMs(entry.durationMs) : '—';
23814
- // Trigger heuristic until we persist it for real (Phase 3.1):
23815
- // 'unleashed' mode + manual cron run = manual; otherwise scheduled.
23816
- // The cron-running.json sidecar already carries pid which can hint at
23817
- // manual but isn't on terminal entries. Best-effort label only.
23818
- var triggerLabel = entry.attempt > 1 ? 'retry' : 'scheduled';
23819
- var triggerColor = 'var(--text-muted)';
23877
+ // 1.18.84: real persisted trigger field. Falls back to a heuristic for
23878
+ // pre-1.18.84 run entries that don't have the field set.
23879
+ var triggerLabel = entry.trigger || (entry.attempt > 1 ? 'retry' : 'scheduled');
23880
+ var triggerColor = entry.trigger === 'manual' ? 'var(--accent)'
23881
+ : entry.trigger === 'after' ? 'var(--purple)'
23882
+ : entry.trigger === 'discord' ? 'var(--blue)'
23883
+ : 'var(--text-muted)';
23820
23884
  // Goal cell
23821
23885
  var goalCell = '<div></div>';
23822
23886
  if (entry.goalCheck) {
@@ -23932,7 +23996,14 @@ async function refreshToolsMcpCatalog() {
23932
23996
  try {
23933
23997
  var sR = await apiFetch('/api/mcp-status');
23934
23998
  var statusJson = await sR.json();
23935
- statusMap = statusJson || {};
23999
+ // /api/mcp-status returns { servers: [{name, status}], updatedAt }.
24000
+ // Build a name → entry lookup so renderMcpCatalogCard can probe by name.
24001
+ if (statusJson && Array.isArray(statusJson.servers)) {
24002
+ for (var si = 0; si < statusJson.servers.length; si++) {
24003
+ var entry = statusJson.servers[si];
24004
+ if (entry && entry.name) statusMap[entry.name] = entry;
24005
+ }
24006
+ }
23936
24007
  } catch (e) { /* status is optional — servers still render without it */ }
23937
24008
  try {
23938
24009
  var lR = await apiFetch('/api/mcp-servers');
@@ -888,7 +888,7 @@ export class CronScheduler {
888
888
  ctx.agentSlug = wf.agentSlug;
889
889
  return ctx;
890
890
  }
891
- async runJob(job) {
891
+ async runJob(job, trigger = 'scheduled') {
892
892
  const creditBlock = getBackgroundCreditBlock();
893
893
  if (creditBlock) {
894
894
  logger.warn({ job: job.name, until: creditBlock.until }, 'Cron job skipped — Claude credit block active');
@@ -1206,6 +1206,10 @@ export class CronScheduler {
1206
1206
  outputPreview: response ? response.slice(0, 200) : undefined,
1207
1207
  advisorApplied,
1208
1208
  terminalReason,
1209
+ // 1.18.84: persist the actual trigger source for the Run list filter.
1210
+ trigger,
1211
+ // 1.18.85: stable UUID linking this run to its Event store entries.
1212
+ ...(cronMetadata?.runId ? { id: cronMetadata.runId } : {}),
1209
1213
  // Trick capability metadata — surfaced by the dashboard's
1210
1214
  // "ran with: …" line. Omit empty arrays to keep the JSONL light.
1211
1215
  ...(cronMetadata?.skillsApplied?.length ? { skillsApplied: cronMetadata.skillsApplied } : {}),
@@ -1268,7 +1272,8 @@ export class CronScheduler {
1268
1272
  const dependents = this.jobs.filter(j => j.after === job.name && j.enabled && !this.disabledJobs.has(j.name));
1269
1273
  for (const dep of dependents) {
1270
1274
  logger.info(`Chain: '${job.name}' succeeded — triggering '${dep.name}'`);
1271
- this.runJob(dep).catch((err) => {
1275
+ // 1.18.84: chained-after triggers carry trigger='after' for the Run list filter.
1276
+ this.runJob(dep, 'after').catch((err) => {
1272
1277
  logger.error({ err, job: dep.name }, `Chained job '${dep.name}' failed`);
1273
1278
  });
1274
1279
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Per-run event log — PRD §6 / Phase 4a.
3
+ *
4
+ * Stores RunEvent rows as JSONL at ~/.clementine/events/<runId>.jsonl,
5
+ * mirroring the existing CronRunLog pattern (auto-prune at 2MB / 2000 lines).
6
+ * One file per run keeps reads cheap (no scanning unrelated runs) and lets
7
+ * the Run detail page tail a single file for live updates.
8
+ *
9
+ * Writers:
10
+ * - In-process tap in runAgent (path A) writes session_start, llm_text,
11
+ * thinking, tool_call, tool_result, session_end during the SDK stream.
12
+ * - Hook side-channel (path B, Phase 4d) writes hook events.
13
+ * - Subagent backfill (path C, Phase 4e) synthesizes tool_call/tool_result
14
+ * for inner SDK calls that don't fire parent-level hooks.
15
+ *
16
+ * Reader: dashboard's Run detail page via /api/runs/:run_id/events.
17
+ */
18
+ import type { RunEvent } from '../types.js';
19
+ export declare class EventLog {
20
+ private readonly dir;
21
+ private static readonly MAX_BYTES;
22
+ private static readonly MAX_LINES;
23
+ constructor(baseDir?: string);
24
+ private logPath;
25
+ append(event: RunEvent): void;
26
+ /** Read every event for one run, in seq order. */
27
+ readByRun(runId: string): RunEvent[];
28
+ /** Returns true if any events were captured for the run. Cheap existence check. */
29
+ hasEventsForRun(runId: string): boolean;
30
+ /** Drop one run's entire log. Called from cron-delete cascade cleanup. */
31
+ removeRun(runId: string): boolean;
32
+ /** Total disk size of all event logs in bytes. For diagnostics. */
33
+ totalBytes(): number;
34
+ private maybePrune;
35
+ }
36
+ //# sourceMappingURL=event-log.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Per-run event log — PRD §6 / Phase 4a.
3
+ *
4
+ * Stores RunEvent rows as JSONL at ~/.clementine/events/<runId>.jsonl,
5
+ * mirroring the existing CronRunLog pattern (auto-prune at 2MB / 2000 lines).
6
+ * One file per run keeps reads cheap (no scanning unrelated runs) and lets
7
+ * the Run detail page tail a single file for live updates.
8
+ *
9
+ * Writers:
10
+ * - In-process tap in runAgent (path A) writes session_start, llm_text,
11
+ * thinking, tool_call, tool_result, session_end during the SDK stream.
12
+ * - Hook side-channel (path B, Phase 4d) writes hook events.
13
+ * - Subagent backfill (path C, Phase 4e) synthesizes tool_call/tool_result
14
+ * for inner SDK calls that don't fire parent-level hooks.
15
+ *
16
+ * Reader: dashboard's Run detail page via /api/runs/:run_id/events.
17
+ */
18
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import pino from 'pino';
21
+ import { BASE_DIR } from '../config.js';
22
+ const logger = pino({ name: 'clementine.event-log', level: process.env.LOG_LEVEL ?? 'info' });
23
+ export class EventLog {
24
+ dir;
25
+ static MAX_BYTES = 2_000_000;
26
+ static MAX_LINES = 2000;
27
+ constructor(baseDir) {
28
+ this.dir = path.join(baseDir ?? BASE_DIR, 'events');
29
+ if (!existsSync(this.dir)) {
30
+ mkdirSync(this.dir, { recursive: true });
31
+ }
32
+ }
33
+ logPath(runId) {
34
+ // Sanitize runId for filesystem — UUIDs are safe but we defend against
35
+ // accidental non-UUID values flowing in from older callsites.
36
+ const safe = String(runId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
37
+ return path.join(this.dir, `${safe}.jsonl`);
38
+ }
39
+ append(event) {
40
+ if (!event.runId) {
41
+ logger.warn({ event: { kind: event.kind, ts: event.ts } }, 'Event missing runId — dropped');
42
+ return;
43
+ }
44
+ const filePath = this.logPath(event.runId);
45
+ const line = JSON.stringify(event) + '\n';
46
+ try {
47
+ appendFileSync(filePath, line);
48
+ // Prune asynchronously so the SDK stream loop never blocks on disk IO.
49
+ setImmediate(() => this.maybePrune(filePath));
50
+ }
51
+ catch (err) {
52
+ // Never throw to the caller — telemetry must not break runs.
53
+ logger.warn({ err, runId: event.runId }, 'Failed to write event log');
54
+ }
55
+ }
56
+ /** Read every event for one run, in seq order. */
57
+ readByRun(runId) {
58
+ const filePath = this.logPath(runId);
59
+ if (!existsSync(filePath))
60
+ return [];
61
+ try {
62
+ const lines = readFileSync(filePath, 'utf-8').trim().split('\n').filter(Boolean);
63
+ const events = lines
64
+ .map((l) => {
65
+ try {
66
+ return JSON.parse(l);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ })
72
+ .filter((e) => e !== null);
73
+ // Sort by seq so events with the same ts (sub-millisecond bursts) order
74
+ // deterministically.
75
+ events.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0));
76
+ return events;
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ /** Returns true if any events were captured for the run. Cheap existence check. */
83
+ hasEventsForRun(runId) {
84
+ return existsSync(this.logPath(runId));
85
+ }
86
+ /** Drop one run's entire log. Called from cron-delete cascade cleanup. */
87
+ removeRun(runId) {
88
+ const filePath = this.logPath(runId);
89
+ if (!existsSync(filePath))
90
+ return false;
91
+ try {
92
+ writeFileSync(filePath, ''); // truncate, then unlink — symmetric with cron-runs delete pattern
93
+ const fs = require('node:fs');
94
+ fs.unlinkSync(filePath);
95
+ return true;
96
+ }
97
+ catch (err) {
98
+ logger.warn({ err, runId }, 'Failed to remove event log');
99
+ return false;
100
+ }
101
+ }
102
+ /** Total disk size of all event logs in bytes. For diagnostics. */
103
+ totalBytes() {
104
+ if (!existsSync(this.dir))
105
+ return 0;
106
+ let total = 0;
107
+ try {
108
+ for (const f of readdirSync(this.dir)) {
109
+ if (!f.endsWith('.jsonl'))
110
+ continue;
111
+ try {
112
+ total += statSync(path.join(this.dir, f)).size;
113
+ }
114
+ catch { /* skip */ }
115
+ }
116
+ }
117
+ catch { /* skip */ }
118
+ return total;
119
+ }
120
+ maybePrune(filePath) {
121
+ try {
122
+ const { size } = statSync(filePath);
123
+ if (size <= EventLog.MAX_BYTES)
124
+ return;
125
+ const lines = readFileSync(filePath, 'utf-8').trim().split('\n');
126
+ if (lines.length <= EventLog.MAX_LINES)
127
+ return;
128
+ const trimmed = lines.slice(-EventLog.MAX_LINES);
129
+ writeFileSync(filePath, trimmed.join('\n') + '\n');
130
+ }
131
+ catch {
132
+ // non-critical
133
+ }
134
+ }
135
+ }
136
+ //# sourceMappingURL=event-log.js.map
@@ -237,6 +237,8 @@ export declare class Gateway {
237
237
  skillsMissing: string[];
238
238
  allowedToolsApplied?: string[];
239
239
  mcpServersApplied: string[];
240
+ /** PRD §6 / 1.18.85: run UUID from runAgent. */
241
+ runId?: string;
240
242
  } | undefined;
241
243
  requestApproval(descriptionOrId: string, explicitId?: string): Promise<boolean | string>;
242
244
  resolveApproval(requestId: string, result: boolean | string): void;
@@ -2023,6 +2023,7 @@ export class Gateway {
2023
2023
  skillsMissing: cronResult.skillsMissing,
2024
2024
  allowedToolsApplied: cronResult.allowedToolsApplied,
2025
2025
  mcpServersApplied: cronResult.mcpServersApplied,
2026
+ runId: cronResult.runId,
2026
2027
  };
2027
2028
  logger.info({
2028
2029
  jobName,
package/dist/types.d.ts CHANGED
@@ -404,7 +404,55 @@ export interface LongTaskPreflightSnapshot {
404
404
  reasons: string[];
405
405
  }
406
406
  export type TerminalReason = 'blocking_limit' | 'rapid_refill_breaker' | 'prompt_too_long' | 'image_error' | 'model_error' | 'aborted_streaming' | 'aborted_tools' | 'stop_hook_prevented' | 'hook_stopped' | 'tool_deferred' | 'max_turns' | 'completed';
407
+ /**
408
+ * PRD §6 Event entity — one row per significant SDK message during a Run.
409
+ * Stored as JSONL at ~/.clementine/events/<run_id>.jsonl. Powers the new
410
+ * Run detail waterfall (Phase 4b) and the metrics dashboards (Phase 6).
411
+ *
412
+ * Designed to fit the SDK's typed message stream + the 12 hook events. Most
413
+ * fields are optional because each event kind populates a different subset.
414
+ */
415
+ export interface RunEvent {
416
+ /** Run this event belongs to — links back to CronRunEntry.id. */
417
+ runId: string;
418
+ /** SDK session id once known (system/init lands first; everything else carries it). */
419
+ sessionId?: string;
420
+ /** Monotonic sequence within the run. Used to order events that share a ts. */
421
+ seq: number;
422
+ /** ISO timestamp when the event was captured. */
423
+ ts: string;
424
+ /** Event kind — semantic grouping for the dashboard's span types. */
425
+ kind: 'session_start' | 'session_end' | 'llm_text' | 'thinking' | 'tool_call' | 'tool_result' | 'subagent_start' | 'subagent_stop' | 'rate_limit' | 'hook' | 'error';
426
+ /** Hook event name when kind='hook' (PreToolUse / PostToolUse / etc.). */
427
+ hookEventName?: string;
428
+ /** Tool name when kind='tool_call' or 'tool_result'. Includes mcp__ prefix. */
429
+ toolName?: string;
430
+ /** SDK-assigned tool_use id — pairs tool_call with its tool_result. */
431
+ toolUseId?: string;
432
+ /** For nested tool calls (parallel sub-spawning). */
433
+ parentToolUseId?: string;
434
+ /** Tool input as JSON, truncated at 8KB. */
435
+ toolInput?: unknown;
436
+ /** Tool result as JSON or string, truncated at 16KB. */
437
+ toolResult?: unknown;
438
+ /** Tool error message when result.is_error or PostToolUseFailure. */
439
+ toolError?: string;
440
+ /** Assistant text block content when kind='llm_text'. */
441
+ text?: string;
442
+ /** ThinkingBlock content when kind='thinking'. */
443
+ thinking?: string;
444
+ /** Cost so far for this run when kind='session_end'. */
445
+ costUsd?: number;
446
+ /** Stop reason from ResultMessage when kind='session_end'. */
447
+ stopReason?: string;
448
+ /** Subagent id when kind='subagent_*'. */
449
+ agentId?: string;
450
+ }
407
451
  export interface CronRunEntry {
452
+ /** PRD §6 / 1.18.85: stable run UUID. Optional only because pre-1.18.85
453
+ * entries don't have it; new entries always do. The Event store keys
454
+ * off this id. */
455
+ id?: string;
408
456
  jobName: string;
409
457
  startedAt: string;
410
458
  /** Optional: in-progress runs are appended with status='running' before the
@@ -447,6 +495,11 @@ export interface CronRunEntry {
447
495
  allowedToolsApplied?: string[];
448
496
  /** MCP servers live for this run (post profile + trick allowlist intersection). */
449
497
  mcpServersApplied?: string[];
498
+ /** PRD §6 / 1.18.84: how this run was triggered. Persisted by the
499
+ * scheduler (cron tick / chained 'after' / manual-run endpoint /
500
+ * Discord) so the Run list can filter by source instead of guessing
501
+ * via heuristics on attempt count. */
502
+ trigger?: 'manual' | 'scheduled' | 'webhook' | 'api' | 'fork' | 'resume' | 'discord' | 'after';
450
503
  /** PRD Phase 1: did the run accomplish what it was supposed to?
451
504
  * Computed at run-end when the Task has successSchema or successCriteriaText.
452
505
  * - status='pass' both configured checks passed (or the only one configured did)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.83",
3
+ "version": "1.18.85",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",