clementine-agent 1.6.0 → 1.6.1

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.
@@ -20,5 +20,16 @@
20
20
  export declare function cmdBrowserStatus(): Promise<void>;
21
21
  export declare function cmdBrowserInstall(): Promise<void>;
22
22
  export declare function cmdBrowserEnable(): Promise<void>;
23
+ /**
24
+ * Auto-prompt during `clementine update`. Stays silent unless there's
25
+ * something actionable — mirrors the keychain wizard's behavior.
26
+ *
27
+ * Skips prompting when:
28
+ * - Not in an interactive TTY
29
+ * - The MCP wrapper isn't shipped with this version
30
+ * - Browser harness is already installed AND enabled
31
+ * - User previously dismissed the prompt
32
+ */
33
+ export declare function maybePromptBrowserHarness(): Promise<void>;
23
34
  export declare function cmdBrowserDisable(): Promise<void>;
24
35
  //# sourceMappingURL=browser.d.ts.map
@@ -22,6 +22,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
+ import { confirm } from '@inquirer/prompts';
25
26
  const BOLD = '\x1b[1m';
26
27
  const DIM = '\x1b[0;90m';
27
28
  const GREEN = '\x1b[0;32m';
@@ -40,6 +41,7 @@ const VENV_PYTHON = path.join(VENV_DIR, 'bin', 'python3');
40
41
  const MCP_SERVERS_FILE = path.join(BASE_DIR, 'mcp-servers.json');
41
42
  const HARNESS_REPO = 'https://github.com/browser-use/browser-harness.git';
42
43
  const SERVER_NAME = 'browser-harness';
44
+ const DISMISSED_MARKER = path.join(BASE_DIR, '.browser-harness-dismissed');
43
45
  function commandExists(cmd) {
44
46
  const result = spawnSync('which', [cmd], { stdio: 'pipe' });
45
47
  return result.status === 0;
@@ -102,7 +104,11 @@ export async function cmdBrowserStatus() {
102
104
  console.log();
103
105
  }
104
106
  }
105
- export async function cmdBrowserInstall() {
107
+ /**
108
+ * Core install logic. Returns true on success, false on any failure.
109
+ * Prints progress + errors to stdout/stderr but never calls process.exit.
110
+ */
111
+ async function runInstall() {
106
112
  console.log();
107
113
  console.log(` ${BOLD}Installing browser-harness${RESET} ${DIM}(beta)${RESET}`);
108
114
  console.log();
@@ -110,13 +116,13 @@ export async function cmdBrowserInstall() {
110
116
  console.error(` ${RED}python3 not found.${RESET} Install Python 3.10+ first:`);
111
117
  console.error(` ${CYAN}brew install python@3.12${RESET}`);
112
118
  console.error();
113
- process.exit(1);
119
+ return false;
114
120
  }
115
121
  if (!existsSync(MCP_SCRIPT)) {
116
122
  console.error(` ${RED}MCP wrapper not found at:${RESET} ${MCP_SCRIPT}`);
117
123
  console.error(` ${DIM}This means the package was installed without vendor/ files. Reinstall:${RESET}`);
118
124
  console.error(` ${CYAN}npm install -g clementine-agent@latest${RESET}`);
119
- process.exit(1);
125
+ return false;
120
126
  }
121
127
  if (!existsSync(BASE_DIR))
122
128
  mkdirSync(BASE_DIR, { recursive: true });
@@ -124,7 +130,7 @@ export async function cmdBrowserInstall() {
124
130
  if (!existsSync(HARNESS_HOME)) {
125
131
  if (!commandExists('git')) {
126
132
  console.error(` ${RED}git not found.${RESET} Install git, then re-run.`);
127
- process.exit(1);
133
+ return false;
128
134
  }
129
135
  console.log(` ${DIM}→ cloning ${HARNESS_REPO}${RESET}`);
130
136
  try {
@@ -132,7 +138,7 @@ export async function cmdBrowserInstall() {
132
138
  }
133
139
  catch {
134
140
  console.error(` ${RED}Clone failed.${RESET} Check network / git access and try again.`);
135
- process.exit(1);
141
+ return false;
136
142
  }
137
143
  }
138
144
  else {
@@ -146,7 +152,7 @@ export async function cmdBrowserInstall() {
146
152
  }
147
153
  catch {
148
154
  console.error(` ${RED}venv creation failed.${RESET}`);
149
- process.exit(1);
155
+ return false;
150
156
  }
151
157
  }
152
158
  else {
@@ -165,25 +171,21 @@ export async function cmdBrowserInstall() {
165
171
  }
166
172
  catch {
167
173
  console.error(` ${RED}pip install failed.${RESET} Inspect output above and re-run when fixed.`);
168
- process.exit(1);
174
+ return false;
169
175
  }
170
176
  console.log();
171
177
  console.log(` ${GREEN}✓${RESET} Install complete.`);
172
- console.log();
173
- console.log(` ${BOLD}Next steps:${RESET}`);
174
- console.log(` 1. Enable Chrome remote debugging — open Chrome with:`);
175
- console.log(` ${CYAN}/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\${RESET}`);
176
- console.log(` ${CYAN}--remote-debugging-port=9222${RESET}`);
177
- console.log(` 2. Enable the MCP server: ${BOLD}clementine browser enable${RESET}`);
178
- console.log(` 3. Restart the daemon: ${BOLD}clementine restart${RESET}`);
179
- console.log();
178
+ return true;
180
179
  }
181
- export async function cmdBrowserEnable() {
180
+ /**
181
+ * Core enable logic. Returns true on success, false on any failure.
182
+ */
183
+ function runEnable() {
182
184
  if (!existsSync(VENV_PYTHON) || !existsSync(MCP_SCRIPT)) {
183
185
  console.error();
184
186
  console.error(` ${RED}Not installed yet.${RESET} Run ${BOLD}clementine browser install${RESET} first.`);
185
187
  console.error();
186
- process.exit(1);
188
+ return false;
187
189
  }
188
190
  const servers = loadMcpServers();
189
191
  servers[SERVER_NAME] = {
@@ -201,9 +203,93 @@ export async function cmdBrowserEnable() {
201
203
  saveMcpServers(servers);
202
204
  console.log();
203
205
  console.log(` ${GREEN}✓${RESET} Registered ${BOLD}${SERVER_NAME}${RESET} in mcp-servers.json`);
206
+ return true;
207
+ }
208
+ export async function cmdBrowserInstall() {
209
+ const ok = await runInstall();
210
+ if (!ok)
211
+ process.exit(1);
212
+ console.log();
213
+ console.log(` ${BOLD}Next steps:${RESET}`);
214
+ console.log(` 1. Enable Chrome remote debugging — open Chrome with:`);
215
+ console.log(` ${CYAN}/Applications/Google\\ Chrome.app/Contents/MacOS/Google\\ Chrome \\${RESET}`);
216
+ console.log(` ${CYAN}--remote-debugging-port=9222${RESET}`);
217
+ console.log(` 2. Enable the MCP server: ${BOLD}clementine browser enable${RESET}`);
218
+ console.log(` 3. Restart the daemon: ${BOLD}clementine restart${RESET}`);
219
+ console.log();
220
+ }
221
+ export async function cmdBrowserEnable() {
222
+ const ok = runEnable();
223
+ if (!ok)
224
+ process.exit(1);
204
225
  console.log(` ${DIM}Restart the daemon to pick up the change: clementine restart${RESET}`);
205
226
  console.log();
206
227
  }
228
+ /**
229
+ * Auto-prompt during `clementine update`. Stays silent unless there's
230
+ * something actionable — mirrors the keychain wizard's behavior.
231
+ *
232
+ * Skips prompting when:
233
+ * - Not in an interactive TTY
234
+ * - The MCP wrapper isn't shipped with this version
235
+ * - Browser harness is already installed AND enabled
236
+ * - User previously dismissed the prompt
237
+ */
238
+ export async function maybePromptBrowserHarness() {
239
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
240
+ return;
241
+ if (!existsSync(MCP_SCRIPT))
242
+ return;
243
+ const servers = loadMcpServers();
244
+ const enabled = Object.prototype.hasOwnProperty.call(servers, SERVER_NAME);
245
+ const installed = existsSync(VENV_PYTHON);
246
+ if (enabled && installed)
247
+ return;
248
+ if (existsSync(DISMISSED_MARKER))
249
+ return;
250
+ console.log();
251
+ console.log(` ${BOLD}Browser Harness available${RESET} ${DIM}(beta, opt-in)${RESET}`);
252
+ console.log(` ${DIM}Lets Clementine drive your real Chrome — fill forms, post on LinkedIn,${RESET}`);
253
+ console.log(` ${DIM}book appointments — using your live browser session.${RESET}`);
254
+ console.log();
255
+ let answer;
256
+ try {
257
+ answer = await confirm({
258
+ message: 'Install Browser Harness now?',
259
+ default: false,
260
+ });
261
+ }
262
+ catch {
263
+ // User Ctrl+C'd or terminal closed — treat as decline, don't dismiss permanently
264
+ return;
265
+ }
266
+ if (!answer) {
267
+ try {
268
+ writeFileSync(DISMISSED_MARKER, new Date().toISOString() + '\n');
269
+ }
270
+ catch { /* non-fatal */ }
271
+ console.log(` ${DIM}Skipped. To install later: clementine browser install${RESET}`);
272
+ console.log();
273
+ return;
274
+ }
275
+ // User said yes — run install + enable inline
276
+ const installOk = await runInstall();
277
+ if (!installOk) {
278
+ console.error(` ${YELLOW}Install failed.${RESET} ${DIM}You can retry with: clementine browser install${RESET}`);
279
+ console.log();
280
+ return;
281
+ }
282
+ const enableOk = runEnable();
283
+ if (!enableOk) {
284
+ console.error(` ${YELLOW}Enable failed.${RESET} ${DIM}Retry with: clementine browser enable${RESET}`);
285
+ console.log();
286
+ return;
287
+ }
288
+ console.log();
289
+ console.log(` ${GREEN}✓${RESET} Browser Harness installed and enabled.`);
290
+ console.log(` ${DIM}Open Chrome with --remote-debugging-port=9222 to connect.${RESET}`);
291
+ console.log();
292
+ }
207
293
  export async function cmdBrowserDisable() {
208
294
  const servers = loadMcpServers();
209
295
  if (!Object.prototype.hasOwnProperty.call(servers, SERVER_NAME)) {
@@ -3174,8 +3174,9 @@ export async function cmdDashboard(opts) {
3174
3174
  maxTurns: 15,
3175
3175
  }],
3176
3176
  sourceFile: '',
3177
+ agentSlug: body.agentSlug || undefined,
3177
3178
  };
3178
- const id = workflowId(slug);
3179
+ const id = workflowId(slug, body.agentSlug || undefined);
3179
3180
  const result = saveWorkflow(id, wf);
3180
3181
  if (!result.ok) {
3181
3182
  res.status(400).json({ error: result.error });
@@ -5620,6 +5621,55 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5620
5621
  // ── Builder chat endpoint ──────────────────────────────────────────
5621
5622
  // Track which builder sessions have received the full system prefix
5622
5623
  const builderSessionInited = new Set();
5624
+ async function buildBuilderCapabilityContext(agentSlug) {
5625
+ const lines = [];
5626
+ try {
5627
+ const { discoverMcpServers, loadToolInventory } = await import('../agent/mcp-bridge.js');
5628
+ const servers = discoverMcpServers();
5629
+ const inv = loadToolInventory();
5630
+ const serverLines = servers.map((s) => {
5631
+ const tools = (inv?.tools ?? [])
5632
+ .filter((t) => t.startsWith(`mcp__${s.name}__`))
5633
+ .map((t) => t.split('__')[2])
5634
+ .filter(Boolean)
5635
+ .slice(0, 40);
5636
+ return `- ${s.name} [${s.enabled ? 'on' : 'off'}]: ${tools.length ? tools.join(', ') : '(no tools cached yet)'}`;
5637
+ });
5638
+ lines.push('[AVAILABLE MCP SERVERS AND TOOLS]');
5639
+ lines.push(serverLines.length ? serverLines.join('\n') : '- none configured');
5640
+ }
5641
+ catch {
5642
+ lines.push('[AVAILABLE MCP SERVERS AND TOOLS]\n- unavailable');
5643
+ }
5644
+ const readSkills = (dir, scope) => {
5645
+ if (!existsSync(dir))
5646
+ return [];
5647
+ return readdirSync(dir)
5648
+ .filter(f => f.endsWith('.md'))
5649
+ .slice(0, 80)
5650
+ .map(f => {
5651
+ try {
5652
+ const parsed = matter(readFileSync(path.join(dir, f), 'utf-8'));
5653
+ const name = f.replace(/\.md$/, '');
5654
+ const title = String(parsed.data.title ?? name);
5655
+ const triggers = Array.isArray(parsed.data.triggers) ? parsed.data.triggers.join(', ') : '';
5656
+ const tools = Array.isArray(parsed.data.toolsUsed) ? parsed.data.toolsUsed.join(', ') : '';
5657
+ return `- ${title} (${scope}${name !== title ? `/${name}` : ''})${triggers ? ` triggers: ${triggers}` : ''}${tools ? ` tools: ${tools}` : ''}`;
5658
+ }
5659
+ catch {
5660
+ return null;
5661
+ }
5662
+ })
5663
+ .filter((x) => Boolean(x));
5664
+ };
5665
+ const globalSkills = readSkills(path.join(VAULT_DIR, '00-System', 'skills'), 'global');
5666
+ const agentSkills = agentSlug
5667
+ ? readSkills(path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'skills'), `agent:${agentSlug}`)
5668
+ : [];
5669
+ lines.push('[AVAILABLE SKILLS]');
5670
+ lines.push([...agentSkills, ...globalSkills].length ? [...agentSkills, ...globalSkills].join('\n') : '- none saved yet');
5671
+ return `\n${lines.join('\n\n')}\n`;
5672
+ }
5623
5673
  app.post('/api/builder/chat', async (req, res) => {
5624
5674
  const { message, artifactType, agentSlug, currentArtifact, attachments, linkedTools } = req.body;
5625
5675
  if (!message || typeof message !== 'string') {
@@ -5686,7 +5736,8 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5686
5736
  `Workflows are defined as markdown files with YAML frontmatter. Each step has an id, prompt, optional agent, and optional dependsOn array.\n` +
5687
5737
  `When the user says "save" or approves, output the final artifact block.]\n\n`
5688
5738
  : `[BUILDER MODE: You are helping configure an artifact. Output structured JSON blocks as you build.]\n\n`;
5689
- enrichedMessage = builderPrefix + fileContext + toolContext + artifactContext + message;
5739
+ const capabilityContext = await buildBuilderCapabilityContext(agentSlug);
5740
+ enrichedMessage = builderPrefix + capabilityContext + fileContext + toolContext + artifactContext + message;
5690
5741
  builderSessionInited.add(sessionKey);
5691
5742
  }
5692
5743
  else {
@@ -5808,35 +5859,26 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5808
5859
  }
5809
5860
  else if (artifactType === 'cron') {
5810
5861
  // Scope cron to agent if selected
5811
- const jobName = agentSlug && !artifact.name.startsWith(agentSlug + ':')
5812
- ? `${agentSlug}:${artifact.name}`
5813
- : artifact.name;
5814
- const { cronFile } = agentSlug
5815
- ? (() => {
5816
- const agentCronFile = path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'CRON.md');
5817
- return { cronFile: existsSync(agentCronFile) ? agentCronFile : path.join(VAULT_DIR, '00-System', 'CRON.md') };
5818
- })()
5819
- : { cronFile: path.join(VAULT_DIR, '00-System', 'CRON.md') };
5820
- if (!existsSync(cronFile)) {
5821
- res.status(500).json({ error: 'CRON.md not found' });
5822
- return;
5823
- }
5824
- const matterMod = require('gray-matter');
5825
- const parsed = matterMod(readFileSync(cronFile, 'utf-8'));
5826
- const jobs = parsed.data.jobs || [];
5862
+ const rawName = String(artifact.name || 'new-cron').trim();
5863
+ const jobName = agentSlug && rawName.startsWith(agentSlug + ':')
5864
+ ? rawName.slice(agentSlug.length + 1)
5865
+ : rawName;
5866
+ const cronFile = agentSlug
5867
+ ? path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'CRON.md')
5868
+ : path.join(VAULT_DIR, '00-System', 'CRON.md');
5869
+ const { parsed, jobs } = readCronFileAt(cronFile);
5827
5870
  jobs.push({
5828
5871
  name: jobName,
5829
5872
  schedule: artifact.schedule,
5830
5873
  prompt: artifact.prompt,
5831
5874
  tier: artifact.tier || 1,
5832
5875
  enabled: artifact.enabled !== false,
5833
- ...(agentSlug ? { agent: agentSlug } : {}),
5834
5876
  ...(artifact.mode === 'unleashed' ? { mode: 'unleashed', max_hours: artifact.max_hours || 1 } : {}),
5835
5877
  ...(artifact.work_dir ? { work_dir: artifact.work_dir } : {}),
5836
5878
  });
5837
- parsed.data.jobs = jobs;
5838
- writeFileSync(cronFile, matterMod.stringify(parsed.content, parsed.data));
5839
- res.json({ ok: true, name: jobName, message: `Cron job "${jobName}" saved` });
5879
+ writeCronFileAt(cronFile, parsed, jobs);
5880
+ const displayName = agentSlug ? `${agentSlug}:${jobName}` : jobName;
5881
+ res.json({ ok: true, name: displayName, message: `Cron job "${displayName}" saved` });
5840
5882
  }
5841
5883
  else if (artifactType === 'agent') {
5842
5884
  // Create agent via the same mechanism as the manual form
@@ -5865,15 +5907,19 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5865
5907
  }
5866
5908
  else if (artifactType === 'workflow') {
5867
5909
  // Save workflow as markdown file
5868
- const wfDir = path.join(VAULT_DIR, '00-System', 'workflows');
5910
+ const wfDir = agentSlug
5911
+ ? path.join(VAULT_DIR, '00-System', 'agents', agentSlug, 'workflows')
5912
+ : path.join(VAULT_DIR, '00-System', 'workflows');
5869
5913
  if (!existsSync(wfDir))
5870
5914
  mkdirSync(wfDir, { recursive: true });
5871
5915
  const wfName = (artifact.name || 'new-workflow').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
5872
5916
  const matterMod = require('gray-matter');
5873
5917
  const meta = {
5918
+ type: 'workflow',
5874
5919
  name: artifact.name || wfName,
5875
5920
  description: artifact.description || '',
5876
5921
  enabled: true,
5922
+ ...(agentSlug ? { agentSlug } : {}),
5877
5923
  ...(artifact.schedule ? { trigger: { schedule: artifact.schedule } } : {}),
5878
5924
  };
5879
5925
  const content = matterMod.stringify(`\n# ${artifact.name || wfName}\n\n${artifact.description || ''}\n\n## Steps\n\n${artifact.steps || ''}\n`, meta);
@@ -12315,7 +12361,13 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
12315
12361
  <option value="agent">agent</option>
12316
12362
  <option value="workflow">workflow</option>
12317
12363
  </select>
12318
- <span id="builder-agent-label" style="padding:0;font-size:13px;color:var(--text-secondary);font-weight:500"></span>
12364
+ <label for="builder-agent-scope" style="font-size:11px;color:var(--text-muted);font-weight:600">Owner</label>
12365
+ <select id="builder-agent-scope" onchange="setBuilderAgentScope(this.value)" style="padding:4px 8px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:12px;max-width:220px">
12366
+ <option value="__all">All owners</option>
12367
+ <option value="__global">Clementine (global)</option>
12368
+ </select>
12369
+ <span id="builder-capability-summary" style="font-size:11px;color:var(--text-muted)"></span>
12370
+ <span id="builder-agent-label" style="display:none;padding:0;font-size:13px;color:var(--text-secondary);font-weight:500"></span>
12319
12371
  <input type="hidden" id="builder-agent" value="">
12320
12372
  <span style="flex:1"></span>
12321
12373
  <button class="btn-sm btn-primary" onclick="newFromBuildHeader()" title="Create a new artifact for this tab" style="padding:4px 14px;border-radius:6px;cursor:pointer;font-size:12px">New</button>
@@ -15152,9 +15204,24 @@ async function newFromBuildHeader() {
15152
15204
  var name = prompt('Name your new ' + noun + ':');
15153
15205
  if (!name || !name.trim()) return;
15154
15206
  try {
15155
- var body = { name: name.trim() };
15156
- if (activeTab === 'crons') body.schedule = '0 9 * * *'; // sensible default; user edits in canvas
15157
- var r = await apiJson('POST', '/api/builder/workflows', body);
15207
+ var agentSlug = getBuilderSelectedAgentSlug();
15208
+ var r;
15209
+ if (activeTab === 'crons') {
15210
+ r = await apiJson('POST', '/api/cron', {
15211
+ name: name.trim(),
15212
+ schedule: '0 9 * * *',
15213
+ prompt: 'Describe what this scheduled automation should do.',
15214
+ tier: 1,
15215
+ enabled: true,
15216
+ agent: agentSlug || undefined,
15217
+ });
15218
+ if (r && !r.error) r.id = builderScopedId('cron', name.trim(), agentSlug);
15219
+ } else {
15220
+ r = await apiJson('POST', '/api/builder/workflows', {
15221
+ name: name.trim(),
15222
+ agentSlug: agentSlug || undefined,
15223
+ });
15224
+ }
15158
15225
  if (r && r.error) { toast('Create failed: ' + r.error, 'error'); return; }
15159
15226
  if (r && r.id) {
15160
15227
  await refreshBuilderCanvasPicker(activeTab === 'crons' ? 'cron' : 'workflow');
@@ -15209,12 +15276,26 @@ async function forkBuildTemplate(templateId) {
15209
15276
  var name = prompt('Name for the new workflow:', tpl.name);
15210
15277
  if (!name) return;
15211
15278
  try {
15212
- var r = await apiJson('POST', '/api/builder/workflows', {
15213
- name: name,
15214
- description: tpl.description,
15215
- schedule: tpl.schedule,
15216
- initialPrompt: tpl.initialPrompt,
15217
- });
15279
+ var agentSlug = getBuilderSelectedAgentSlug();
15280
+ var r;
15281
+ if (tpl.schedule) {
15282
+ r = await apiJson('POST', '/api/cron', {
15283
+ name: name,
15284
+ schedule: tpl.schedule,
15285
+ prompt: tpl.initialPrompt,
15286
+ tier: 1,
15287
+ enabled: true,
15288
+ agent: agentSlug || undefined,
15289
+ });
15290
+ if (r && !r.error) r.id = builderScopedId('cron', name, agentSlug);
15291
+ } else {
15292
+ r = await apiJson('POST', '/api/builder/workflows', {
15293
+ name: name,
15294
+ description: tpl.description,
15295
+ initialPrompt: tpl.initialPrompt,
15296
+ agentSlug: agentSlug || undefined,
15297
+ });
15298
+ }
15218
15299
  if (r && r.error) { toast('Create failed: ' + r.error, 'error'); return; }
15219
15300
  if (r && r.id) {
15220
15301
  switchBuildTab(tpl.schedule ? 'crons' : 'workflows');
@@ -19351,6 +19432,26 @@ var _builderCanvasOpenId = null;
19351
19432
  var _builderCanvasLastWorkflow = null;
19352
19433
  var _builderDrawflowLoading = null;
19353
19434
 
19435
+ function getBuilderOwnerScope() {
19436
+ var sel = document.getElementById('builder-agent-scope');
19437
+ return (sel && sel.value) || '__all';
19438
+ }
19439
+
19440
+ function getBuilderSelectedAgentSlug() {
19441
+ var scope = getBuilderOwnerScope();
19442
+ return (scope && scope !== '__all' && scope !== '__global') ? scope : '';
19443
+ }
19444
+
19445
+ function builderScopedId(type, name, agentSlug) {
19446
+ var prefix = type === 'cron' ? 'cron:' : 'workflow:';
19447
+ return agentSlug ? (prefix + 'agent:' + agentSlug + ':' + name) : (prefix + 'global:' + name);
19448
+ }
19449
+
19450
+ function builderOwnerLabel(item) {
19451
+ if (!item || !item.agentSlug) return 'Clementine';
19452
+ return item.agentSlug;
19453
+ }
19454
+
19354
19455
  function _ensureDrawflowLoaded() {
19355
19456
  if (window.Drawflow) return Promise.resolve();
19356
19457
  if (_builderDrawflowLoading) return _builderDrawflowLoading;
@@ -19370,11 +19471,17 @@ async function refreshBuilderCanvasPicker(type) {
19370
19471
  try {
19371
19472
  var r = await apiFetch('/api/builder/workflows');
19372
19473
  var d = await r.json();
19373
- var items = (d.workflows || []).filter(function(w) { return w.origin === type; });
19474
+ var ownerScope = getBuilderOwnerScope();
19475
+ var items = (d.workflows || []).filter(function(w) {
19476
+ if (w.origin !== type) return false;
19477
+ if (ownerScope === '__all') return true;
19478
+ if (ownerScope === '__global') return !w.agentSlug;
19479
+ return w.agentSlug === ownerScope;
19480
+ });
19374
19481
  var opts = '<option value="">' + (items.length ? '— pick a ' + type + ' —' : '(none yet)') + '</option>';
19375
19482
  for (var i = 0; i < items.length; i++) {
19376
19483
  var w = items[i];
19377
- var lbl = w.name + (w.schedule ? ' · ' + w.schedule : '') + (w.enabled ? '' : ' · off');
19484
+ var lbl = builderOwnerLabel(w) + ' · ' + w.name + (w.schedule ? ' · ' + w.schedule : '') + (w.enabled ? '' : ' · off');
19378
19485
  opts += '<option value="' + esc(w.id) + '">' + esc(lbl) + '</option>';
19379
19486
  }
19380
19487
  picker.innerHTML = opts;
@@ -20031,7 +20138,8 @@ async function refreshBuilderSkills() {
20031
20138
  var countEl = document.getElementById('builder-skills-count');
20032
20139
  if (!container) return;
20033
20140
  try {
20034
- var r = await apiFetch('/api/skills');
20141
+ var agentSlug = getBuilderSelectedAgentSlug();
20142
+ var r = await apiFetch(agentSlug ? ('/api/agents/' + encodeURIComponent(agentSlug) + '/skills') : '/api/skills');
20035
20143
  var d = await r.json();
20036
20144
  var skills = d.skills || [];
20037
20145
  if (countEl) countEl.textContent = skills.length + ' skill' + (skills.length !== 1 ? 's' : '');
@@ -20041,12 +20149,13 @@ async function refreshBuilderSkills() {
20041
20149
  }
20042
20150
  var html = '';
20043
20151
  for (var s of skills) {
20152
+ var scopeTag = s.scope === 'agent' ? '<span style="font-size:9px;background:var(--blue);color:white;padding:1px 5px;border-radius:3px">agent</span> ' : '';
20044
20153
  var sourceTag = s.source === 'builder' ? '<span style="font-size:9px;background:var(--accent);color:white;padding:1px 5px;border-radius:3px">built</span>'
20045
20154
  : s.source === 'manual' ? '<span style="font-size:9px;background:var(--blue);color:white;padding:1px 5px;border-radius:3px">taught</span>'
20046
20155
  : '<span style="font-size:9px;background:var(--bg-tertiary);color:var(--text-muted);padding:1px 5px;border-radius:3px">' + esc(s.source || 'auto') + '</span>';
20047
20156
  html += '<div style="display:flex;align-items:center;gap:6px;padding:6px 4px;border-bottom:1px solid var(--border);font-size:12px">'
20048
20157
  + '<div style="flex:1;min-width:0">'
20049
- + '<div style="font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(s.title) + ' ' + sourceTag + '</div>'
20158
+ + '<div style="font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(s.title) + ' ' + scopeTag + sourceTag + '</div>'
20050
20159
  + '<div style="font-size:10px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' + esc(s.description || '') + '</div>'
20051
20160
  + '</div>'
20052
20161
  + '<button onclick="editSkillInBuilder(\\x27' + esc(s.name) + '\\x27)" style="background:none;border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:10px;color:var(--accent);cursor:pointer;white-space:nowrap">Edit</button>'
@@ -20094,6 +20203,17 @@ async function editSkillInBuilder(name, agentSlug) {
20094
20203
  var agentLabel = document.getElementById('builder-agent-label');
20095
20204
  if (agentHidden) agentHidden.value = agentSlug || '';
20096
20205
  if (agentLabel) agentLabel.textContent = agentSlug ? 'Agent: ' + agentSlug : '';
20206
+ var ownerSel = document.getElementById('builder-agent-scope');
20207
+ if (ownerSel) {
20208
+ if (agentSlug && !Array.prototype.some.call(ownerSel.options, function(o) { return o.value === agentSlug; })) {
20209
+ var opt = document.createElement('option');
20210
+ opt.value = agentSlug;
20211
+ opt.textContent = agentSlug;
20212
+ ownerSel.appendChild(opt);
20213
+ }
20214
+ ownerSel.value = agentSlug || '__global';
20215
+ setBuilderAgentScope(ownerSel.value);
20216
+ }
20097
20217
 
20098
20218
  // Reset session on server so next message gets full prefix
20099
20219
  await apiJson('POST', '/api/builder/reset', { artifactType: 'skill', agentSlug: agentSlug || undefined }).catch(function(){});
@@ -20417,23 +20537,45 @@ async function saveBuilderArtifact() {
20417
20537
  } catch(e) { toast('Error: ' + e, 'error'); }
20418
20538
  }
20419
20539
 
20420
- // Populate agent dropdown when builder page loads
20421
- function refreshBuilderAgents(preselect) {
20540
+ function setBuilderAgentScope(value) {
20422
20541
  var hidden = document.getElementById('builder-agent');
20423
20542
  var label = document.getElementById('builder-agent-label');
20424
- if (!hidden || !label) return;
20425
- hidden.value = preselect || '';
20426
- if (!preselect) {
20427
- label.textContent = 'Clementine (global)';
20428
- return;
20543
+ var agentSlug = (value && value !== '__all' && value !== '__global') ? value : '';
20544
+ if (hidden) hidden.value = agentSlug;
20545
+ if (label) {
20546
+ label.textContent = value === '__all'
20547
+ ? 'All owners'
20548
+ : value === '__global'
20549
+ ? 'Clementine (global)'
20550
+ : agentSlug;
20429
20551
  }
20430
- // Look up agent name from sidebar
20431
- var teamItem = document.querySelector('.team-nav-item[data-slug="' + preselect + '"] span');
20432
- if (teamItem) {
20433
- label.textContent = teamItem.textContent;
20434
- } else {
20435
- label.textContent = preselect;
20552
+ var type = (document.getElementById('builder-type') || {}).value;
20553
+ if (type === 'cron' || type === 'workflow') refreshBuilderCanvasPicker(type);
20554
+ if (type === 'skill') refreshBuilderSkills();
20555
+ apiJson('POST', '/api/builder/reset', { artifactType: type, agentSlug: agentSlug || undefined }).catch(function(){});
20556
+ }
20557
+
20558
+ // Populate owner dropdown when builder page loads
20559
+ async function refreshBuilderAgents(preselect) {
20560
+ var hidden = document.getElementById('builder-agent');
20561
+ var label = document.getElementById('builder-agent-label');
20562
+ var select = document.getElementById('builder-agent-scope');
20563
+ if (!hidden || !label || !select) return;
20564
+ var current = preselect ? preselect : (select.value || '__all');
20565
+ try {
20566
+ var r = await apiFetch('/api/agents');
20567
+ var agents = await r.json();
20568
+ var opts = '<option value="__all">All owners</option><option value="__global">Clementine (global)</option>';
20569
+ (agents || []).forEach(function(a) {
20570
+ opts += '<option value="' + esc(a.slug) + '">' + esc(a.name || a.slug) + '</option>';
20571
+ });
20572
+ select.innerHTML = opts;
20573
+ } catch(e) {
20574
+ select.innerHTML = '<option value="__all">All owners</option><option value="__global">Clementine (global)</option>';
20436
20575
  }
20576
+ var hasCurrent = Array.prototype.some.call(select.options, function(o) { return o.value === current; });
20577
+ select.value = hasCurrent ? current : '__all';
20578
+ setBuilderAgentScope(select.value);
20437
20579
  }
20438
20580
 
20439
20581
  // ── Builder Linked Tools ──────────────────
@@ -20804,7 +20946,7 @@ function openBuilderForNewWorkflow() {
20804
20946
  if (typeSel) { typeSel.value = 'workflow'; updateBuilderMode(); }
20805
20947
  var name = prompt('Name your new workflow:');
20806
20948
  if (!name) return;
20807
- apiJson('POST', '/api/builder/workflows', { name: name }).then(function(r) {
20949
+ apiJson('POST', '/api/builder/workflows', { name: name, agentSlug: getBuilderSelectedAgentSlug() || undefined }).then(function(r) {
20808
20950
  if (r && r.error) { toast('Create failed: ' + r.error, 'error'); return; }
20809
20951
  if (r && r.id) {
20810
20952
  // Refresh picker, then open the new workflow
package/dist/cli/index.js CHANGED
@@ -24,7 +24,7 @@ import { cmdCronList, cmdCronRun, cmdCronRunDue, cmdCronRuns, cmdCronAdd, cmdCro
24
24
  import { cmdDashboard } from './dashboard.js';
25
25
  import { cmdChat } from './chat.js';
26
26
  import { cmdIngestSeed, cmdIngestRun, cmdIngestList, cmdIngestStatus } from './ingest.js';
27
- import { cmdBrowserStatus, cmdBrowserInstall, cmdBrowserEnable, cmdBrowserDisable } from './browser.js';
27
+ import { cmdBrowserStatus, cmdBrowserInstall, cmdBrowserEnable, cmdBrowserDisable, maybePromptBrowserHarness } from './browser.js';
28
28
  import { isSensitiveEnvKey } from '../secrets/sensitivity.js';
29
29
  const __filename = fileURLToPath(import.meta.url);
30
30
  const __dirname = path.dirname(__filename);
@@ -3171,6 +3171,8 @@ async function cmdUpdate(options) {
3171
3171
  console.log(` ${DIM}Restart your daemon to pick up the new code:${RESET}`);
3172
3172
  console.log(` clementine restart`);
3173
3173
  }
3174
+ // Surface new opt-in integrations (silent unless action needed)
3175
+ await maybePromptBrowserHarness();
3174
3176
  return;
3175
3177
  }
3176
3178
  let step = 0;
@@ -3830,6 +3832,8 @@ async function cmdUpdate(options) {
3830
3832
  }
3831
3833
  console.log(` ${DIM}Config backup: ${backupDir}${RESET}`);
3832
3834
  console.log();
3835
+ // Surface new opt-in integrations (silent unless action needed)
3836
+ await maybePromptBrowserHarness();
3833
3837
  }
3834
3838
  // ── Cron commands ───────────────────────────────────────────────────
3835
3839
  const cronCmd = program
@@ -15,12 +15,15 @@
15
15
  * unless edited through this module, and edits preserve unrelated fields.
16
16
  */
17
17
  import type { WorkflowDefinition, CronJobDefinition, BuilderWorkflowSummary, WorkflowOriginKind } from '../../types.js';
18
- export declare function cronId(name: string): string;
19
- export declare function workflowId(filename: string): string;
20
- export declare function parseBuilderId(id: string): {
18
+ export interface ParsedBuilderId {
21
19
  origin: WorkflowOriginKind;
22
20
  key: string;
23
- } | null;
21
+ scope: 'global' | 'agent';
22
+ agentSlug?: string;
23
+ }
24
+ export declare function cronId(name: string, agentSlug?: string): string;
25
+ export declare function workflowId(filename: string, agentSlug?: string): string;
26
+ export declare function parseBuilderId(id: string): ParsedBuilderId | null;
24
27
  export declare function listAllForBuilder(): BuilderWorkflowSummary[];
25
28
  export declare function readWorkflow(id: string): WorkflowDefinition | null;
26
29
  export declare function cronJobToWorkflow(job: CronJobDefinition): WorkflowDefinition;
@@ -32,11 +35,8 @@ export declare function saveWorkflow(id: string, wf: WorkflowDefinition): {
32
35
  ok: false;
33
36
  error: string;
34
37
  };
35
- /** Resolve the on-disk file path for a builder id (cron entries all share CRON_FILE). */
36
- export declare function sourceFileForId(id: string, parsedHint?: {
37
- origin: WorkflowOriginKind;
38
- key: string;
39
- }): string | null;
38
+ /** Resolve the on-disk file path for a builder id. */
39
+ export declare function sourceFileForId(id: string, parsedHint?: ParsedBuilderId): string | null;
40
40
  /** Drawflow node shape (subset we use). */
41
41
  interface DrawflowNode {
42
42
  id: number;
@@ -18,30 +18,86 @@ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from
18
18
  import path from 'node:path';
19
19
  import matter from 'gray-matter';
20
20
  import yaml from 'js-yaml';
21
- import { CRON_FILE, WORKFLOWS_DIR } from '../../config.js';
21
+ import { AGENTS_DIR, CRON_FILE, WORKFLOWS_DIR } from '../../config.js';
22
22
  import { snapshotWorkflow } from './snapshots.js';
23
23
  // ── ID scheme ───────────────────────────────────────────────────────
24
24
  const CRON_ID_PREFIX = 'cron:';
25
25
  const WORKFLOW_ID_PREFIX = 'workflow:';
26
- export function cronId(name) {
27
- return CRON_ID_PREFIX + name;
26
+ export function cronId(name, agentSlug) {
27
+ return agentSlug
28
+ ? `${CRON_ID_PREFIX}agent:${agentSlug}:${name}`
29
+ : `${CRON_ID_PREFIX}global:${name}`;
28
30
  }
29
- export function workflowId(filename) {
31
+ export function workflowId(filename, agentSlug) {
30
32
  const base = filename.endsWith('.md') ? filename.slice(0, -3) : filename;
31
- return WORKFLOW_ID_PREFIX + base;
33
+ return agentSlug
34
+ ? `${WORKFLOW_ID_PREFIX}agent:${agentSlug}:${base}`
35
+ : `${WORKFLOW_ID_PREFIX}global:${base}`;
32
36
  }
33
37
  export function parseBuilderId(id) {
34
38
  if (id.startsWith(CRON_ID_PREFIX))
35
- return { origin: 'cron', key: id.slice(CRON_ID_PREFIX.length) };
39
+ return parseScopedTail('cron', id.slice(CRON_ID_PREFIX.length));
36
40
  if (id.startsWith(WORKFLOW_ID_PREFIX))
37
- return { origin: 'workflow', key: id.slice(WORKFLOW_ID_PREFIX.length) };
41
+ return parseScopedTail('workflow', id.slice(WORKFLOW_ID_PREFIX.length));
38
42
  return null;
39
43
  }
44
+ function parseScopedTail(origin, tail) {
45
+ if (tail.startsWith('global:')) {
46
+ return { origin, scope: 'global', key: tail.slice('global:'.length) };
47
+ }
48
+ if (tail.startsWith('agent:')) {
49
+ const rest = tail.slice('agent:'.length);
50
+ const idx = rest.indexOf(':');
51
+ if (idx > 0) {
52
+ return {
53
+ origin,
54
+ scope: 'agent',
55
+ agentSlug: rest.slice(0, idx),
56
+ key: rest.slice(idx + 1),
57
+ };
58
+ }
59
+ }
60
+ // Backwards-compatible legacy ids: cron:job-name / workflow:file-name.
61
+ return { origin, scope: 'global', key: tail };
62
+ }
63
+ function listAgentSlugs() {
64
+ if (!existsSync(AGENTS_DIR))
65
+ return [];
66
+ try {
67
+ const entries = readdirSync(AGENTS_DIR, { withFileTypes: true });
68
+ return entries
69
+ .filter((d) => d.isDirectory() && !d.name.startsWith('_'))
70
+ .map((d) => d.name)
71
+ .sort();
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ }
77
+ function cronFileForParsed(parsed) {
78
+ if (parsed.scope === 'agent' && parsed.agentSlug) {
79
+ return path.join(AGENTS_DIR, parsed.agentSlug, 'CRON.md');
80
+ }
81
+ return CRON_FILE;
82
+ }
83
+ function workflowsDirForParsed(parsed) {
84
+ if (parsed.scope === 'agent' && parsed.agentSlug) {
85
+ return path.join(AGENTS_DIR, parsed.agentSlug, 'workflows');
86
+ }
87
+ return WORKFLOWS_DIR;
88
+ }
89
+ function bareNameForScope(name, agentSlug) {
90
+ if (!agentSlug)
91
+ return name;
92
+ const prefix = `${agentSlug}:`;
93
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
94
+ }
40
95
  // ── List ────────────────────────────────────────────────────────────
41
96
  export function listAllForBuilder() {
42
97
  const out = [];
43
- // Crons from CRON.md
44
- for (const job of readCronJobs()) {
98
+ // Global crons from CRON.md. A global entry may still run as an agent via
99
+ // agentSlug; its source remains global so saves write back to the right file.
100
+ for (const job of readCronJobs(CRON_FILE)) {
45
101
  out.push({
46
102
  id: cronId(job.name),
47
103
  origin: 'cron',
@@ -52,9 +108,30 @@ export function listAllForBuilder() {
52
108
  stepCount: 1,
53
109
  sourceFile: CRON_FILE,
54
110
  agentSlug: job.agentSlug,
111
+ scope: 'global',
55
112
  });
56
113
  }
57
- // Workflows from workflows dir
114
+ // Agent crons from agents/<slug>/CRON.md.
115
+ for (const slug of listAgentSlugs()) {
116
+ const cronFile = path.join(AGENTS_DIR, slug, 'CRON.md');
117
+ if (!existsSync(cronFile))
118
+ continue;
119
+ for (const job of readCronJobs(cronFile, slug)) {
120
+ out.push({
121
+ id: cronId(job.name, slug),
122
+ origin: 'cron',
123
+ name: job.name,
124
+ description: '',
125
+ enabled: job.enabled,
126
+ schedule: job.schedule,
127
+ stepCount: 1,
128
+ sourceFile: cronFile,
129
+ agentSlug: slug,
130
+ scope: 'agent',
131
+ });
132
+ }
133
+ }
134
+ // Global workflows from workflows dir.
58
135
  if (existsSync(WORKFLOWS_DIR)) {
59
136
  for (const file of readdirSync(WORKFLOWS_DIR).filter(f => f.endsWith('.md'))) {
60
137
  try {
@@ -69,6 +146,33 @@ export function listAllForBuilder() {
69
146
  stepCount: wf.steps.length,
70
147
  sourceFile: wf.sourceFile,
71
148
  agentSlug: wf.agentSlug,
149
+ scope: 'global',
150
+ });
151
+ }
152
+ catch {
153
+ // Skip unparseable workflow files
154
+ }
155
+ }
156
+ }
157
+ // Agent workflows from agents/<slug>/workflows.
158
+ for (const slug of listAgentSlugs()) {
159
+ const wfDir = path.join(AGENTS_DIR, slug, 'workflows');
160
+ if (!existsSync(wfDir))
161
+ continue;
162
+ for (const file of readdirSync(wfDir).filter(f => f.endsWith('.md'))) {
163
+ try {
164
+ const wf = parseWorkflowFile(path.join(wfDir, file), slug);
165
+ out.push({
166
+ id: workflowId(file, slug),
167
+ origin: 'workflow',
168
+ name: wf.name,
169
+ description: wf.description,
170
+ enabled: wf.enabled,
171
+ schedule: wf.trigger.schedule,
172
+ stepCount: wf.steps.length,
173
+ sourceFile: wf.sourceFile,
174
+ agentSlug: slug,
175
+ scope: 'agent',
72
176
  });
73
177
  }
74
178
  catch {
@@ -76,7 +180,11 @@ export function listAllForBuilder() {
76
180
  }
77
181
  }
78
182
  }
79
- return out.sort((a, b) => a.name.localeCompare(b.name));
183
+ return out.sort((a, b) => {
184
+ const ownerA = a.agentSlug ?? '';
185
+ const ownerB = b.agentSlug ?? '';
186
+ return ownerA.localeCompare(ownerB) || a.name.localeCompare(b.name);
187
+ });
80
188
  }
81
189
  // ── Read ────────────────────────────────────────────────────────────
82
190
  export function readWorkflow(id) {
@@ -84,25 +192,28 @@ export function readWorkflow(id) {
84
192
  if (!parsed)
85
193
  return null;
86
194
  if (parsed.origin === 'cron') {
87
- const job = readCronJobs().find(j => j.name === parsed.key);
195
+ const cronFile = cronFileForParsed(parsed);
196
+ const job = readCronJobs(cronFile, parsed.agentSlug).find(j => j.name === parsed.key);
88
197
  if (!job)
89
198
  return null;
90
- return cronJobToWorkflow(job);
199
+ const wf = cronJobToWorkflow(job);
200
+ wf.sourceFile = cronFile;
201
+ return wf;
91
202
  }
92
- const file = path.join(WORKFLOWS_DIR, parsed.key + '.md');
203
+ const file = path.join(workflowsDirForParsed(parsed), parsed.key + '.md');
93
204
  if (!existsSync(file))
94
205
  return null;
95
206
  try {
96
- return parseWorkflowFile(file);
207
+ return parseWorkflowFile(file, parsed.agentSlug);
97
208
  }
98
209
  catch {
99
210
  return null;
100
211
  }
101
212
  }
102
- function readCronJobs() {
103
- if (!existsSync(CRON_FILE))
213
+ function readCronJobs(cronFile = CRON_FILE, agentSlug) {
214
+ if (!existsSync(cronFile))
104
215
  return [];
105
- const raw = readFileSync(CRON_FILE, 'utf-8');
216
+ const raw = readFileSync(cronFile, 'utf-8');
106
217
  let parsed;
107
218
  try {
108
219
  parsed = matter(raw);
@@ -137,16 +248,16 @@ function readCronJobs() {
137
248
  alwaysDeliver: job.always_deliver === true ? true : undefined,
138
249
  context: job.context != null ? String(job.context) : undefined,
139
250
  preCheck: job.pre_check != null ? String(job.pre_check) : undefined,
140
- agentSlug: typeof job.agentSlug === 'string'
251
+ agentSlug: agentSlug ?? (typeof job.agentSlug === 'string'
141
252
  ? job.agentSlug
142
253
  : typeof job.agent_slug === 'string'
143
254
  ? job.agent_slug
144
- : undefined,
255
+ : undefined),
145
256
  });
146
257
  }
147
258
  return jobs;
148
259
  }
149
- function parseWorkflowFile(filePath) {
260
+ function parseWorkflowFile(filePath, fallbackAgentSlug) {
150
261
  const raw = readFileSync(filePath, 'utf-8');
151
262
  const parsed = matter(raw);
152
263
  const data = parsed.data;
@@ -215,7 +326,7 @@ function parseWorkflowFile(filePath) {
215
326
  steps,
216
327
  synthesis,
217
328
  sourceFile: filePath,
218
- agentSlug: typeof data.agentSlug === 'string' ? data.agentSlug : undefined,
329
+ agentSlug: typeof data.agentSlug === 'string' ? data.agentSlug : fallbackAgentSlug,
219
330
  };
220
331
  }
221
332
  // ── Cron ⇄ Workflow ─────────────────────────────────────────────────
@@ -265,23 +376,25 @@ export function saveWorkflow(id, wf) {
265
376
  if (!isCronShape(wf)) {
266
377
  return { ok: false, error: 'Cron entry must remain a single prompt step with a cron schedule' };
267
378
  }
268
- return saveCronEntry(parsed.key, wf);
379
+ return saveCronEntryAt(cronFileForParsed(parsed), parsed.key, wf, parsed.agentSlug);
269
380
  }
270
- return saveWorkflowFile(parsed.key, wf);
381
+ return saveWorkflowFileAt(workflowsDirForParsed(parsed), parsed.key, wf, parsed.agentSlug);
271
382
  }
272
- /** Resolve the on-disk file path for a builder id (cron entries all share CRON_FILE). */
383
+ /** Resolve the on-disk file path for a builder id. */
273
384
  export function sourceFileForId(id, parsedHint) {
274
385
  const parsed = parsedHint ?? parseBuilderId(id);
275
386
  if (!parsed)
276
387
  return null;
277
388
  if (parsed.origin === 'cron')
278
- return CRON_FILE;
279
- return path.join(WORKFLOWS_DIR, parsed.key + '.md');
389
+ return cronFileForParsed(parsed);
390
+ return path.join(workflowsDirForParsed(parsed), parsed.key + '.md');
280
391
  }
281
- function saveCronEntry(originalName, wf) {
282
- if (!existsSync(CRON_FILE))
283
- return { ok: false, error: 'CRON.md does not exist' };
284
- const raw = readFileSync(CRON_FILE, 'utf-8');
392
+ function saveCronEntryAt(cronFile, originalName, wf, agentSlug) {
393
+ if (!existsSync(cronFile)) {
394
+ mkdirSync(path.dirname(cronFile), { recursive: true });
395
+ writeFileSync(cronFile, matter.stringify('', { jobs: [] }), 'utf-8');
396
+ }
397
+ const raw = readFileSync(cronFile, 'utf-8');
285
398
  let parsed;
286
399
  try {
287
400
  parsed = matter(raw);
@@ -297,7 +410,7 @@ function saveCronEntry(originalName, wf) {
297
410
  const prev = jobs[idx];
298
411
  const updated = {
299
412
  ...prev,
300
- name: wf.name,
413
+ name: bareNameForScope(wf.name, agentSlug),
301
414
  schedule: wf.trigger.schedule,
302
415
  prompt: step.prompt,
303
416
  enabled: wf.enabled,
@@ -309,18 +422,27 @@ function saveCronEntry(originalName, wf) {
309
422
  updated.model = step.model;
310
423
  if (step.workDir != null)
311
424
  updated.work_dir = step.workDir;
312
- if (wf.agentSlug)
425
+ if (agentSlug) {
426
+ delete updated.agentSlug;
427
+ delete updated.agent_slug;
428
+ }
429
+ else if (wf.agentSlug) {
313
430
  updated.agentSlug = wf.agentSlug;
431
+ }
432
+ else {
433
+ delete updated.agentSlug;
434
+ delete updated.agent_slug;
435
+ }
314
436
  jobs[idx] = updated;
315
437
  parsed.data.jobs = jobs;
316
438
  const out = matter.stringify(parsed.content ?? '', parsed.data);
317
- writeFileSync(CRON_FILE, out, 'utf-8');
439
+ writeFileSync(cronFile, out, 'utf-8');
318
440
  return { ok: true };
319
441
  }
320
- function saveWorkflowFile(key, wf) {
321
- if (!existsSync(WORKFLOWS_DIR))
322
- mkdirSync(WORKFLOWS_DIR, { recursive: true });
323
- const file = path.join(WORKFLOWS_DIR, key + '.md');
442
+ function saveWorkflowFileAt(workflowsDir, key, wf, agentSlug) {
443
+ if (!existsSync(workflowsDir))
444
+ mkdirSync(workflowsDir, { recursive: true });
445
+ const file = path.join(workflowsDir, key + '.md');
324
446
  // Preserve body content if the file exists; otherwise empty body.
325
447
  let body = '';
326
448
  if (existsSync(file)) {
@@ -339,8 +461,8 @@ function saveWorkflowFile(key, wf) {
339
461
  enabled: wf.enabled,
340
462
  trigger: wf.trigger,
341
463
  };
342
- if (wf.agentSlug)
343
- data.agentSlug = wf.agentSlug;
464
+ if (agentSlug || wf.agentSlug)
465
+ data.agentSlug = agentSlug ?? wf.agentSlug;
344
466
  if (Object.keys(wf.inputs).length > 0)
345
467
  data.inputs = wf.inputs;
346
468
  data.steps = wf.steps.map(serializeStep);
@@ -34,7 +34,7 @@ const stepShape = z.object({
34
34
  });
35
35
  export function registerBuilderTools(server) {
36
36
  // ── Discovery ──────────────────────────────────────────────────────────
37
- server.tool('workflow_list', 'List all workflows and crons visible in the Builder. Returns one per line: id|name|origin|enabled|schedule|stepCount.', {
37
+ server.tool('workflow_list', 'List all workflows and crons visible in the Builder. Returns one per line: id|owner|name|origin|enabled|schedule|stepCount.', {
38
38
  enabledOnly: z.boolean().optional().describe('If true, return only enabled workflows'),
39
39
  verbose: z.boolean().optional(),
40
40
  }, async ({ enabledOnly, verbose }) => {
@@ -43,7 +43,7 @@ export function registerBuilderTools(server) {
43
43
  return textResult(JSON.stringify(items, null, 2));
44
44
  if (items.length === 0)
45
45
  return textResult('(no workflows or crons found)');
46
- return textResult(items.map(i => `${i.id}|${i.name}|${i.origin}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`).join('\n'));
46
+ return textResult(items.map(i => `${i.id}|${i.agentSlug ?? 'global'}|${i.name}|${i.origin}|${i.enabled ? 'on' : 'off'}|${i.schedule ?? '-'}|${i.stepCount}step${i.stepCount === 1 ? '' : 's'}`).join('\n'));
47
47
  });
48
48
  server.tool('workflow_read', 'Read a workflow as canonical JSON. Use this before editing — patches reference current step ids.', {
49
49
  id: z.string().describe('Builder id (e.g., cron:morning-briefing or workflow:daily-digest)'),
package/dist/types.d.ts CHANGED
@@ -524,6 +524,7 @@ export interface BuilderWorkflowSummary {
524
524
  stepCount: number;
525
525
  sourceFile: string;
526
526
  agentSlug?: string;
527
+ scope?: 'global' | 'agent';
527
528
  }
528
529
  export interface WorkflowRunEntry {
529
530
  workflowName: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",