clementine-agent 1.18.70 → 1.18.72

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.
@@ -21,7 +21,7 @@ import { discoverMcpServers, getClaudeIntegrations } from '../agent/mcp-bridge.j
21
21
  import { buildBuilderEnrichedMessage, builderSessionKey } from '../dashboard/builder/prompt.js';
22
22
  import { AGENTS_DIR, SESSIONS_FILE, applyOneMillionContextRecovery, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
23
23
  import { parseTasks } from '../tools/shared.js';
24
- import { todayISO } from '../gateway/cron-scheduler.js';
24
+ import { todayISO, CronRunLog } from '../gateway/cron-scheduler.js';
25
25
  import { goalsRouter } from './routes/goals.js';
26
26
  import { delegationsRouter } from './routes/delegations.js';
27
27
  import { workflowsRouter } from './routes/workflows.js';
@@ -2516,6 +2516,10 @@ export async function cmdDashboard(opts) {
2516
2516
  hasKey = true;
2517
2517
  authMode = 'oauth-token';
2518
2518
  }
2519
+ else if (/^CLAUDE_CODE_OAUTH_TOKEN=.+/m.test(content)) {
2520
+ hasKey = true;
2521
+ authMode = 'oauth-token';
2522
+ }
2519
2523
  else if (/^ANTHROPIC_API_KEY=.+/m.test(content)) {
2520
2524
  hasKey = true;
2521
2525
  authMode = 'api-key';
@@ -2525,14 +2529,28 @@ export async function cmdDashboard(opts) {
2525
2529
  hasKey = true;
2526
2530
  authMode = 'oauth-token';
2527
2531
  }
2532
+ if (!hasKey && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
2533
+ hasKey = true;
2534
+ authMode = 'oauth-token';
2535
+ }
2528
2536
  if (!hasKey && process.env.ANTHROPIC_API_KEY) {
2529
2537
  hasKey = true;
2530
2538
  authMode = 'api-key';
2531
2539
  }
2532
- // If no explicit creds, the SDK subprocess reads keychain OAuth automatically
2533
- if (!hasKey) {
2534
- authMode = 'keychain';
2535
- hasKey = true;
2540
+ // Honest keychain check: only report authenticated if Claude Code actually has a session.
2541
+ // Previously this assumed yes blindly, which hid the welcome panel from new desktop users.
2542
+ if (!hasKey && process.platform === 'darwin') {
2543
+ try {
2544
+ const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', {
2545
+ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 1500,
2546
+ }).trim();
2547
+ const parsed = JSON.parse(raw);
2548
+ if (parsed?.claudeAiOauth?.accessToken) {
2549
+ hasKey = true;
2550
+ authMode = 'keychain';
2551
+ }
2552
+ }
2553
+ catch { /* no keychain entry — leave hasKey false */ }
2536
2554
  }
2537
2555
  res.json({
2538
2556
  authenticated: hasKey,
@@ -2544,6 +2562,65 @@ export async function cmdDashboard(opts) {
2544
2562
  res.json({ authenticated: false, email: null, error: String(err).slice(0, 200) });
2545
2563
  }
2546
2564
  });
2565
+ // Restart the dashboard daemon so newly-saved auth credentials take effect.
2566
+ // SAFE_ENV in src/agent/assistant.ts is built once at module load, so changes
2567
+ // to ~/.clementine/.env after that point require a process restart to flow
2568
+ // through to spawned SDK subprocesses. In desktop mode (CLI spawned by
2569
+ // Electron) the parent supervisor respawns this process automatically; in
2570
+ // standalone CLI mode the caller must run `clementine restart` themselves.
2571
+ app.post('/api/restart-daemon', (_req, res) => {
2572
+ const inDesktop = process.env.__CLEM_DASHBOARD_CHILD === '1';
2573
+ res.json({ ok: true, willExit: inDesktop, mode: inDesktop ? 'desktop-child' : 'standalone' });
2574
+ if (inDesktop) {
2575
+ // Give the response a moment to flush before exiting.
2576
+ setTimeout(() => { process.exit(0); }, 250);
2577
+ }
2578
+ });
2579
+ // Save an API key (or OAuth token) pasted by the user. Validates against the
2580
+ // Anthropic API before writing to ~/.clementine/.env so we never persist a
2581
+ // bad key. Mirrors the `clementine login --api-key` CLI flow.
2582
+ app.post('/api/auth/anthropic/api-key', async (req, res) => {
2583
+ try {
2584
+ const raw = String((req.body?.apiKey ?? '')).trim();
2585
+ if (!raw) {
2586
+ res.status(400).json({ error: 'Missing apiKey in request body' });
2587
+ return;
2588
+ }
2589
+ // Detect token type by prefix: Anthropic OAuth tokens start with sk-ant-oat
2590
+ // or sk-ant-rt; everything else is treated as a standard API key.
2591
+ const isOAuth = /^sk-ant-(oat|rt)/.test(raw);
2592
+ const credKey = isOAuth ? 'CLAUDE_CODE_OAUTH_TOKEN' : 'ANTHROPIC_API_KEY';
2593
+ // Validate by calling /v1/models — cheap, doesn't burn tokens.
2594
+ const Anthropic = (await import('@anthropic-ai/sdk')).default;
2595
+ const client = new Anthropic(isOAuth ? { authToken: raw } : { apiKey: raw });
2596
+ try {
2597
+ await client.models.list({ limit: 1 });
2598
+ }
2599
+ catch (err) {
2600
+ const status = err?.status || err?.response?.status;
2601
+ const detail = status === 401
2602
+ ? 'Key was rejected by Anthropic (401). Check that you copied it fully.'
2603
+ : `Validation failed: ${(err?.message || String(err)).slice(0, 180)}`;
2604
+ res.status(400).json({ ok: false, error: detail });
2605
+ return;
2606
+ }
2607
+ // Persist to ~/.clementine/.env (replace any existing line for this key).
2608
+ const envPath = path.join(BASE_DIR, '.env');
2609
+ const dir = path.dirname(envPath);
2610
+ try {
2611
+ mkdirSync(dir, { recursive: true });
2612
+ }
2613
+ catch { /* exists */ }
2614
+ let content = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : '';
2615
+ content = content.replace(new RegExp(`^${credKey}=.*$\\n?`, 'm'), '').trimEnd();
2616
+ content += `\n${credKey}=${raw}\n`;
2617
+ writeFileSync(envPath, content, { mode: 0o600 });
2618
+ res.json({ ok: true, credKey, apiKeySource: isOAuth ? 'oauth-token' : 'api-key' });
2619
+ }
2620
+ catch (err) {
2621
+ res.status(500).json({ ok: false, error: String(err).slice(0, 300) });
2622
+ }
2623
+ });
2547
2624
  // Start OAuth login — spawns SDK query and calls claudeAuthenticate
2548
2625
  let oauthQuery = null;
2549
2626
  app.post('/api/auth/anthropic/login', async (_req, res) => {
@@ -5553,6 +5630,21 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5553
5630
  res.status(500).json({ error: String(err) });
5554
5631
  }
5555
5632
  });
5633
+ // ── Recent runs across ALL cron jobs ───────────────────────────
5634
+ // Powers the "Recent History" zone on the Tasks page. Returns the most
5635
+ // recent N CronRunEntry rows merged from every per-job .jsonl, sorted
5636
+ // newest-first. Uses CronRunLog.readAllRecent which tails each file.
5637
+ app.get('/api/cron/runs', (req, res) => {
5638
+ try {
5639
+ const limit = Math.max(1, Math.min(200, parseInt(String(req.query.limit ?? '50'), 10) || 50));
5640
+ const log = new CronRunLog();
5641
+ const runs = log.readAllRecent(limit, 30);
5642
+ res.json({ ok: true, runs });
5643
+ }
5644
+ catch (err) {
5645
+ res.status(500).json({ ok: false, error: String(err) });
5646
+ }
5647
+ });
5556
5648
  // ── Cron trace viewer ──────────────────────────────────────────
5557
5649
  app.get('/api/cron/traces/:job', (req, res) => {
5558
5650
  try {
@@ -14842,6 +14934,52 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
14842
14934
  border-top: 1px solid var(--border);
14843
14935
  padding-top: 12px;
14844
14936
  }
14937
+ /* ── Compact (de-cluttered) task cards ──
14938
+ Applied to scheduled-task cards on the Tasks page. Keeps name, schedule,
14939
+ prompt, last-run status, and a single Edit button visible by default.
14940
+ Reveals badges, capability strip, and secondary actions on hover/focus
14941
+ so non-technical users see only what they need at a glance. */
14942
+ .task-card.compact .task-card-badges,
14943
+ .task-card.compact .task-cap-strip,
14944
+ .task-card.compact .task-card-actions .secondary {
14945
+ max-height: 0;
14946
+ opacity: 0;
14947
+ overflow: hidden;
14948
+ margin: 0;
14949
+ padding-top: 0;
14950
+ padding-bottom: 0;
14951
+ border-width: 0;
14952
+ pointer-events: none;
14953
+ transition: opacity 0.18s ease, max-height 0.22s ease, padding 0.18s ease, margin 0.18s ease;
14954
+ }
14955
+ .task-card.compact:hover .task-card-badges,
14956
+ .task-card.compact:focus-within .task-card-badges {
14957
+ max-height: 80px;
14958
+ opacity: 1;
14959
+ margin-bottom: 14px;
14960
+ pointer-events: auto;
14961
+ }
14962
+ .task-card.compact:hover .task-cap-strip,
14963
+ .task-card.compact:focus-within .task-cap-strip {
14964
+ max-height: 120px;
14965
+ opacity: 1;
14966
+ padding: 8px 0 10px;
14967
+ margin-bottom: 12px;
14968
+ border-top-width: 1px;
14969
+ pointer-events: auto;
14970
+ }
14971
+ .task-card.compact:hover .task-card-actions .secondary,
14972
+ .task-card.compact:focus-within .task-card-actions .secondary {
14973
+ max-height: 40px;
14974
+ opacity: 1;
14975
+ pointer-events: auto;
14976
+ }
14977
+ .task-card.compact .task-card-actions {
14978
+ flex-wrap: wrap;
14979
+ }
14980
+ .task-card.compact .task-card-actions .primary {
14981
+ flex: 1;
14982
+ }
14845
14983
  /* ── Trick capability strip (skills + MCP + tools at a glance) ─── */
14846
14984
  .task-cap-strip {
14847
14985
  border-top: 1px solid var(--border-light);
@@ -15829,8 +15967,8 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15829
15967
  <div class="nav-item active" data-page="home" data-icon="home" title="Chat, today, activity">
15830
15968
  <span class="nav-icon"></span> Home
15831
15969
  </div>
15832
- <div class="nav-item" data-page="build" data-icon="workflow" title="Tricks Clementine knows">
15833
- <span class="nav-icon"></span> Build
15970
+ <div class="nav-item" data-page="build" data-icon="workflow" title="Scheduled tasks Clementine runs for you">
15971
+ <span class="nav-icon"></span> Tasks
15834
15972
  <span class="nav-badge" id="nav-cron-count" style="display:none">0</span>
15835
15973
  </div>
15836
15974
  <div class="nav-item" data-page="heartbeat" data-icon="bell" title="Heartbeat controls and queued work">
@@ -15905,7 +16043,10 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15905
16043
  <div class="gs-card-icon">&#128274;</div>
15906
16044
  <div class="gs-card-title">Login with Anthropic</div>
15907
16045
  <div class="gs-card-desc" id="gs-auth-desc">Connect your Anthropic account to power your agents.</div>
15908
- <button class="btn btn-sm btn-primary" id="gs-auth-btn" onclick="startAnthropicOAuth()">Login with Claude</button>
16046
+ <div style="display:flex;flex-direction:column;gap:6px;align-items:stretch">
16047
+ <button class="btn btn-sm btn-primary" id="gs-auth-btn" onclick="startAnthropicOAuth()">Login with Claude</button>
16048
+ <button class="btn btn-sm" onclick="openApiKeyModal()" style="font-size:11px">Use API key instead</button>
16049
+ </div>
15909
16050
  </div>
15910
16051
  <div class="gs-card" id="gs-step-agent">
15911
16052
  <div class="gs-step-num">2</div>
@@ -16031,15 +16172,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16031
16172
  </div>
16032
16173
  </div>
16033
16174
 
16034
- <!-- ═══ Build Page — Routines (single unified surface) ═══ -->
16175
+ <!-- ═══ Tasks Page — single unified surface ═══ -->
16035
16176
  <div class="page" id="page-build">
16036
- <!-- Build sub-tabs: Scheduled Tasks (single-prompt cron jobs) | Tricks (multi-step workflows) -->
16037
- <div id="build-tabs" style="display:flex;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0">
16177
+ <!-- Sub-tabs hidden by default. Cron is the single surface; multi-step
16178
+ workflows (formerly "Tricks") are still accessible via deep-link
16179
+ ?tab=workflows or by toggling .show-workflows-tabs on the page. -->
16180
+ <div id="build-tabs" style="display:none;gap:4px;padding:8px 18px 0;background:var(--bg-secondary);border-bottom:1px solid var(--border);flex-shrink:0">
16038
16181
  <button class="build-tab-btn active" data-build-tab="crons" onclick="switchBuildTab('crons')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-primary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
16039
- <span style="margin-right:6px">📅</span>Scheduled Tasks <span id="build-tab-cron-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
16182
+ <span style="margin-right:6px">📅</span>Tasks <span id="build-tab-cron-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
16040
16183
  </button>
16041
16184
  <button class="build-tab-btn" data-build-tab="workflows" onclick="switchBuildTab('workflows')" style="padding:8px 14px;border-radius:6px 6px 0 0;border:none;background:transparent;color:var(--text-secondary);font-size:13px;font-weight:500;cursor:pointer;border-bottom:2px solid transparent">
16042
- <span style="margin-right:6px">🔧</span>Tricks (workflows) <span id="build-tab-workflows-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
16185
+ <span style="margin-right:6px">🔧</span>Workflows <span id="build-tab-workflows-count" style="display:none;margin-left:4px;font-size:10px;background:var(--bg-tertiary);padding:1px 6px;border-radius:999px;color:var(--text-muted)">0</span>
16043
16186
  </button>
16044
16187
  </div>
16045
16188
  <style>
@@ -16054,7 +16197,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16054
16197
  <div id="build-tab-workflows" style="display:none;flex:1;min-height:0;display:flex;flex-direction:column">
16055
16198
  <!-- Toolbar -->
16056
16199
  <div id="routines-toolbar" style="display:flex;align-items:center;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border);background:var(--bg-secondary);flex-wrap:wrap">
16057
- <h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span> Tricks</h2>
16200
+ <h2 style="margin:0;font-size:18px;font-weight:600;color:var(--text-primary);display:flex;align-items:center;gap:8px"><span data-icon="workflow" class="icon-slot"></span> Workflows</h2>
16058
16201
  <span id="routines-count" style="font-size:11px;color:var(--text-muted)"></span>
16059
16202
  <span id="routines-editor-breadcrumb" style="display:none;font-size:12px;color:var(--text-muted)"> &rsaquo; <span id="routines-editor-name" style="color:var(--text-primary);font-weight:500"></span></span>
16060
16203
  <span style="flex:1"></span>
@@ -16065,16 +16208,16 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16065
16208
  </select>
16066
16209
  <button id="routines-back-btn" style="display:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)" onclick="window.RoutinesUI && RoutinesUI.closeEditor()">&larr; Back to list</button>
16067
16210
  <button id="routines-assist-btn" class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" title="Skip the chat and fill out a form yourself" style="padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Build manually</button>
16068
- <button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Trick</button>
16211
+ <button id="routines-create-btn" class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ New Workflow</button>
16069
16212
  </div>
16070
16213
  <!-- List view (default) -->
16071
16214
  <div id="routines-list-pane" style="flex:1;min-height:0;overflow-y:auto;padding:18px;background:var(--bg-primary)">
16072
16215
  <div id="routines-list-empty" class="empty-state" style="display:none;padding:64px 18px;text-align:center;color:var(--text-muted)">
16073
16216
  <div style="font-size:38px;opacity:0.4;margin-bottom:14px">&#9881;</div>
16074
- <div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No tricks yet</div>
16075
- <div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A Trick is a sequence of steps Clementine performs on cue &mdash; call MCP tools, run local CLIs, prompt the agent, branch on results &mdash; that runs on a schedule or on demand. Example: &ldquo;at 8am check email; if anything urgent, summarize and Slack me.&rdquo;</div>
16217
+ <div style="font-size:15px;font-weight:500;color:var(--text-secondary);margin-bottom:6px">No workflows yet</div>
16218
+ <div style="font-size:12px;line-height:1.5;max-width:380px;margin:0 auto 16px">A workflow is a sequence of steps Clementine performs on cue &mdash; call MCP tools, run local CLIs, prompt the agent, branch on results &mdash; that runs on a schedule or on demand. Example: &ldquo;at 8am check email; if anything urgent, summarize and Slack me.&rdquo;</div>
16076
16219
  <div style="display:flex;gap:8px;justify-content:center;flex-wrap:wrap">
16077
- <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px">+ New Trick</button>
16220
+ <button class="btn-sm btn-primary" onclick="window.RoutinesUI && RoutinesUI.openChat()" style="padding:6px 14px">+ New Workflow</button>
16078
16221
  <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.openCreate()" style="padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">Build manually</button>
16079
16222
  </div>
16080
16223
  </div>
@@ -16102,7 +16245,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16102
16245
  <!-- Create modal -->
16103
16246
  <div id="routines-create-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
16104
16247
  <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:22px;width:480px;max-width:92vw;display:flex;flex-direction:column;gap:12px">
16105
- <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New trick</h3>
16248
+ <h3 style="margin:0;font-size:16px;font-weight:600;color:var(--text-primary)">New workflow</h3>
16106
16249
  <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Name</label>
16107
16250
  <input type="text" id="routines-create-name" placeholder="e.g. 8am Email Triage" style="padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text-primary);font-size:13px">
16108
16251
  <label style="font-size:11px;color:var(--text-muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em">Description (optional)</label>
@@ -16137,7 +16280,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16137
16280
  <div id="routines-chat-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:200;align-items:center;justify-content:center">
16138
16281
  <div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);width:1100px;max-width:96vw;height:88vh;max-height:88vh;display:flex;flex-direction:column;overflow:hidden">
16139
16282
  <div style="padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;flex-shrink:0">
16140
- <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a trick with Clementine</h3>
16283
+ <h3 style="margin:0;font-size:15px;font-weight:600;color:var(--text-primary)">Build a workflow with Clementine</h3>
16141
16284
  <span id="routines-chat-status" style="color:var(--text-muted);flex:1;font-size:11px;min-height:14px"></span>
16142
16285
  <button class="btn-sm" onclick="window.RoutinesUI && RoutinesUI.closeChat()" style="padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)">&times;</button>
16143
16286
  </div>
@@ -16246,7 +16389,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16246
16389
  if (filter === '__global__') return r.scope === 'global';
16247
16390
  return r.scope === 'agent' && r.agentSlug === filter;
16248
16391
  });
16249
- if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' trick' : ' tricks');
16392
+ if (count) count.textContent = rows.length === 0 ? '' : rows.length + (rows.length === 1 ? ' workflow' : ' workflows');
16250
16393
  if (rows.length === 0) {
16251
16394
  empty.style.display = 'block';
16252
16395
  wrap.style.display = 'none';
@@ -16287,7 +16430,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16287
16430
  if (r.status === 409) {
16288
16431
  return r.json().then(function(j){
16289
16432
  var lines = (j.sideEffects || []).map(function(s){ return ' • ' + s.kind + ': ' + s.label; }).join('\\n');
16290
- if (confirm('This trick has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
16433
+ if (confirm('This workflow has side effects:\\n\\n' + lines + '\\n\\nProceed?')) R.run(id, true);
16291
16434
  });
16292
16435
  }
16293
16436
  return r.json().then(function(j){
@@ -16301,7 +16444,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16301
16444
  apiFetch('/api/routines/' + encodeURIComponent(id))
16302
16445
  .then(function(r){ return r.json(); })
16303
16446
  .then(function(data){
16304
- if (!data || !data.routine) { alert('Failed to load trick'); return; }
16447
+ if (!data || !data.routine) { alert('Failed to load workflow'); return; }
16305
16448
  R.state.editing = { id: data.id, routine: data.routine, dirty: false, validation: data.validation };
16306
16449
  R.showEditor();
16307
16450
  }).catch(function(err){ alert('Open failed: ' + err); });
@@ -16556,7 +16699,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16556
16699
  },
16557
16700
  removeStep: function(idx) {
16558
16701
  if (!R.state.editing) return;
16559
- if (R.state.editing.routine.steps.length <= 1) { alert('A trick must have at least one step.'); return; }
16702
+ if (R.state.editing.routine.steps.length <= 1) { alert('A workflow must have at least one step.'); return; }
16560
16703
  if (!confirm('Remove this step?')) return;
16561
16704
  var removed = R.state.editing.routine.steps.splice(idx, 1)[0];
16562
16705
  // Strip lingering dependsOn references.
@@ -16773,7 +16916,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16773
16916
  R.renderChatMessages();
16774
16917
  R.renderChatSpec();
16775
16918
  // Seed with a greeting from the assistant so the panel isn't empty.
16776
- R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a trick you can save.');
16919
+ R.appendChatMessage('assistant', 'Hi! Tell me what you want Clementine to do — a sentence is fine. I\\x27ll ask a couple of follow-ups (when it should run, which tools she needs, which model) and draft a workflow you can save.');
16777
16920
  m.style.display = 'flex';
16778
16921
  setTimeout(function(){ document.getElementById('routines-chat-input').focus(); }, 50);
16779
16922
  },
@@ -16809,9 +16952,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16809
16952
  pane.classList.toggle('trick-spec-streaming', !!R.state.chatStreaming);
16810
16953
  var a = R.state.chatArtifact;
16811
16954
  if (!a || !a.name) {
16812
- pane.innerHTML = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:10px">Trick spec</div>'
16955
+ pane.innerHTML = '<div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:10px">Workflow spec</div>'
16813
16956
  + '<div class="trick-spec-card" style="opacity:0.7"><div class="trick-spec-skeleton-row" style="width:60%"></div><div class="trick-spec-skeleton-row" style="width:90%"></div><div class="trick-spec-skeleton-row" style="width:40%"></div></div>'
16814
- + '<div style="font-size:11px;color:var(--text-muted);line-height:1.5;margin-top:14px;font-style:italic">I\\x27ll fill this in as we chat. Each answer you give populates a field on the right — name, schedule, model, steps. When it\\x27s ready you\\x27ll see a Save trick button.</div>';
16957
+ + '<div style="font-size:11px;color:var(--text-muted);line-height:1.5;margin-top:14px;font-style:italic">I\\x27ll fill this in as we chat. Each answer you give populates a field on the right — name, schedule, model, steps. When it\\x27s ready you\\x27ll see a Save workflow button.</div>';
16815
16958
  return;
16816
16959
  }
16817
16960
  // Parse the YAML-ish steps string into displayable cards.
@@ -16819,7 +16962,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16819
16962
  var stepCount = steps.length;
16820
16963
  var schedule = a.schedule ? R.humanizeCron(a.schedule) : 'manual';
16821
16964
  var modelLabel = a.model ? R.modelLabel(a.model) : 'inherit';
16822
- var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em">Trick spec</div><span style="flex:1"></span>'
16965
+ var head = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.04em">Workflow spec</div><span style="flex:1"></span>'
16823
16966
  + (R.state.chatStreaming ? '<span style="font-size:11px;color:var(--clementine)">drafting…</span>' : '')
16824
16967
  + '</div>';
16825
16968
  var meta = '<div class="trick-spec-card">'
@@ -16845,7 +16988,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
16845
16988
  }
16846
16989
  var ready = a.name && stepCount > 0;
16847
16990
  var saveRow = ready
16848
- ? '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px"><span style="font-size:12px;color:var(--green,#22c55e);font-weight:500">✓ Ready to save</span><span style="flex:1"></span><button class="btn-sm btn-primary" onclick="window.RoutinesUI&&RoutinesUI.saveChatDraft()" style="padding:6px 18px">Save trick</button></div>'
16991
+ ? '<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:10px"><span style="font-size:12px;color:var(--green,#22c55e);font-weight:500">✓ Ready to save</span><span style="flex:1"></span><button class="btn-sm btn-primary" onclick="window.RoutinesUI&&RoutinesUI.saveChatDraft()" style="padding:6px 18px">Save workflow</button></div>'
16849
16992
  : '<div style="margin-top:18px;font-size:11px;color:var(--text-muted);font-style:italic">A few more details and I\\x27ll let you save.</div>';
16850
16993
  pane.innerHTML = head + meta + stepsHtml + saveRow;
16851
16994
  },
@@ -17005,7 +17148,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17005
17148
  })
17006
17149
  }).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
17007
17150
  .then(function(res){
17008
- if (btn) { btn.textContent = 'Save trick'; btn.disabled = false; }
17151
+ if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
17009
17152
  if (!res.ok) {
17010
17153
  alert('Save failed: ' + (res.body && res.body.error || 'unknown'));
17011
17154
  return;
@@ -17013,7 +17156,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
17013
17156
  R.closeChat();
17014
17157
  if (res.body && res.body.id) R.openEditor(res.body.id);
17015
17158
  }).catch(function(err){
17016
- if (btn) { btn.textContent = 'Save trick'; btn.disabled = false; }
17159
+ if (btn) { btn.textContent = 'Save workflow'; btn.disabled = false; }
17017
17160
  alert('Save failed: ' + err);
17018
17161
  });
17019
17162
  },
@@ -19655,7 +19798,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19655
19798
  <option value="weekly">Weekly</option>
19656
19799
  <option value="hourly">Every N hours</option>
19657
19800
  <option value="minutes">Every N minutes</option>
19658
- <option value="custom">Custom cron expression</option>
19801
+ <!-- Custom cron is hidden from the dropdown; revealed via the
19802
+ "Use a cron expression" link below for power users. -->
19803
+ <option value="custom" style="display:none">Custom cron expression</option>
19659
19804
  </select>
19660
19805
  <select id="sched-day" style="display:none" onchange="updateScheduleFromBuilder()">
19661
19806
  <option value="1">Monday</option>
@@ -19722,6 +19867,9 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19722
19867
  <input type="text" id="cron-schedule" placeholder="0 9 * * *" oninput="updateScheduleHint()">
19723
19868
  </div>
19724
19869
  <div class="form-hint" id="cron-schedule-hint" style="margin-top:6px"></div>
19870
+ <div style="margin-top:8px">
19871
+ <a href="#" id="sched-cron-link" onclick="event.preventDefault();var s=document.getElementById('sched-freq');s.value='custom';updateScheduleBuilder();this.style.display='none';" style="font-size:11px;color:var(--text-muted);text-decoration:none;border-bottom:1px dotted var(--border)">Use a cron expression instead</a>
19872
+ </div>
19725
19873
  </div>
19726
19874
  </div>
19727
19875
  </div>
@@ -19751,13 +19899,17 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
19751
19899
  </div>
19752
19900
  </div>
19753
19901
 
19754
- <!-- Capabilities: predictable mode + skills + MCP + tools + tags -->
19902
+ <!-- Skills & tools: pinned skills + MCP + tools + tags -->
19755
19903
  <div class="cron-section-card">
19756
- <h4>Capabilities</h4>
19757
- <p class="cron-section-desc">Predictable mode locks the run to exactly what's pinned here. Switch to the <strong>What will run</strong> tab to see the resolved final state.</p>
19758
-
19759
- <!-- Predictable Mode (prominent at top) -->
19760
- <label id="cron-predictable-card" class="cron-predictable-card on" style="cursor:pointer;margin-bottom:12px">
19904
+ <h4>Skills &amp; tools</h4>
19905
+ <p class="cron-section-desc">Pin the skills and tools this task should use. Switch to the <strong>What will run</strong> tab to see exactly what the agent receives.</p>
19906
+
19907
+ <!-- Predictable Mode is now hidden by default — new tasks always
19908
+ get predictable=true. Legacy tasks (predictable !== true) reveal
19909
+ the toggle automatically via openEditCronModal() so users can
19910
+ see/migrate. The checkbox stays in the DOM so saveCronJob()
19911
+ can read its value unchanged. -->
19912
+ <label id="cron-predictable-card" class="cron-predictable-card on" style="display:none;cursor:pointer;margin-bottom:12px">
19761
19913
  <span class="pred-icon" id="cron-predictable-icon">🔒</span>
19762
19914
  <input type="checkbox" id="cron-predictable" checked style="margin-top:3px" onchange="onPredictableChange()">
19763
19915
  <span style="flex:1">
@@ -20293,6 +20445,100 @@ async function startAnthropicOAuth() {
20293
20445
  }
20294
20446
  }
20295
20447
 
20448
+ // ── API key paste flow (alternative to Login with Claude) ────────────
20449
+ function openApiKeyModal() {
20450
+ if (document.getElementById('api-key-modal')) return;
20451
+ var overlay = document.createElement('div');
20452
+ overlay.id = 'api-key-modal';
20453
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:1000;display:flex;align-items:center;justify-content:center';
20454
+ overlay.onclick = function(e) { if (e.target === overlay) closeApiKeyModal(); };
20455
+ overlay.innerHTML =
20456
+ '<div style="background:var(--bg-secondary,#1f2532);border:1px solid var(--border,#333);border-radius:10px;width:480px;max-width:92vw;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,0.4)">'
20457
+ + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">'
20458
+ + '<div style="font-size:15px;font-weight:600;color:var(--text-primary,#fff)">Use an Anthropic API key</div>'
20459
+ + '<button class="btn-ghost btn-sm" onclick="closeApiKeyModal()" style="background:transparent;border:0;color:var(--text-muted);font-size:18px;cursor:pointer">&times;</button>'
20460
+ + '</div>'
20461
+ + '<div style="font-size:12px;color:var(--text-muted,#888);line-height:1.5;margin-bottom:14px">'
20462
+ + 'Paste an API key from your Anthropic Console. We validate it before saving.'
20463
+ + ' <a href="#" id="api-key-console-link" style="color:var(--accent,#7aa2f7);text-decoration:underline">Open the keys page</a>'
20464
+ + ' &middot; key starts with <code style="color:var(--text-secondary)">sk-ant-</code>'
20465
+ + '</div>'
20466
+ + '<input id="api-key-input" type="password" placeholder="sk-ant-..." autocomplete="off" spellcheck="false"'
20467
+ + ' style="width:100%;padding:10px 12px;background:var(--bg-input,#0e131c);border:1px solid var(--border,#333);border-radius:6px;color:var(--text-primary,#fff);font-family:monospace;font-size:13px;letter-spacing:0.5px"'
20468
+ + ' onkeydown="if(event.key===\\x27Enter\\x27)submitApiKey()">'
20469
+ + '<div id="api-key-status" style="font-size:12px;margin-top:10px;min-height:16px"></div>'
20470
+ + '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px">'
20471
+ + '<button class="btn btn-sm" onclick="closeApiKeyModal()">Cancel</button>'
20472
+ + '<button class="btn btn-sm btn-primary" id="api-key-submit-btn" onclick="submitApiKey()">Validate &amp; save</button>'
20473
+ + '</div>'
20474
+ + '</div>';
20475
+ document.body.appendChild(overlay);
20476
+ var link = document.getElementById('api-key-console-link');
20477
+ if (link) link.onclick = function(e) {
20478
+ e.preventDefault();
20479
+ window.open('https://console.anthropic.com/settings/keys', '_blank', 'noopener');
20480
+ };
20481
+ var input = document.getElementById('api-key-input');
20482
+ if (input) setTimeout(function() { input.focus(); }, 30);
20483
+ }
20484
+
20485
+ function closeApiKeyModal() {
20486
+ var el = document.getElementById('api-key-modal');
20487
+ if (el) el.remove();
20488
+ }
20489
+
20490
+ async function submitApiKey() {
20491
+ var input = document.getElementById('api-key-input');
20492
+ var status = document.getElementById('api-key-status');
20493
+ var btn = document.getElementById('api-key-submit-btn');
20494
+ if (!input) return;
20495
+ var key = String(input.value || '').trim();
20496
+ if (!key) { if (status) status.textContent = 'Paste a key first.'; return; }
20497
+ if (!/^sk-ant-/.test(key)) {
20498
+ if (status) status.innerHTML = '<span style="color:var(--red,#f55)">That does not look like an Anthropic key (expected sk-ant-...).</span>';
20499
+ return;
20500
+ }
20501
+ if (btn) { btn.disabled = true; btn.textContent = 'Validating...'; }
20502
+ if (status) status.innerHTML = '<span style="color:var(--text-muted)">Calling Anthropic to verify the key...</span>';
20503
+ try {
20504
+ var r = await apiFetch('/api/auth/anthropic/api-key', {
20505
+ method: 'POST',
20506
+ headers: { 'Content-Type': 'application/json' },
20507
+ body: JSON.stringify({ apiKey: key }),
20508
+ });
20509
+ var d = await r.json();
20510
+ if (!r.ok || !d.ok) throw new Error(d.error || 'Validation failed');
20511
+ // Trigger a daemon restart so SAFE_ENV picks up the new key. In desktop
20512
+ // mode, Electron respawns the child within ~1.5s; we then reload.
20513
+ if (status) status.innerHTML = '<span style="color:var(--green,#5fa)">Saved. Restarting Clementine to activate...</span>';
20514
+ try {
20515
+ var rr = await apiFetch('/api/restart-daemon', { method: 'POST' });
20516
+ var rd = await rr.json();
20517
+ if (rd && rd.willExit) {
20518
+ // Daemon is exiting now. Wait for it to come back, then reload so
20519
+ // the new auth state is reflected everywhere on the page.
20520
+ setTimeout(function() { location.reload(); }, 2500);
20521
+ } else {
20522
+ // Standalone (non-desktop) — daemon will not auto-restart.
20523
+ if (status) status.innerHTML = '<span style="color:var(--green,#5fa)">Saved. Restart Clementine (run <code>clementine restart</code>) to activate.</span>';
20524
+ setTimeout(function() {
20525
+ closeApiKeyModal();
20526
+ checkAnthropicAuth();
20527
+ }, 1800);
20528
+ }
20529
+ } catch (_) {
20530
+ // Restart endpoint failed — fall back to client-side refresh.
20531
+ setTimeout(function() {
20532
+ closeApiKeyModal();
20533
+ checkAnthropicAuth();
20534
+ }, 600);
20535
+ }
20536
+ } catch (err) {
20537
+ if (status) status.innerHTML = '<span style="color:var(--red,#f55)">' + (err && err.message ? err.message : String(err)) + '</span>';
20538
+ if (btn) { btn.disabled = false; btn.textContent = 'Validate & save'; }
20539
+ }
20540
+ }
20541
+
20296
20542
  // ── Authenticated fetch helper ────────────
20297
20543
  var _dashToken = document.querySelector('meta[name="dashboard-token"]')?.getAttribute('content') || '';
20298
20544
  async function apiFetch(url, opts) {
@@ -22845,7 +23091,7 @@ function renderTrickFilterRow(allTasks, filter) {
22845
23091
 
22846
23092
  function renderScheduledTaskCard(task) {
22847
23093
  var enabled = task.enabled !== false;
22848
- var cardCls = 'task-card' + (enabled ? '' : ' disabled') + (task.health === 'running' ? ' running' : '');
23094
+ var cardCls = 'task-card compact' + (enabled ? '' : ' disabled') + (task.health === 'running' ? ' running' : '');
22849
23095
  var style = task.health === 'broken' ? 'border-left:3px solid var(--red)' : task.health === 'failed' ? 'border-left:3px solid var(--yellow)' : '';
22850
23096
  var lastRunHtml = '<span style="color:var(--text-muted)">Never run</span>';
22851
23097
  if (task.lastRun) {
@@ -22895,11 +23141,11 @@ function renderScheduledTaskCard(task) {
22895
23141
  + renderTrickTagChips(task)
22896
23142
  + '<div class="task-card-badges">' + badges + '</div>'
22897
23143
  + '<div class="task-card-actions">'
22898
- + '<button class="btn-sm btn-success" onclick="apiPost(\\x27/api/cron/run/' + encodeURIComponent(task.name) + '\\x27)">Run Now</button>'
22899
- + '<button class="btn-sm" onclick="openCronPreview(\\x27' + safeName + '\\x27)">Preview</button>'
22900
- + '<button class="btn-sm" data-trace-job="' + esc(task.name) + '">Trace</button>'
22901
- + '<button class="btn-sm" onclick="openEditCronModal(\\x27' + safeName + '\\x27)">Edit</button>'
22902
- + '<button class="btn-sm btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)">Del</button>'
23144
+ + '<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>'
23145
+ + '<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>'
23146
+ + '<button class="btn-sm secondary" onclick="openCronPreview(\\x27' + safeName + '\\x27)" title="See exactly what will run">Preview</button>'
23147
+ + '<button class="btn-sm secondary" data-trace-job="' + esc(task.name) + '" title="View execution trace">Trace</button>'
23148
+ + '<button class="btn-sm secondary btn-danger" onclick="confirmDeleteCron(\\x27' + safeName + '\\x27)" title="Delete task">Del</button>'
22903
23149
  + '</div></div>';
22904
23150
  }
22905
23151
 
@@ -22927,6 +23173,67 @@ function renderScheduledWorkflowCard(wf) {
22927
23173
  + '</div></div>';
22928
23174
  }
22929
23175
 
23176
+ // ── Recent history list (Tasks page, bottom zone) ───────────────────────
23177
+ // Renders a compact table of CronRunEntry rows fetched from /api/cron/runs.
23178
+ // Each row shows status icon, job name, started time, duration, and a Trace
23179
+ // button. Click anywhere on the row to open the full trace viewer.
23180
+ function renderRecentHistoryList(runs) {
23181
+ if (!runs || runs.length === 0) {
23182
+ return '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">No runs yet. History appears here once your tasks start firing.</div>';
23183
+ }
23184
+ var rowsHtml = '';
23185
+ for (var i = 0; i < runs.length; i++) {
23186
+ var entry = runs[i] || {};
23187
+ var status = entry.status || 'unknown';
23188
+ var statusColor, statusIcon;
23189
+ if (status === 'ok') { statusColor = 'var(--green)'; statusIcon = '&#10003;'; }
23190
+ else if (status === 'error') { statusColor = 'var(--red)'; statusIcon = '&#10007;'; }
23191
+ else if (status === 'retried') { statusColor = 'var(--yellow)'; statusIcon = '&#8635;'; }
23192
+ else if (status === 'skipped') { statusColor = 'var(--text-muted)'; statusIcon = '&minus;'; }
23193
+ else { statusColor = 'var(--text-muted)'; statusIcon = '&middot;'; }
23194
+ var jobName = entry.jobName || '(unknown)';
23195
+ var safeName = jsStr(jobName);
23196
+ var startedAt = entry.startedAt ? new Date(entry.startedAt) : null;
23197
+ var startedLabel = startedAt ? startedAt.toLocaleString() : '—';
23198
+ var durationLabel = entry.durationMs != null ? formatDurationMs(entry.durationMs) : '—';
23199
+ var attemptLabel = entry.attempt && entry.attempt > 1 ? ' · attempt ' + esc(entry.attempt) : '';
23200
+ var errorPreview = '';
23201
+ if (status === 'error' && entry.error) {
23202
+ var msg = String(entry.error).slice(0, 120);
23203
+ errorPreview = '<div style="font-size:11px;color:var(--red);margin-top:2px;word-break:break-word">' + esc(msg) + '</div>';
23204
+ } else if (entry.outputPreview) {
23205
+ var preview = String(entry.outputPreview).slice(0, 140);
23206
+ errorPreview = '<div style="font-size:11px;color:var(--text-muted);margin-top:2px;word-break:break-word">' + esc(preview) + '</div>';
23207
+ }
23208
+ rowsHtml += '<div class="history-row" data-trace-job="' + esc(jobName) + '" style="display:grid;grid-template-columns:24px minmax(180px,1.2fr) minmax(180px,1fr) 90px auto;gap:10px;align-items:start;padding:8px 14px;border-bottom:1px solid var(--border);cursor:pointer" onmouseover="this.style.background=\'var(--bg-hover)\'" onmouseout="this.style.background=\'\'">'
23209
+ + '<div style="color:' + statusColor + ';font-size:14px;line-height:18px;text-align:center" title="' + esc(status) + '">' + statusIcon + '</div>'
23210
+ + '<div style="min-width:0">'
23211
+ + '<div style="font-weight:500;color:var(--text-primary);font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(jobName) + '">' + esc(jobName) + attemptLabel + '</div>'
23212
+ + errorPreview
23213
+ + '</div>'
23214
+ + '<div style="font-size:12px;color:var(--text-secondary);line-height:18px">' + esc(startedLabel) + '</div>'
23215
+ + '<div style="font-size:12px;color:var(--text-muted);line-height:18px">' + esc(durationLabel) + '</div>'
23216
+ + '<div style="display:flex;gap:6px;align-items:center"><button class="btn-sm" onclick="event.stopPropagation();openTraceViewer(\\x27' + safeName + '\\x27)" style="font-size:11px;padding:3px 8px">Trace</button></div>'
23217
+ + '</div>';
23218
+ }
23219
+ return '<div class="history-list" style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius)">'
23220
+ + '<div style="display:grid;grid-template-columns:24px minmax(180px,1.2fr) minmax(180px,1fr) 90px auto;gap:10px;padding:8px 14px;border-bottom:1px solid var(--border);font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.04em;font-weight:500">'
23221
+ + '<div></div><div>Task</div><div>Started</div><div>Duration</div><div></div>'
23222
+ + '</div>'
23223
+ + rowsHtml
23224
+ + '</div>';
23225
+ }
23226
+
23227
+ function formatDurationMs(ms) {
23228
+ if (ms == null || isNaN(ms)) return '—';
23229
+ if (ms < 1000) return ms + 'ms';
23230
+ var s = Math.round(ms / 100) / 10;
23231
+ if (s < 60) return s + 's';
23232
+ var m = Math.floor(s / 60);
23233
+ var rem = Math.round(s % 60);
23234
+ return m + 'm ' + rem + 's';
23235
+ }
23236
+
22930
23237
  function renderRunningCard(item) {
22931
23238
  var runtime = item.runtime || {};
22932
23239
  var runtimeName = runtime.runtimeName || runtime.name || runtime.jobName || runtime.id || '';
@@ -22945,8 +23252,15 @@ function renderRunningCard(item) {
22945
23252
 
22946
23253
  async function refreshCron() {
22947
23254
  try {
22948
- var r = await apiFetch('/api/build/operations?hours=168&limit=50');
22949
- var ops = await r.json();
23255
+ // Fetch operations + cross-job recent runs in parallel for the three-zone
23256
+ // Tasks layout: Running now (top) / Your tasks (middle) / Recent history.
23257
+ var opsP = apiFetch('/api/build/operations?hours=168&limit=50').then(function(r) { return r.json().then(function(d){ return { r:r, d:d }; }); });
23258
+ var runsP = apiFetch('/api/cron/runs?limit=50').then(function(r) { return r.json().catch(function(){ return { ok:false, runs: [] }; }); }).catch(function() { return { ok:false, runs: [] }; });
23259
+ var both = await Promise.all([opsP, runsP]);
23260
+ var opsResp = both[0];
23261
+ var historyData = (both[1] && both[1].runs) || [];
23262
+ var r = opsResp.r;
23263
+ var ops = opsResp.d;
22950
23264
  if (!r.ok || !ops || ops.ok === false) throw new Error((ops && ops.error) || 'Build operations unavailable');
22951
23265
  mergeBuildUsageTasks(ops.usageTasks || []);
22952
23266
  cronJobsData = (ops.scheduledTasks || []).map(function(t) { return t.definition || {}; });
@@ -22973,16 +23287,26 @@ async function refreshCron() {
22973
23287
  var ownerFilter = getBuildOwnerFilter();
22974
23288
 
22975
23289
  var html = renderOperationsSummary(ops);
23290
+
23291
+ // ── Zone 1 — Running now (promoted to top, primary "what's live" view) ──
23292
+ if (visibleRunning.length > 0) {
23293
+ html += operationSectionHeader('Running now', 'Live background, long-running, and unleashed work. These are executions, not scheduled definitions.', 'badge-blue', visibleRunning.length + ' active', '0')
23294
+ + '<div class="task-grid">' + visibleRunning.slice(0, 10).map(renderRunningCard).join('') + '</div>';
23295
+ if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
23296
+ }
23297
+
23298
+ // ── Needs attention (only when there are issues) ──
22976
23299
  if (visibleAttention.length > 0) {
22977
- html += operationSectionHeader('Needs Attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', visibleAttention.length > 0 ? 'badge-yellow' : 'badge-gray', visibleAttention.length + ' review', '0')
23300
+ html += operationSectionHeader('Needs attention', 'Broken scheduled tasks and failed runtime work that can waste tokens or silently stop.', 'badge-yellow', visibleAttention.length + ' review', visibleRunning.length > 0 ? '28px' : '0')
22978
23301
  + '<div class="task-grid">' + visibleAttention.slice(0, 12).map(renderAttentionCard).join('') + '</div>';
22979
23302
  if (visibleAttention.length > 12) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 12 of ' + visibleAttention.length + ' items. Use the Owner filter to narrow this list.</div>';
22980
23303
  }
22981
23304
 
23305
+ // ── Zone 2 — Your tasks (the main card grid) ──
22982
23306
  var filteredTasks = applyTrickFilter(visibleTasks, _trickFilter);
22983
23307
  var filterPillsHtml = renderTrickFilterRow(visibleTasks, _trickFilter);
22984
23308
  var taskCountLabel = (_trickFilter.kind ? filteredTasks.length + '/' + visibleTasks.length : visibleTasks.length) + ' task' + (visibleTasks.length === 1 ? '' : 's');
22985
- html += operationSectionHeader('Scheduled Tasks', 'Single recurring jobs from CRON.md. These are the default scheduled automation surface.', 'badge-blue', taskCountLabel, visibleAttention.length > 0 ? '28px' : '0')
23309
+ html += operationSectionHeader('Your tasks', 'Recurring jobs Clementine runs for you. Tap any card to edit; the toggle on each card pauses or resumes it.', 'badge-blue', taskCountLabel, (visibleRunning.length > 0 || visibleAttention.length > 0) ? '28px' : '0')
22986
23310
  + filterPillsHtml
22987
23311
  + '<div class="task-grid">';
22988
23312
  if (filteredTasks.length === 0) {
@@ -22990,28 +23314,25 @@ async function refreshCron() {
22990
23314
  if (_trickFilter.kind) {
22991
23315
  emptyLabel = 'No tasks match the current filter.';
22992
23316
  } else {
22993
- emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No scheduled tasks across any agent.' : (ownerFilter ? 'No scheduled tasks for ' + ownerFilter + '.' : 'No global scheduled tasks.');
23317
+ emptyLabel = ownerFilter === BUILD_OWNER_ALL ? 'No tasks across any agent.' : (ownerFilter ? 'No tasks for ' + ownerFilter + '.' : 'No tasks yet.');
22994
23318
  }
22995
- html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Scheduled Task</div>'
23319
+ html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>'
22996
23320
  + '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">' + esc(emptyLabel) + '</div>';
22997
23321
  } else {
22998
23322
  html += filteredTasks.map(renderScheduledTaskCard).join('');
22999
- html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Scheduled Task</div>';
23323
+ html += '<div class="task-card-add" onclick="openCreateCronModal(getBuildCreateOwner())">+ New Task</div>';
23000
23324
  }
23001
23325
  html += '</div>';
23002
23326
 
23003
- html += operationSectionHeader('Scheduled Workflows', 'A scheduled workflow is one scheduled trigger that runs chained steps. It is separate from CRON.md scheduled tasks.', 'badge-purple', visibleWorkflows.length + ' workflow' + (visibleWorkflows.length === 1 ? '' : 's'), '28px');
23327
+ // ── Workflows section (kept for back-compat; only renders when present) ──
23004
23328
  if (visibleWorkflows.length > 0) {
23329
+ html += operationSectionHeader('Multi-step workflows', 'One scheduled trigger that runs chained steps. Separate from regular tasks.', 'badge-purple', visibleWorkflows.length + ' workflow' + (visibleWorkflows.length === 1 ? '' : 's'), '28px');
23005
23330
  html += '<div class="task-grid">' + visibleWorkflows.map(renderScheduledWorkflowCard).join('') + '</div>';
23006
- } else {
23007
- html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">No scheduled workflows in this scope. Build and schedule chained workflows from the Workflow Builder when a multi-step run is needed.</div>';
23008
23331
  }
23009
23332
 
23010
- if (visibleRunning.length > 0) {
23011
- html += operationSectionHeader('Running Now', 'Live background, long-running, and unleashed work. These are executions, not scheduled definitions.', 'badge-blue', visibleRunning.length + ' active', '28px')
23012
- + '<div class="task-grid">' + visibleRunning.slice(0, 10).map(renderRunningCard).join('') + '</div>';
23013
- if (visibleRunning.length > 10) html += '<div class="empty-state" style="padding:18px;color:var(--text-muted);font-size:13px">Showing 10 of ' + visibleRunning.length + ' active runs. Use the Owner filter to narrow this list.</div>';
23014
- }
23333
+ // ── Zone 3 — Recent history (last 50 runs across all jobs) ──
23334
+ html += operationSectionHeader('Recent history', 'The last 50 task runs across every job, newest first. Click any row to open the full trace.', 'badge-gray', historyData.length + ' run' + (historyData.length === 1 ? '' : 's'), '28px');
23335
+ html += renderRecentHistoryList(historyData);
23015
23336
 
23016
23337
  panel.innerHTML = html;
23017
23338
  panel.onclick = function(ev) {
@@ -24277,18 +24598,29 @@ function renderCronLegacyBanner(job) {
24277
24598
  + '<h5>⚠ Legacy mode — output may not match what you see here</h5>'
24278
24599
  + '<div>' + msg + '</div>'
24279
24600
  + '<div class="banner-actions">'
24280
- + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">Switch to Predictable Mode</button>'
24601
+ + '<button class="btn-primary btn-sm" onclick="enablePredictableFromBanner()">Migrate now</button>'
24281
24602
  + '<button class="btn-sm" onclick="switchCronTab(\\x27preview\\x27)" style="background:transparent;border:1px solid var(--border);color:var(--text-primary)">See what will run</button>'
24282
24603
  + '</div>'
24283
24604
  + '</div>';
24284
24605
  }
24285
24606
 
24286
- function enablePredictableFromBanner() {
24607
+ // One-click migration: flip predictable=true AND save immediately so the
24608
+ // user doesn't have to remember to also click Save Changes.
24609
+ async function enablePredictableFromBanner() {
24287
24610
  var predEl = document.getElementById('cron-predictable');
24288
24611
  if (!predEl) return;
24289
24612
  predEl.checked = true;
24290
24613
  onPredictableChange();
24291
- toast('Predictable Mode enabled — click Save Changes to lock it in.', 'success');
24614
+ try {
24615
+ if (typeof saveCronJob === 'function') {
24616
+ await saveCronJob();
24617
+ toast('Migrated to Predictable Mode.', 'success');
24618
+ } else {
24619
+ toast('Predictable Mode enabled — click Save Changes to lock it in.', 'success');
24620
+ }
24621
+ } catch (err) {
24622
+ toast('Toggled to Predictable Mode but save failed: ' + String(err), 'error');
24623
+ }
24292
24624
  }
24293
24625
 
24294
24626
  function openCreateCronModal(agentSlug) {
@@ -24317,11 +24649,20 @@ function openCreateCronModal(agentSlug) {
24317
24649
  document.getElementById('cron-train-btn').style.display = '';
24318
24650
  resetCronTrainingChat();
24319
24651
  resetTrickCapabilityState();
24652
+ // New tasks default to predictable=true. The visible card stays hidden;
24653
+ // the checkbox in the DOM carries the value through to saveCronJob().
24654
+ var predElNew = document.getElementById('cron-predictable');
24655
+ if (predElNew) predElNew.checked = true;
24656
+ var predCardNew = document.getElementById('cron-predictable-card');
24657
+ if (predCardNew) predCardNew.style.display = 'none';
24320
24658
  // No saved state to preview when creating — disable the Preview tab.
24321
24659
  var previewBtn = document.getElementById('cron-tab-btn-preview');
24322
24660
  if (previewBtn) previewBtn.setAttribute('disabled', 'disabled');
24323
24661
  var host = document.getElementById('cron-legacy-banner-host');
24324
24662
  if (host) host.innerHTML = '';
24663
+ // Reset the "Use a cron expression" link in case it was hidden last time.
24664
+ var schedLink = document.getElementById('sched-cron-link');
24665
+ if (schedLink) schedLink.style.display = '';
24325
24666
  switchCronTab('configure');
24326
24667
  onPredictableChange();
24327
24668
  document.getElementById('cron-modal').classList.add('show');
@@ -24370,6 +24711,10 @@ function openEditCronModal(jobName) {
24370
24711
  // banner surfaces the migration choice instead.
24371
24712
  var predEl = document.getElementById('cron-predictable');
24372
24713
  if (predEl) predEl.checked = (job.predictable === true);
24714
+ // Reveal the Predictable Mode card only for legacy jobs that need
24715
+ // attention. Predictable jobs keep it hidden — there's nothing to do.
24716
+ var predCardEdit = document.getElementById('cron-predictable-card');
24717
+ if (predCardEdit) predCardEdit.style.display = (job.predictable === true) ? 'none' : '';
24373
24718
  onPredictableChange();
24374
24719
  renderCronLegacyBanner(job);
24375
24720
  renderSkillsPickerChips();
@@ -34149,7 +34494,7 @@ try {
34149
34494
  if (evt.type === 'connected') return;
34150
34495
  if (evt.type === 'cron_complete' || evt.type === 'cron_triggered') {
34151
34496
  refreshActivity();
34152
- if (currentPage === 'automations') refreshCron();
34497
+ if (currentPage === 'build') refreshCron();
34153
34498
  refreshTeamNav();
34154
34499
  }
34155
34500
  if (evt.type === 'agent_created' || evt.type === 'agent_updated' || evt.type === 'agent_deleted' || evt.type === 'agent_status') {
@@ -34225,6 +34570,16 @@ try {
34225
34570
  // Poll interval: 30s when SSE connected, 10s otherwise (re-evaluates each tick)
34226
34571
  setInterval(function() { if (!sseConnected) refreshAll(); }, 10000);
34227
34572
  setInterval(function() { if (sseConnected) refreshAll(); }, 30000);
34573
+
34574
+ // Tasks page: keep "Running now" fresh while the user is looking at it.
34575
+ // 12s feels alive without thrashing the API. Skip if SSE just delivered an
34576
+ // event in the last few seconds (refreshCron will already have fired).
34577
+ setInterval(function() {
34578
+ if (currentPage !== 'build') return;
34579
+ if (typeof refreshCron === 'function') {
34580
+ try { refreshCron(); } catch (e) { /* non-fatal */ }
34581
+ }
34582
+ }, 12000);
34228
34583
  </script>
34229
34584
  </body>
34230
34585
  </html>`;
@@ -33,7 +33,11 @@ const CLI_ENTRY = path.join(PACKAGE_ROOT, 'dist', 'cli', 'index.js');
33
33
  const ICONS_DIR = path.join(PACKAGE_ROOT, 'build', 'icons');
34
34
  const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
35
35
  const DEFAULT_PORT = Number(process.env.CLEMENTINE_DESKTOP_PORT || process.env.DASHBOARD_PORT || 3030);
36
- const NODE_BINARY = process.env.CLEMENTINE_NODE_PATH || 'node';
36
+ // Use Electron's bundled Node runtime (via ELECTRON_RUN_AS_NODE) so the
37
+ // desktop app does not depend on a `node` binary being on the host PATH.
38
+ // macOS GUI launches (Finder/Spotlight/Dock) get a barebones PATH that
39
+ // excludes Homebrew and NVM, which previously caused `spawn node ENOENT`.
40
+ const NODE_BINARY = process.env.CLEMENTINE_NODE_PATH || process.execPath;
37
41
  let mainWindow = null;
38
42
  let tray = null;
39
43
  let dashboardProcess = null;
@@ -138,6 +142,9 @@ function startDashboardProcess(port) {
138
142
  CLEMENTINE_HOME: BASE_DIR,
139
143
  CLEMENTINE_NO_OPEN: '1',
140
144
  __CLEM_DASHBOARD_CHILD: '1',
145
+ // Run Electron's binary as plain Node when no host node is configured.
146
+ // No-op when the user pinned a real node via CLEMENTINE_NODE_PATH.
147
+ ELECTRON_RUN_AS_NODE: process.env.CLEMENTINE_NODE_PATH ? '0' : '1',
141
148
  },
142
149
  stdio: ['ignore', 'pipe', 'pipe'],
143
150
  });
@@ -70,6 +70,14 @@ export declare class CronRunLog {
70
70
  append(entry: CronRunEntry): void;
71
71
  readRecent(jobName: string, count?: number): CronRunEntry[];
72
72
  consecutiveErrors(jobName: string): number;
73
+ /**
74
+ * Read the most recent run entries across ALL jobs, sorted newest-first.
75
+ * Each .jsonl file is already pruned to MAX_LINES, so this is bounded by
76
+ * (number of jobs × tail of recent entries). For dashboard "Recent History"
77
+ * this is fast enough — we tail the last `tailPerFile` lines from each file
78
+ * before merging, then trim the merged set to `count`.
79
+ */
80
+ readAllRecent(count?: number, tailPerFile?: number): CronRunEntry[];
73
81
  private maybePrune;
74
82
  }
75
83
  export declare class CronScheduler {
@@ -395,6 +395,49 @@ export class CronRunLog {
395
395
  }
396
396
  return count;
397
397
  }
398
+ /**
399
+ * Read the most recent run entries across ALL jobs, sorted newest-first.
400
+ * Each .jsonl file is already pruned to MAX_LINES, so this is bounded by
401
+ * (number of jobs × tail of recent entries). For dashboard "Recent History"
402
+ * this is fast enough — we tail the last `tailPerFile` lines from each file
403
+ * before merging, then trim the merged set to `count`.
404
+ */
405
+ readAllRecent(count = 50, tailPerFile = 30) {
406
+ if (!existsSync(this.dir))
407
+ return [];
408
+ let files;
409
+ try {
410
+ files = readdirSync(this.dir).filter((n) => n.endsWith('.jsonl'));
411
+ }
412
+ catch {
413
+ return [];
414
+ }
415
+ const merged = [];
416
+ for (const f of files) {
417
+ const filePath = path.join(this.dir, f);
418
+ try {
419
+ const lines = readFileSync(filePath, 'utf-8').trim().split('\n').filter(Boolean);
420
+ const tail = lines.slice(-tailPerFile);
421
+ for (const l of tail) {
422
+ try {
423
+ merged.push(JSON.parse(l));
424
+ }
425
+ catch {
426
+ /* skip malformed line */
427
+ }
428
+ }
429
+ }
430
+ catch {
431
+ /* skip unreadable file */
432
+ }
433
+ }
434
+ merged.sort((a, b) => {
435
+ const at = a.startedAt ? new Date(a.startedAt).getTime() : 0;
436
+ const bt = b.startedAt ? new Date(b.startedAt).getTime() : 0;
437
+ return bt - at;
438
+ });
439
+ return merged.slice(0, count);
440
+ }
398
441
  maybePrune(filePath) {
399
442
  try {
400
443
  const { size } = statSync(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.70",
3
+ "version": "1.18.72",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",