kimaki 0.18.0 → 0.20.0

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.
@@ -36,6 +36,18 @@ function createMatchers() {
36
36
  ],
37
37
  },
38
38
  };
39
+ const toolCallMatcher = {
40
+ id: 'tool-call-reply',
41
+ priority: 90,
42
+ when: { latestUserTextIncludes: 'use a tool please' },
43
+ then: {
44
+ parts: [
45
+ { type: 'stream-start', warnings: [] },
46
+ { type: 'tool-call', toolCallId: 'tc1', toolName: 'bash', input: JSON.stringify({ command: 'echo hello world', description: 'Print greeting' }) },
47
+ { type: 'finish', finishReason: 'tool-calls', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 } },
48
+ ],
49
+ },
50
+ };
39
51
  const defaultMatcher = {
40
52
  id: 'default-reply',
41
53
  priority: 1,
@@ -49,12 +61,13 @@ function createMatchers() {
49
61
  ],
50
62
  },
51
63
  };
52
- return [helloMatcher, defaultMatcher];
64
+ return [helloMatcher, toolCallMatcher, defaultMatcher];
53
65
  }
54
66
  let client;
55
67
  let directories;
56
68
  let testStartTime;
57
69
  let sessionID;
70
+ let toolSessionID;
58
71
  beforeAll(async () => {
59
72
  testStartTime = Date.now();
60
73
  directories = createRunDirectories();
@@ -117,7 +130,34 @@ beforeAll(async () => {
117
130
  setTimeout(resolve, 200);
118
131
  });
119
132
  }
120
- }, 20_000);
133
+ // Create a second session that triggers a tool call (bash echo)
134
+ const toolCreateResult = await client.session.create({
135
+ directory: directories.projectDirectory,
136
+ title: 'Tool Call Session',
137
+ });
138
+ toolSessionID = toolCreateResult.data.id;
139
+ await client.session.promptAsync({
140
+ sessionID: toolSessionID,
141
+ directory: directories.projectDirectory,
142
+ parts: [{ type: 'text', text: 'use a tool please' }],
143
+ });
144
+ // Wait for tool execution to complete
145
+ const toolMaxWait = 15_000;
146
+ const toolPollStart = Date.now();
147
+ while (Date.now() - toolPollStart < toolMaxWait) {
148
+ const msgs = await client.session.messages({
149
+ sessionID: toolSessionID,
150
+ directory: directories.projectDirectory,
151
+ });
152
+ const messages = msgs.data || [];
153
+ const hasToolPart = messages.some((m) => m.parts.some((p) => p.type === 'tool' && p.state?.status === 'completed'));
154
+ if (hasToolPart) {
155
+ await new Promise((resolve) => { setTimeout(resolve, 500); });
156
+ break;
157
+ }
158
+ await new Promise((resolve) => { setTimeout(resolve, 200); });
159
+ }
160
+ }, 30_000);
121
161
  afterAll(async () => {
122
162
  if (directories) {
123
163
  await cleanupTestSessions({
@@ -262,3 +302,34 @@ test('generate markdown with lastAssistantOnly', async () => {
262
302
  // Should contain the assistant response
263
303
  expect(markdown).toContain('Hello! This is a deterministic markdown test response.');
264
304
  });
305
+ test('compact tools: tool calls show one-liner with line count', async () => {
306
+ const exporter = new ShareMarkdown(client);
307
+ const result = await exporter.generate({
308
+ sessionID: toolSessionID,
309
+ compactTools: true,
310
+ });
311
+ expect(errore.isOk(result)).toBe(true);
312
+ const md = errore.unwrap(result);
313
+ // Compact mode: exact one-liner format with params and line count
314
+ expect(md).toContain('> 🛠️ **bash** command=echo hello world, description=Print greeting');
315
+ expect(md).toMatch(/\(\d+ lines?\)/);
316
+ // Should NOT contain full output code blocks or input YAML
317
+ expect(md).not.toContain('**Output:**');
318
+ expect(md).not.toContain('**Input:**');
319
+ expect(md).not.toContain('```yaml');
320
+ });
321
+ test('verbose tools: tool calls show full input and output', async () => {
322
+ const exporter = new ShareMarkdown(client);
323
+ const result = await exporter.generate({
324
+ sessionID: toolSessionID,
325
+ compactTools: false,
326
+ });
327
+ expect(errore.isOk(result)).toBe(true);
328
+ const md = errore.unwrap(result);
329
+ // Verbose mode: full tool rendering with input YAML and output code block
330
+ expect(md).toContain('#### 🛠️ Tool: bash');
331
+ expect(md).toContain('**Input:**');
332
+ expect(md).toContain('```yaml');
333
+ expect(md).toContain('**Output:**');
334
+ expect(md).toContain('hello world');
335
+ });
@@ -266,6 +266,42 @@ export async function getFileAttachments(message) {
266
266
  return results.filter((r) => r !== null);
267
267
  }
268
268
  const MAX_BASH_COMMAND_INLINE_LENGTH = 100;
269
+ /**
270
+ * Format the inline title for a bash tool part. Handles three cases:
271
+ * 1. Short single-line command → show full command
272
+ * 2. Long/multiline command with description → show description
273
+ * 3. Long/multiline command without description → truncate first line of command
274
+ *
275
+ * The description field was removed from the bash tool schema in newer opencode
276
+ * versions, so case 3 is the common path now. Without this fallback, long commands
277
+ * would render as just "┣ bash" with no context.
278
+ */
279
+ export function formatBashToolTitle({ command, description, stateTitle, }) {
280
+ if (!command && !description && !stateTitle)
281
+ return '';
282
+ const isSingleLine = !command.includes('\n');
283
+ // Find first non-empty line to handle commands with leading blank lines
284
+ const firstMeaningfulLine = command
285
+ .split('\n')
286
+ .find((line) => line.trim().length > 0)
287
+ ?.trimStart() ?? '';
288
+ if (command && isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
289
+ return ` _${escapeInlineMarkdown(command)}_`;
290
+ }
291
+ if (description) {
292
+ return ` _${escapeInlineMarkdown(description)}_`;
293
+ }
294
+ if (firstMeaningfulLine.length > 0) {
295
+ const truncated = firstMeaningfulLine.length > MAX_BASH_COMMAND_INLINE_LENGTH
296
+ ? firstMeaningfulLine.slice(0, MAX_BASH_COMMAND_INLINE_LENGTH) + '…'
297
+ : firstMeaningfulLine;
298
+ return ` _${escapeInlineMarkdown(truncated)}_`;
299
+ }
300
+ if (stateTitle) {
301
+ return ` _${escapeInlineMarkdown(stateTitle)}_`;
302
+ }
303
+ return '';
304
+ }
269
305
  export function getToolSummaryText(part) {
270
306
  if (part.type !== 'tool')
271
307
  return '';
@@ -449,12 +485,10 @@ export function formatPart(part, prefix) {
449
485
  }
450
486
  const command = part.state.input?.command || '';
451
487
  const description = part.state.input?.description || '';
452
- const isSingleLine = !command.includes('\n');
453
- const toolTitle = isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH
454
- ? ` _${escapeInlineMarkdown(command)}_`
455
- : description
456
- ? ` _${escapeInlineMarkdown(description)}_`
457
- : '';
488
+ const toolTitle = formatBashToolTitle({
489
+ command,
490
+ description,
491
+ });
458
492
  return `┣ ${pfx}bash${toolTitle}`;
459
493
  }
460
494
  const summaryText = getToolSummaryText(part);
@@ -466,16 +500,12 @@ export function formatPart(part, prefix) {
466
500
  else if (part.tool === 'bash') {
467
501
  const command = part.state.input?.command || '';
468
502
  const description = part.state.input?.description || '';
469
- const isSingleLine = !command.includes('\n');
470
- if (isSingleLine && command.length <= MAX_BASH_COMMAND_INLINE_LENGTH) {
471
- toolTitle = `_${escapeInlineMarkdown(command)}_`;
472
- }
473
- else if (description) {
474
- toolTitle = `_${escapeInlineMarkdown(description)}_`;
475
- }
476
- else if (stateTitle) {
477
- toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
478
- }
503
+ const formatted = formatBashToolTitle({
504
+ command,
505
+ description,
506
+ stateTitle,
507
+ });
508
+ toolTitle = formatted.startsWith(' ') ? formatted.slice(1) : formatted;
479
509
  }
480
510
  else if (stateTitle) {
481
511
  toolTitle = `_${escapeInlineMarkdown(stateTitle)}_`;
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'vitest';
2
- import { formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
2
+ import { formatBashToolTitle, formatPart, formatTodoList, serializeEmbeds, serializePoll, serializeMessageSnapshots } from './message-formatting.js';
3
3
  describe('formatPart', () => {
4
4
  test('callout text does not get ⬥ prefix', () => {
5
5
  const part = {
@@ -42,6 +42,44 @@ describe('formatPart', () => {
42
42
  `);
43
43
  });
44
44
  });
45
+ describe('formatBashToolTitle', () => {
46
+ test('short single-line command shown in full', () => {
47
+ expect(formatBashToolTitle({ command: 'echo hello' })).toMatchInlineSnapshot(`" _echo hello_"`);
48
+ });
49
+ test('multiline command without description truncates to first line', () => {
50
+ expect(formatBashToolTitle({ command: 'echo hello\necho world\necho done' })).toMatchInlineSnapshot(`" _echo hello_"`);
51
+ });
52
+ test('long single-line command is truncated with ellipsis', () => {
53
+ const longCommand = 'a'.repeat(150);
54
+ const result = formatBashToolTitle({ command: longCommand });
55
+ expect(result).toContain('…');
56
+ expect(result.length).toBeLessThan(150);
57
+ });
58
+ test('description is preferred over truncated command when present', () => {
59
+ expect(formatBashToolTitle({
60
+ command: 'echo hello\necho world',
61
+ description: 'Print greeting',
62
+ })).toMatchInlineSnapshot(`" _Print greeting_"`);
63
+ });
64
+ test('stateTitle used as last resort', () => {
65
+ expect(formatBashToolTitle({ command: '', stateTitle: 'Running tests' })).toMatchInlineSnapshot(`" _Running tests_"`);
66
+ });
67
+ test('empty inputs return empty string', () => {
68
+ expect(formatBashToolTitle({ command: '' })).toBe('');
69
+ });
70
+ test('leading blank line skipped, uses first meaningful line', () => {
71
+ expect(formatBashToolTitle({ command: '\npnpm test\npnpm build' })).toMatchInlineSnapshot(`" _pnpm test_"`);
72
+ });
73
+ test('whitespace-only first line skipped', () => {
74
+ expect(formatBashToolTitle({ command: ' \npnpm test' })).toMatchInlineSnapshot(`" _pnpm test_"`);
75
+ });
76
+ test('no description field (new opencode) with multiline command', () => {
77
+ // This is the exact scenario that was broken: opencode removed `description`
78
+ // from the bash tool schema, so multiline commands rendered as just "┣ bash"
79
+ const command = 'git diff HEAD~1 --stat && git log --oneline -5';
80
+ expect(formatBashToolTitle({ command: command + '\n' + 'echo done' })).toMatchInlineSnapshot(`" _git diff HEAD\\~1 --stat && git log --oneline -5_"`);
81
+ });
82
+ });
45
83
  describe('formatTodoList', () => {
46
84
  test('formats active todo with monospace numbers', () => {
47
85
  const part = {
@@ -190,23 +190,23 @@ function getTokenTotal(tokens) {
190
190
  tokens.cache.read +
191
191
  tokens.cache.write);
192
192
  }
193
+ /**
194
+ * Built-in read-only tools that are hidden in default verbosity mode.
195
+ * Any tool NOT in this list is considered "essential" and shown,
196
+ * which means custom tools, MCP tools, and plugin tools are visible by default.
197
+ */
198
+ const HIDDEN_READONLY_TOOLS = [
199
+ 'read',
200
+ 'glob',
201
+ 'grep',
202
+ 'describe-media',
203
+ 'todoread',
204
+ ];
193
205
  /** Check if a tool part is "essential" (shown in text-and-essential-tools mode). */
194
206
  export function isEssentialToolName(toolName) {
195
- const essentialTools = [
196
- 'edit',
197
- 'write',
198
- 'apply_patch',
199
- 'bash',
200
- 'webfetch',
201
- 'websearch',
202
- 'googlesearch',
203
- 'codesearch',
204
- 'task',
205
- 'todowrite',
206
- 'skill',
207
- ];
208
- // Also match any MCP tool that contains these names
209
- return essentialTools.some((name) => {
207
+ // Hide known read-only built-in tools; show everything else
208
+ // (custom tools, MCP tools, plugin tools are visible by default)
209
+ return !HIDDEN_READONLY_TOOLS.some((name) => {
210
210
  return toolName === name || toolName.endsWith(`_${name}`);
211
211
  });
212
212
  }
@@ -2062,6 +2062,15 @@ export class ThreadSessionRuntime {
2062
2062
  skippedBySessionGuard = true;
2063
2063
  return;
2064
2064
  }
2065
+ // Context-only messages (noReply) should not create a new session.
2066
+ // If there is no existing session, silently skip.
2067
+ if (input.noReply) {
2068
+ const existingSessionId = this.state?.sessionId || await getThreadSession(this.thread.id) || undefined;
2069
+ if (!existingSessionId) {
2070
+ logger.log(`[INGRESS] Skipping noReply message for thread ${this.threadId}: no existing session`);
2071
+ return;
2072
+ }
2073
+ }
2065
2074
  // Helper: stop typing and drain queued local messages on error.
2066
2075
  const cleanupOnError = async (errorMessage) => {
2067
2076
  this.stopTyping();
@@ -2269,6 +2278,7 @@ export class ThreadSessionRuntime {
2269
2278
  ...(resolvedAgent ? { agent: resolvedAgent } : {}),
2270
2279
  ...(modelField ? { model: modelField } : {}),
2271
2280
  ...variantField,
2281
+ ...(input.noReply ? { noReply: true } : {}),
2272
2282
  };
2273
2283
  const promptResult = await getClient().session.promptAsync(request)
2274
2284
  .catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }));
@@ -2284,7 +2294,10 @@ export class ThreadSessionRuntime {
2284
2294
  return;
2285
2295
  }
2286
2296
  logger.log(`[INGRESS] promptAsync accepted by opencode queue sessionId=${session.id} threadId=${this.threadId}`);
2287
- this.markQueueDispatchBusy(session.id);
2297
+ // noReply messages don't trigger the agent loop, so don't mark as busy
2298
+ if (!input.noReply) {
2299
+ this.markQueueDispatchBusy(session.id);
2300
+ }
2288
2301
  });
2289
2302
  if (skippedBySessionGuard) {
2290
2303
  return { queued: false };
@@ -2426,9 +2439,17 @@ export class ThreadSessionRuntime {
2426
2439
  // Route with the resolved mode through normal paths.
2427
2440
  // Await the enqueue so session state (ensureSession, setThreadSession)
2428
2441
  // is persisted before the next message's preprocessing reads it.
2429
- const enqueueResult = resolvedInput.mode === 'local-queue' || resolvedInput.command
2430
- ? await this.enqueueViaLocalQueue(resolvedInput)
2431
- : await this.submitViaOpencodeQueue(resolvedInput);
2442
+ // noReply messages always go through the opencode path so the flag
2443
+ // reaches promptAsync; local queue doesn't support noReply.
2444
+ const enqueueResult = resolvedInput.noReply
2445
+ ? await this.submitViaOpencodeQueue({
2446
+ ...resolvedInput,
2447
+ mode: 'opencode',
2448
+ command: undefined,
2449
+ })
2450
+ : (resolvedInput.mode === 'local-queue' || resolvedInput.command)
2451
+ ? await this.enqueueViaLocalQueue(resolvedInput)
2452
+ : await this.submitViaOpencodeQueue(resolvedInput);
2432
2453
  resolveOuter(enqueueResult);
2433
2454
  }
2434
2455
  catch (err) {
package/dist/store.js CHANGED
@@ -14,6 +14,7 @@ export const store = createStore(() => ({
14
14
  allowedMentions: ['users'],
15
15
  allowAllUsers: false,
16
16
  permissionTimeoutMs: 10 * 60 * 1000,
17
+ useWorktrees: false,
17
18
  autoUpgradeEnabled: true,
18
19
  syncEnabled: true,
19
20
  discordBaseUrl: 'https://discord.com',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.18.0",
5
+ "version": "0.20.0",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -25,9 +25,9 @@
25
25
  "tsx": "^4.20.5",
26
26
  "undici": "^8.0.2",
27
27
  "discord-digital-twin": "^0.1.0",
28
- "db": "^0.0.0",
28
+ "opencode-deterministic-provider": "^0.0.1",
29
29
  "opencode-cached-provider": "^0.0.1",
30
- "opencode-deterministic-provider": "^0.0.1"
30
+ "db": "^0.0.0"
31
31
  },
32
32
  "dependencies": {
33
33
  "@ai-sdk/google": "^3.0.53",
@@ -62,9 +62,9 @@
62
62
  "yaml": "^2.8.3",
63
63
  "zod": "^4.3.6",
64
64
  "zustand": "^5.0.11",
65
- "errore": "^0.14.1",
66
65
  "libsqlproxy": "^0.1.0",
67
66
  "opencode-injection-guard": "^0.2.1",
67
+ "errore": "^0.14.1",
68
68
  "traforo": "^0.7.1"
69
69
  },
70
70
  "optionalDependencies": {
@@ -178,6 +178,23 @@ import { openInBrowser } from 'goke'
178
178
  await openInBrowser('https://example.com/dashboard')
179
179
  ```
180
180
 
181
+ ## Command Descriptions and Examples
182
+
183
+ **Use backtick formatting in descriptions** for flags and command references (e.g. `` `--status` ``, `` `mycli deploy` ``). Plain text flag names won't render as code in generated docs.
184
+
185
+ **Use `.example()` for usage examples, not the description string.** `generateDocs()` auto-wraps `.example()` strings in fenced `` ```sh `` code blocks. Examples in the description render as plain text without syntax highlighting.
186
+
187
+ ```ts
188
+ cli.command(
189
+ 'query <sql>',
190
+ dedent`
191
+ Run a SQL query. Add \`--json\` for the raw JSON envelope.
192
+ `,
193
+ )
194
+ .example('mycli query "SELECT * FROM users" -p my-app')
195
+ .example('mycli query "SELECT * FROM users FORMAT CSV" > out.csv')
196
+ ```
197
+
181
198
  ## Command Naming Conventions
182
199
 
183
200
  **ALWAYS read existing commands before adding a new one.** Scan the CLI for option names, verbs, and noun patterns already in use. New commands must stay consistent with what exists.
@@ -43,6 +43,7 @@ When the user asks about a specific workflow, fetch the matching page directly:
43
43
  - **Node deploy**: https://holocron.so/docs/deploy/node.md
44
44
  - **Holocron deploy**: https://holocron.so/docs/deploy/holocron.md
45
45
  - **AI assistant docs**: https://holocron.so/docs/ai/assistant.md
46
+ - **Bleed**: https://holocron.so/docs/customize/bleed.md
46
47
 
47
48
  ```bash
48
49
  curl -fsSL https://holocron.so/docs/quickstart.md
@@ -326,6 +327,36 @@ sidebarTitle: Auth Providers
326
327
  below the title and in search engine snippets. Write a single sentence that
327
328
  summarizes the page content. Longer descriptions get truncated with an ellipsis.
328
329
 
330
+ ## Page titles (MUST follow)
331
+
332
+ Holocron renders the browser `<title>` as `{page title} — {site name}`. Two
333
+ frontmatter fields control what appears where:
334
+
335
+ - **`title`** — browser tab, Google results, OG image, page heading. Write for SEO.
336
+ - **`sidebarTitle`** — sidebar navigation only. Write for scannability (short label).
337
+
338
+ **Always set both.** `title` is the full descriptive phrase; `sidebarTitle` is
339
+ the short sidebar label. Without `sidebarTitle`, the full title shows in the
340
+ sidebar and often wraps or looks verbose.
341
+
342
+ ```mdx
343
+ ---
344
+ title: Open-source browser automation for AI agents
345
+ sidebarTitle: Home
346
+ description: Automate any browser with a simple TypeScript API.
347
+ ---
348
+ ```
349
+
350
+ Browser tab: `Open-source browser automation for AI agents — Playwriter`
351
+ Sidebar: `Home`
352
+
353
+ **Rules:**
354
+
355
+ - **Never set `title` identical to the site name.** `Playwriter — Playwriter` is redundant.
356
+ - **Every `title` MUST be descriptive, not a generic label.** `Introduction`, `Getting Started`, `Overview` say nothing in a browser tab. Write what the page covers.
357
+ - **Every `title` MUST be unique across the site.**
358
+ - **`index.mdx` MUST use `sidebarTitle: Home`** (or `Overview`) and a descriptive `title`.
359
+
329
360
  ## Custom entry (mounting docs inside a Spiceflow app)
330
361
 
331
362
  When the site mounts Holocron inside an existing **Spiceflow** app (the `entry`
@@ -416,6 +447,12 @@ below the main content to accommodate the aside height. Keep aside callouts
416
447
  short, and only place them in sections that have at least a few paragraphs of
417
448
  body text.
418
449
 
450
+ ## Moving or renaming a page
451
+
452
+ When moving or renaming a page, **always add a redirect** from the old slug to
453
+ the new one so existing links and bookmarks don't break. Update internal links
454
+ in other pages and the slug in `docs.json` navigation to match.
455
+
419
456
  ## New pages and navigation
420
457
 
421
458
  After creating a new `.mdx` or `.md` page, add its slug to `docs.jsonc`
@@ -569,7 +606,7 @@ Use ASCII diagrams frequently in MDX pages. Always use the **`diagram`** languag
569
606
  - Never exceed 94 characters per line.
570
607
  - Prefer a **varied, organic layout**. Mix plain text labels, boxes for major components, and directional arrows.
571
608
  - All connections must use **directional arrows**. Never use plain lines without an arrowhead.
572
- - Verify alignment by counting characters precisely.
609
+ - Verify alignment by running `npx -y @holocron.so/cli diagrams fix <file>` (see dedicated section below).
573
610
 
574
611
  ````mdx
575
612
  ```diagram
@@ -590,6 +627,23 @@ docs.jsonc ───►│ Vite Plugin │──────► Build Output
590
627
  ```
591
628
  ````
592
629
 
630
+ ## Always run `diagrams fix` after editing diagrams
631
+
632
+ LLMs cannot count characters reliably. Every time you create or edit a diagram in an MDX page, you **must** run the alignment fixer before committing. This is not optional.
633
+
634
+ ```bash
635
+ npx -y @holocron.so/cli diagrams fix path/to/file.mdx
636
+ ```
637
+
638
+ The command:
639
+ - Fixes box alignment (padding, border widths, junctions) in-place
640
+ - Reports how many lines were changed
641
+ - Exits with code 1 if any lines exceed max width (94 cols by default)
642
+
643
+ If the output says "No changes", the diagram was already correct. If it reports width violations, shorten the offending lines manually and re-run.
644
+
645
+ **Do not skip this step.** Even if the diagram looks correct in your editor, run the command. Off-by-one padding errors are invisible to LLMs but obvious to humans in monospace fonts.
646
+
593
647
  ## OpenAPI summaries
594
648
 
595
649
  OpenAPI operation `summary` fields become sidebar titles. The sidebar is only
@@ -601,6 +655,15 @@ characters. Use the same concise style as page `sidebarTitle` fields.
601
655
  - Never repeat the tag/group name in the summary. If the tag is `Deploy`, the summary should not start with "Deploy".
602
656
  - If the summary reads well as a sidebar label at 230px, it is short enough.
603
657
 
658
+ ## Bleed — extending content past the prose column
659
+
660
+ Always wrap **YouTube embeds, videos, and large images** in `<div className='bleed'>`
661
+ so they extend into both page margins instead of sitting at narrow prose width.
662
+ `<Frame>` also accepts `className='bleed'`. Containers like Callout, Card, and
663
+ Accordion apply `no-bleed` automatically to keep descendants inside their frame.
664
+
665
+ See https://holocron.so/docs/customize/bleed.md for the full reference.
666
+
604
667
  ## Agent rules
605
668
 
606
669
  - **Always place MDX pages inside the Holocron `pagesDir`.** Before creating or
@@ -614,5 +677,7 @@ characters. Use the same concise style as page `sidebarTitle` fields.
614
677
  - All ASCII diagrams in MDX pages must use the `diagram` language hint on the
615
678
  code fence (` ```diagram `). This renders them with proper styling on the
616
679
  website instead of plain monospace.
680
+ - After creating or editing any diagram, run `npx -y @holocron.so/cli diagrams fix <file>`
681
+ to auto-fix alignment. LLMs cannot count characters reliably.
617
682
  - Never pipe curl or `--help` output through `head`, `tail`, or any truncation
618
683
  command. Always read the full output.
@@ -199,6 +199,40 @@ After setup, `sigillo run` in any subdirectory uses that project + environment a
199
199
 
200
200
  Avoid `sigillo secrets download` unless a specific tool requires a file. Prefer injecting directly via `sigillo run --` so values never touch the filesystem.
201
201
 
202
+ ## Placeholder secrets (user fills in later)
203
+
204
+ When the user asks to add a secret but will provide the actual value later via the dashboard, set it with an empty value:
205
+
206
+ ```bash
207
+ sigillo secrets set DATABASE_URL ""
208
+ ```
209
+
210
+ This creates the secret as a placeholder. The CLI shows `empty: true` when listing secrets so empty placeholders are visible. After setting the placeholder, always print the dashboard URL so the user can fill it in:
211
+
212
+ ```
213
+ https://sigillo.dev/dash/projects/<PROJECT_ID>/envs/<ENV_SLUG>
214
+ ```
215
+
216
+ Replace `<PROJECT_ID>` and `<ENV_SLUG>` with the actual values from the current setup. To find them:
217
+
218
+ ```bash
219
+ sigillo secrets
220
+ ```
221
+
222
+ The `environment_id` in the output is the env ID. The project ID is from `sigillo setup` or `sigillo projects`.
223
+
224
+ To set placeholders across all environments at once:
225
+
226
+ ```bash
227
+ sigillo secrets set DATABASE_URL "" -c dev -c preview -c prod
228
+ ```
229
+
230
+ For secrets that need real random values immediately (auth secrets, encryption keys), generate them instead of leaving empty:
231
+
232
+ ```bash
233
+ sigillo secrets set AUTH_SECRET "$(openssl rand -base64 32)" -c dev -c preview -c prod
234
+ ```
235
+
202
236
  ## Bootstrapping a project for a new codebase
203
237
 
204
238
  When a codebase needs Sigillo for the first time, the agent creates the org, project, and placeholder secrets. The user fills in real values later via the web UI.
@@ -47,3 +47,16 @@ The README and `zele --help` output are the source of truth for commands, option
47
47
  ```
48
48
  5. **Google-only features** (labels, Gmail filters, `zele cal *`, full profile) fail on IMAP accounts with a clear error. Check `zele whoami` output for account type before using them.
49
49
  6. **Headless Google login** requires a tmux wrapper because `zele login` is interactive. See the README "Remote / headless login" section for the exact pattern.
50
+ 7. **Waiting for emails** with `zele mail watch`. It polls for new emails matching a filter and exits as soon as one arrives. Use this to wait for replies, verification codes, or any expected email:
51
+ ```bash
52
+ # wait for a reply from alice (no timeout, blocks until match)
53
+ zele mail watch --filter "is:unread from:alice@example.com"
54
+
55
+ # wait for a verification code with a 5-minute timeout
56
+ zele mail watch --filter "is:unread subject:verification" --timeout 300
57
+
58
+ # send an email then wait for the reply
59
+ zele mail send --to bob@example.com --subject "Question" --body "Hey, can you check this?"
60
+ zele mail watch --filter "is:unread from:bob@example.com subject:Re:Question" --timeout 600
61
+ ```
62
+ If the matched email wasn't the expected one, call `zele mail watch` again with a more specific filter. Exit code 0 means a match was found, exit code 1 means timeout.