typeclaw 0.35.1 → 0.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.35.1",
3
+ "version": "0.36.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -42,6 +42,7 @@
42
42
  "postinstall": "bun run scripts/generate-schema.ts"
43
43
  },
44
44
  "dependencies": {
45
+ "@clack/core": "^1.2.0",
45
46
  "@clack/prompts": "^1.2.0",
46
47
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
48
  "@mariozechner/pi-tui": "^0.67.3",
@@ -574,14 +574,15 @@ function renderResearchReportDeliveryGuidance(platformInfo: PlatformInfo): strin
574
574
  `**Ship reports as a PDF by default.** ${platformInfo.displayName} accepts file`,
575
575
  'attachments. When the user asks for a report, document, brief, or "the report"',
576
576
  '— or a `researcher` subagent hands you a `research-<slug>.md` file path in its',
577
- '`<report>` block — convert that markdown to a PDF with the `typeclaw-markdown-pdf`',
577
+ '`<report>` block — convert that markdown to a PDF with the `typeclaw-render-pdf`',
578
578
  'skill and deliver it with `channel_send({ ..., attachments: [{ path, filename }] })`,',
579
579
  'with a one- or two-line summary as the message text. A `researcher` `<summary>`',
580
580
  'is a teaser, NOT the deliverable: the deliverable is the report file rendered to',
581
581
  'PDF. Never build the PDF with an ad-hoc library (jsPDF, pdfkit, a raw-text dump) —',
582
582
  'that yields unrendered markdown and mojibake; the skill is the only correct path.',
583
- "For CJK (Korean/Japanese/Chinese) reports, follow that skill's CJK font gate —",
584
- 'never ship a tofu-rendered PDF; ask before enabling the opt-in `cjkFonts`.',
583
+ "For CJK (Korean/Japanese/Chinese) reports, follow that skill's CJK guidance —",
584
+ 'never ship a tofu-rendered PDF; if the output has tofu boxes, ask before',
585
+ 'enabling the opt-in `cjkFonts` and restarting.',
585
586
  'A downloadable file is what a human wants for a multi-page report; do not paste',
586
587
  'the full markdown into chat, and do not attach the raw `.md` when asked for a',
587
588
  'report or PDF. Send inline plain text only if the caller explicitly asked for it,',
@@ -63,7 +63,7 @@ Do not narrate routine, low-risk tool calls. Just call the tool. Narrate only wh
63
63
 
64
64
  When the user asks for a *report*, *document*, *brief*, *PDF*, or asks you to *send/show/attach/export* a generated result — anything where the deliverable is a file a human would download, print, or forward — produce a polished file, not a wall of text pasted into chat and not a one-line summary that drops the substance. A summary (yours or a subagent's) is a pointer to the deliverable, never the deliverable itself; when the user asked for the report, ship the report.
65
65
 
66
- To turn Markdown into a PDF, use the bundled \`typeclaw-markdown-pdf\` skill — it is the only supported path and it renders Markdown properly (headings, lists, tables). **Never** hand-roll a PDF with an ad-hoc library (jsPDF, pdfkit, a canvas text dump, a headless-browser raw-text print): those produce unrendered raw \`##\`/\`**\` markup and mojibake for non-Latin text. CJK fonts are opt-in, so for Korean/Japanese/Chinese reports follow that skill's CJK gate — never ship a tofu-rendered PDF; ask before enabling opt-in CJK fonts. If a request is plainly satisfied by inline chat — a short answer, a snippet, a quick explanation — stay inline; this rule is for explicit document deliverables, not for every long reply.
66
+ To turn Markdown into a PDF, use the bundled \`typeclaw-render-pdf\` skill — it is the only supported path and it renders Markdown properly (headings, lists, tables). **Never** hand-roll a PDF with an ad-hoc library (jsPDF, pdfkit, a canvas text dump, a headless-browser raw-text print, Python ReportLab): those produce unrendered raw \`##\`/\`**\` markup and mojibake for non-Latin text. CJK fonts are opt-in, so for Korean/Japanese/Chinese reports follow that skill's CJK guidance — never ship a tofu-rendered PDF; if the output has tofu boxes, ask before enabling opt-in CJK fonts and restarting. If a request is plainly satisfied by inline chat — a short answer, a snippet, a quick explanation — stay inline; this rule is for explicit document deliverables, not for every long reply.
67
67
 
68
68
  ## Long-running and interactive shell work
69
69
 
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path'
2
+
3
+ import { definePlugin } from '@/plugin'
4
+
5
+ // In-container path of the bundled render script, relative to the agent root
6
+ // (the bwrap jail ro-binds /agent, so a low-trust sandboxed `bun run` can read
7
+ // it here). typeclaw always installs at node_modules/typeclaw and ships src/ to
8
+ // npm, so this path is stable across dev and prod. The skill references the same
9
+ // path — keep them in lockstep.
10
+ export const RENDER_SCRIPT_AGENT_RELATIVE_PATH = 'node_modules/typeclaw/src/bundled-plugins/doc-render/render.ts'
11
+
12
+ export function renderScriptPath(): string {
13
+ return join(import.meta.dir, 'render.ts')
14
+ }
15
+
16
+ export default definePlugin({
17
+ plugin: async () => ({
18
+ skillsDirs: [join(import.meta.dir, 'skills')],
19
+ }),
20
+ })
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bun
2
+ // Bundled `bun run`-able render script for the doc-render plugin: the agent runs
3
+ // `bun run <this> <main.typ> <out.pdf>`. Lives as a script (not a registered
4
+ // tool) so it costs zero always-on system-prompt context, and the agent
5
+ // dynamic-imports the Typst compiler from its own /agent/node_modules — installed
6
+ // on first use, never baked into the image. Its on-disk path is published to a
7
+ // /tmp hint at boot (see index.ts) so the skill can resolve it.
8
+ //
9
+ // TODO(doc-render): PDF via Typst only. docx/xlsx/pptx are tracked separately —
10
+ // each an npm renderer installed via the same dynamic `bun add` + dynamic-import
11
+ // pattern. Office *conversion* (md->docx, docx->pdf) needs a system binary
12
+ // (LibreOffice/Pandoc), not an npm package, so it is out of scope here. See
13
+ // typeclaw/typeclaw#761.
14
+
15
+ // Keep in sync with the `bun add` line in skills/typeclaw-render-pdf/SKILL.md.
16
+ // 0.7.0 embeds Typst 0.14.2; bumping is a deliberate, re-validated edit.
17
+ export const COMPILER_PACKAGE = '@myriaddreamin/typst-ts-node-compiler'
18
+ export const COMPILER_VERSION = '0.7.0'
19
+
20
+ // NodeCompiler does not auto-discover system font dirs the way the Typst CLI
21
+ // does; without these, CJK glyphs render as .notdef tofu. Filtered by existence
22
+ // so a missing dir on a dev/host run is skipped, not fatal.
23
+ export const FONT_PATHS = ['/usr/share/fonts', '/usr/local/share/fonts', '/Library/Fonts', '/System/Library/Fonts']
24
+
25
+ export function missingCompilerGuidance(): string {
26
+ return [
27
+ `doc-render: ${COMPILER_PACKAGE} is not installed.`,
28
+ '',
29
+ 'One-time PDF toolchain install. Run from the agent root (writes node_modules',
30
+ '+ package.json + bun.lock, which survive restarts), then re-run this render:',
31
+ '',
32
+ ` bun add ${COMPILER_PACKAGE}@${COMPILER_VERSION}`,
33
+ '',
34
+ 'Do NOT fall back to jsPDF, pdfkit, a canvas text dump, a headless-browser',
35
+ 'raw-text print, or Python ReportLab — those skip Markdown rendering and ship',
36
+ 'no CJK font. This Typst path is the only supported one.',
37
+ ].join('\n')
38
+ }
39
+
40
+ // The per-tool sandbox falls back to a degraded `--tmpfs /proc` on a host with
41
+ // no usable user namespaces, where Bun's loader can't read /proc/self/{fd,maps}
42
+ // and aborts with ENOTDIR. Rare since the proc-bind retry fix (typeclaw 0.35.1),
43
+ // but still possible on exotic runtimes — and unfixable by switching libraries.
44
+ function isNotDirError(error: unknown): boolean {
45
+ return (
46
+ typeof error === 'object' && error !== null && 'code' in error && (error as { code?: unknown }).code === 'ENOTDIR'
47
+ )
48
+ }
49
+
50
+ function notDirGuidance(): string {
51
+ return [
52
+ 'doc-render: the renderer aborted with a Bun "NotDir" / ENOTDIR error.',
53
+ '',
54
+ 'This is the sandbox /proc degraded mode, not your markup and not a missing',
55
+ 'font: on a host with no usable user namespaces the per-tool sandbox falls',
56
+ "back to a tmpfs /proc where bun can't run packages. Retry once; if it",
57
+ 'persists, report it as a sandbox/environment issue. A different PDF library',
58
+ 'will not fix a /proc problem.',
59
+ ].join('\n')
60
+ }
61
+
62
+ type NodeCompilerModule = {
63
+ NodeCompiler: {
64
+ create(options: { workspace: string; fontArgs?: { fontPaths: string[] }[] }): {
65
+ pdf(options: { mainFilePath: string }): Uint8Array
66
+ }
67
+ }
68
+ }
69
+
70
+ // Resolve from the CALLER's cwd, not this script's location: the agent installs
71
+ // the compiler into its own /agent/node_modules and runs `bun run <this>` from
72
+ // the agent root, but bare `import(pkg)` resolves relative to this file (under
73
+ // node_modules/typeclaw/...), which need not see the agent's deps. Resolving
74
+ // against cwd, then importing the absolute path, makes the lookup independent of
75
+ // where the bundled script physically lives.
76
+ export async function loadCompilerModule(cwd: string = process.cwd()): Promise<NodeCompilerModule> {
77
+ const resolved = Bun.resolveSync(COMPILER_PACKAGE, cwd)
78
+ return (await import(resolved)) as NodeCompilerModule
79
+ }
80
+
81
+ export async function renderPdf(mainFile: string, outFile: string): Promise<number> {
82
+ const { existsSync, writeFileSync } = await import('node:fs')
83
+
84
+ const fontPaths = FONT_PATHS.filter((p) => existsSync(p))
85
+ const mod = await loadCompilerModule()
86
+ const compiler = mod.NodeCompiler.create({
87
+ workspace: '.',
88
+ ...(fontPaths.length > 0 ? { fontArgs: [{ fontPaths }] } : {}),
89
+ })
90
+ const pdf = compiler.pdf({ mainFilePath: mainFile })
91
+ writeFileSync(outFile, Buffer.from(pdf))
92
+ return pdf.length
93
+ }
94
+
95
+ // Bun phrasings for an unresolved import. Broad on purpose: a false positive only
96
+ // shows the install hint on an unrelated error, and the package name in the
97
+ // message keeps it specific in practice.
98
+ export function isModuleNotFound(message: string): boolean {
99
+ return (
100
+ message.includes('Cannot find package') ||
101
+ message.includes('Cannot find module') ||
102
+ message.includes('Module not found') ||
103
+ message.includes(COMPILER_PACKAGE)
104
+ )
105
+ }
106
+
107
+ const EXIT_RENDER_FAILED = 1
108
+ const EXIT_BAD_USAGE = 2
109
+ const EXIT_COMPILER_MISSING = 3
110
+
111
+ async function main(): Promise<void> {
112
+ const [, , mainFile, outFile] = process.argv
113
+ if (!mainFile || !outFile) {
114
+ process.stderr.write('usage: bun run render.ts <main.typ> <out.pdf>\n')
115
+ process.exit(EXIT_BAD_USAGE)
116
+ }
117
+
118
+ let bytes: number
119
+ try {
120
+ bytes = await renderPdf(mainFile, outFile)
121
+ } catch (error) {
122
+ if (isNotDirError(error)) {
123
+ process.stderr.write(`${notDirGuidance()}\n`)
124
+ process.exit(EXIT_RENDER_FAILED)
125
+ }
126
+ const message = error instanceof Error ? error.message : String(error)
127
+ if (isModuleNotFound(message)) {
128
+ process.stderr.write(`${missingCompilerGuidance()}\n`)
129
+ process.exit(EXIT_COMPILER_MISSING)
130
+ }
131
+ process.stderr.write(`doc-render: render failed: ${message}\n`)
132
+ process.exit(EXIT_RENDER_FAILED)
133
+ }
134
+
135
+ process.stdout.write(`wrote ${outFile} (${bytes} bytes)\n`)
136
+ }
137
+
138
+ if (import.meta.main) {
139
+ await main()
140
+ }
@@ -0,0 +1,314 @@
1
+ ---
2
+ name: typeclaw-render-pdf
3
+ description: "The ONLY supported way to render Markdown into a polished, professional PDF (and optionally attach it to a channel). Load this whenever you need to deliver a document as a PDF rather than raw markdown — reports, summaries, briefs, meeting notes, docs, render report, export document, anything a human would want to download, print, or forward, including a researcher's report file shipped as a Slack/Discord attachment. Triggers: 'make a PDF', 'export to PDF', 'markdown to PDF', 'PDF report', 'render report', 'export document', 'the report', 'attach the report', 'send me a PDF', 'as a PDF', 'turn this into a document', a researcher/subagent result you want to ship as a file, 'PDF로', 'PDF로 만들어', 'PDF로 변환', 'PDF 첨부', '리포트', '보고서'. Provided by the bundled `doc-render` plugin: a small Typst toolchain is installed on first use via `bun add` (no PDF library is baked into the image), and a bundled render script does the compile. Handles CJK/Korean/Japanese/Chinese: CJK fonts are opt-in, so if the output has tofu (□□□) boxes it tells you to enable `docker.file.cjkFonts` and restart, then regenerates — it never auto-downloads a font. Also load before saying you cannot produce PDFs — you can. NEVER build a PDF with jsPDF, pdfkit, a canvas text dump, a headless-browser raw-text print, or Python ReportLab — those produce unrendered markdown and broken CJK; this skill is the only correct path. Covers the one-time install, the styled wrapper, the render command, and how to attach the PDF to Slack/Discord/Telegram/KakaoTalk. For operating on EXISTING PDFs (merge, split, extract text, fill forms), this is not the skill — use pypdf/qpdf instead; doc-render produces documents, it does not read them."
4
+ ---
5
+
6
+ # typeclaw-render-pdf
7
+
8
+ You can produce professional PDFs from Markdown. The bundled `doc-render` plugin
9
+ ships the render script; the only thing installed on demand is the
10
+ [Typst](https://typst.app) compiler — a single npm package the agent `bun add`s
11
+ into its own `node_modules` the **first time** you need a PDF, then reuses. No
12
+ Pandoc, no LaTeX, no headless browser, and no PDF toolchain baked into the image.
13
+
14
+ The flow is: **(1)** install the compiler once (`bun add` — it lands in the
15
+ agent's `node_modules`, surviving restarts), **(2)** write a styled `.typ`
16
+ wrapper that reads your Markdown, **(3)** run the bundled render script. If a
17
+ channel asked for the PDF, attach the result with `channel_send`.
18
+
19
+ You do **not** need to learn Typst markup. The
20
+ [`cmarker`](https://typst.app/universe/package/cmarker/) package renders your
21
+ CommonMark (headings, lists, tables, code, blockquotes, footnotes, links,
22
+ images). The wrapper only sets _styling_ — fonts, margins, headings, page numbers
23
+ — so the output looks deliberate, not like a default-template export.
24
+
25
+ > **This is the only supported way to make a PDF from Markdown in TypeClaw.**
26
+ > Do **not** reach for `jsPDF`, `pdfkit`, a `<canvas>` text dump, a
27
+ > headless-browser "print raw text" path, or Python `ReportLab`. Those skip
28
+ > Markdown rendering (you get literal `##` and `**` in the output) and ship no
29
+ > CJK font, so Korean/Japanese/Chinese come out as mojibake. The Typst path below
30
+ > renders the Markdown properly. If you catch yourself about to `bun add` a PDF
31
+ > library other than the Typst compiler named here, stop.
32
+
33
+ ## When to use this
34
+
35
+ - A research report, brief, or summary the user wants as a downloadable file.
36
+ - A subagent (e.g. the `researcher`) handed you a `research-<slug>.md` to ship as a PDF.
37
+ - Any channel message asking for "a PDF" / "the report attached" / "PDF로 보내줘".
38
+
39
+ When plain markdown in chat is fine, **don't** make a PDF. This is for when a
40
+ _file_ is the deliverable.
41
+
42
+ ## Step 0 — install the Typst compiler (once per container)
43
+
44
+ The PDF compiler is not baked into the image — install it on first use. It is a
45
+ single version-pinned npm package (npm pulls only this platform's prebuilt
46
+ binary — Linux x64/arm64, glibc or musl). It writes to the agent's
47
+ `node_modules` + `package.json` + `bun.lock`, all of which survive restarts, so
48
+ this only runs once per container life:
49
+
50
+ ```sh
51
+ # Idempotent: bun add is a no-op if it's already the installed version.
52
+ bun add @myriaddreamin/typst-ts-node-compiler@0.7.0
53
+ ```
54
+
55
+ The `@0.7.0` pin embeds Typst 0.14.2 and keeps the toolchain reproducible — a
56
+ future npm release can't silently change the embedded Typst version or the API
57
+ the render script depends on. If you forget this step, the render script in
58
+ Step 3 stops with the exact `bun add` line to run, so you can also just try the
59
+ render and follow its guidance.
60
+
61
+ > **Where it goes:** the agent's own `node_modules` — the canonical home for
62
+ > executable dependencies, gitignored, not user-facing. Do **not** create a
63
+ > `package.json` or `node_modules` under `workspace/` for this; let `bun add`
64
+ > manage it at the agent root like any other dependency.
65
+
66
+ ## Step 1 — have the markdown ready
67
+
68
+ Use an existing markdown file (yours or a subagent's), or `write` your content to
69
+ a markdown file. Standard CommonMark plus tables and footnotes all work. Put the
70
+ `.md` and the `.typ` (Step 2) in the same directory so the wrapper's relative
71
+ `read("...")` resolves — any agent-writable directory works (`workspace/`,
72
+ `public/`, `mounts/`, or wherever the source `.md` already lives, e.g. a
73
+ researcher's report under `public/`). There is no required directory; keep the
74
+ two files together and run the render from there.
75
+
76
+ ## Step 2 — write the styled wrapper
77
+
78
+ `write` a `.typ` next to your markdown, pointing `read("...")` at the markdown
79
+ filename. The wrapper below is a clean, professional **starting point** — not a
80
+ fixed template. Design the document to fit its content and audience: a dense
81
+ technical brief wants tighter margins than an airy exec summary; a launch report
82
+ might open with a cover banner. Adjust fonts, spacing, and structure freely, and
83
+ reach for the **Rich elements** palette below when plain prose isn't enough.
84
+
85
+ ```typst
86
+ #set document(title: "Report")
87
+ #set page(
88
+ paper: "a4",
89
+ margin: (x: 2.5cm, y: 2.75cm),
90
+ numbering: "1",
91
+ footer: context align(center, text(size: 9pt, fill: luma(120))[
92
+ #counter(page).display("1 / 1", both: true)
93
+ ]),
94
+ )
95
+ #set text(font: ("Libertinus Serif", "New Computer Modern", "Noto Serif CJK KR"), size: 11pt, lang: "en")
96
+ #set par(justify: true, leading: 0.68em, spacing: 1.1em)
97
+
98
+ #show heading: set text(weight: "semibold")
99
+ #show heading.where(level: 1): it => block(width: 100%, above: 1.4em, below: 0.9em)[
100
+ #text(size: 1.5em, it.body)
101
+ #v(-0.4em)
102
+ #line(length: 100%, stroke: 0.5pt + luma(200))
103
+ ]
104
+ #show link: it => text(fill: rgb("#1a56db"), underline(it))
105
+ #show quote.where(block: true): it => block(
106
+ inset: (left: 1em), stroke: (left: 2pt + luma(200)),
107
+ text(style: "italic", fill: luma(80), it.body),
108
+ )
109
+ #show raw.where(block: true): it => block(
110
+ fill: luma(245), inset: 8pt, radius: 4pt, width: 100%, text(size: 9pt, it),
111
+ )
112
+ #show table: set table(stroke: 0.5pt + luma(200))
113
+
114
+ #import "@preview/cmarker:0.1.8"
115
+ #cmarker.render(read("report.md"), h1-level: 1, blockquote: quote.with(block: true))
116
+ ```
117
+
118
+ Notes:
119
+
120
+ - `read("report.md")` is **relative to the render's working directory**, so Step 3
121
+ `cd`s into the directory holding the `.typ` and `.md` before running. Keep them
122
+ together.
123
+ - Fonts `Libertinus Serif` / `New Computer Modern` are bundled with Typst (no font
124
+ install) and carry the Latin text. `"Noto Serif CJK KR"` is appended as the
125
+ fallback so Korean/CJK glyphs resolve per-glyph wherever the Latin fonts have no
126
+ glyph, leaving Latin runs untouched. It is only present when the container's
127
+ `cjkFonts` toggle is on — see "## Handling CJK content".
128
+ - `cmarker` fetches from the Typst package registry on first compile (the same
129
+ network the `bun add` step needs). It caches under the render's `$HOME`, which
130
+ in a sandboxed (channel/guest) session is per-session scratch — so a later
131
+ session may re-fetch it. That's a one-time network hit, not an error.
132
+
133
+ ## Step 3 — render
134
+
135
+ The render script is **bundled with the plugin** — you do not write it. It lives
136
+ at `/agent/node_modules/typeclaw/src/bundled-plugins/doc-render/render.ts`.
137
+
138
+ **`cd` into the directory holding your `.typ` and `.md` first** — your shell
139
+ starts at the agent root, and the wrapper's `read("report.md")` resolves relative
140
+ to the render's working directory, so you must run it from there:
141
+
142
+ ```sh
143
+ cd /agent/workspace # or wherever your .typ + .md live (public/, mounts/, …)
144
+ bun run /agent/node_modules/typeclaw/src/bundled-plugins/doc-render/render.ts report.typ report.pdf
145
+ ```
146
+
147
+ On success it prints `wrote report.pdf (<N> bytes)` and `report.pdf` exists in
148
+ that directory.
149
+
150
+ If it stops with **`@myriaddreamin/typst-ts-node-compiler is not installed`**
151
+ (exit 3), run the Step 0 `bun add` and re-run. If it stops with a **`NotDir` /
152
+ ENOTDIR** error, that is the sandbox `/proc` degraded mode, not your markup and
153
+ not a font — retry once; if it persists, report it as a sandbox/environment
154
+ issue and do **not** switch to another PDF library (it won't help). Any other
155
+ error is a real Typst compile error (usually raw HTML or an unsupported markdown
156
+ extension) and names the offending line — simplify that part and re-run.
157
+
158
+ ## Handling CJK content
159
+
160
+ CJK fonts are **opt-in** (the `docker.file.cjkFonts` toggle). When they are off,
161
+ Typst still renders — it just substitutes `.notdef` tofu (□) boxes for every
162
+ Korean/Japanese/Chinese glyph. **Do not** download, vendor, or `curl` a font to
163
+ work around this, and **do not** silently deliver a tofu PDF.
164
+
165
+ You don't need a pre-render gate: render first, then verify. If the source
166
+ markdown contains CJK and the resulting PDF shows tofu boxes (or you know CJK
167
+ fonts aren't enabled on this container), tell the user honestly and offer the
168
+ fix:
169
+
170
+ > This report has Korean/Japanese/Chinese text but the container has no CJK font
171
+ > — they're opt-in, so the PDF comes out as tofu boxes. Want me to set
172
+ > `docker.file.cjkFonts: true` in `typeclaw.json`? It's a boot setting, so after
173
+ > I edit it you'll run `typeclaw restart` from the host project directory, and
174
+ > then I'll regenerate the PDF.
175
+
176
+ Only after the user agrees: edit `typeclaw.json` to set `docker.file.cjkFonts:
177
+ true` (use the `typeclaw-config` skill), ask them to `typeclaw restart`, and
178
+ regenerate the PDF after the restarted container comes back. If the markdown has
179
+ no CJK, this section doesn't apply.
180
+
181
+ ## Rich elements (optional)
182
+
183
+ When plain markdown isn't enough — a cover banner, callout boxes, multi-column
184
+ sections, captioned figures — you don't switch to HTML (Typst doesn't render
185
+ HTML). Instead, drop **raw Typst** into the markdown via `<!--raw-typst ... -->`
186
+ comments. `cmarker` evaluates them as Typst (the `raw-typst: true` option is the
187
+ default). The rest of the document stays plain markdown.
188
+
189
+ Each snippet below is self-contained — paste it into your `.md` where you want the
190
+ element. They use Typst built-ins only (no extra packages).
191
+
192
+ **Cover banner** (top of a report):
193
+
194
+ ```markdown
195
+ <!--raw-typst
196
+ #block(width: 100%, fill: rgb("#0f172a"), inset: 18pt, radius: 6pt)[
197
+ #text(fill: white, size: 1.6em, weight: "bold")[Quarterly Business Review]
198
+ #v(2pt)
199
+ #text(fill: rgb("#94a3b8"), size: 0.95em)[Acme Robotics · Q2 2026 · Confidential]
200
+ ]
201
+ #v(1em)
202
+ -->
203
+ ```
204
+
205
+ **Callout boxes** (info / warning — change the two colors for other variants):
206
+
207
+ ```markdown
208
+ <!--raw-typst
209
+ #block(fill: rgb("#eff6ff"), stroke: (left: 3pt + rgb("#3b82f6")), inset: 12pt, radius: 4pt, width: 100%)[
210
+ #text(weight: "bold")[Note.] Revenue grew 31% YoY.
211
+ ]
212
+ #v(0.6em)
213
+ #block(fill: rgb("#fef2f2"), stroke: (left: 3pt + rgb("#ef4444")), inset: 12pt, radius: 4pt, width: 100%)[
214
+ #text(weight: "bold")[Risk.] A single supplier covers 40% of NPUs.
215
+ ]
216
+ -->
217
+ ```
218
+
219
+ **Two-column section** (use `#colbreak()` to split):
220
+
221
+ ```markdown
222
+ <!--raw-typst
223
+ #columns(2, gutter: 1.4em)[
224
+ #text(weight: "bold")[Strengths]
225
+ - Net retention 124%
226
+ - Margin +240bps
227
+ #colbreak()
228
+ #text(weight: "bold")[Risks]
229
+ - Supplier concentration
230
+ - Partial FX hedging
231
+ ]
232
+ -->
233
+ ```
234
+
235
+ **Figure with caption** (swap the `rect(...)` for `image("chart.png")` to embed an
236
+ image written next to the markdown):
237
+
238
+ ```markdown
239
+ <!--raw-typst
240
+ #figure(
241
+ rect(width: 60%, height: 48pt, fill: luma(245), stroke: 0.5pt + luma(180)),
242
+ caption: [Revenue trend, Q1–Q2 2026.],
243
+ )
244
+ -->
245
+ ```
246
+
247
+ Keep it tasteful — a banner, a couple of callouts, and one good figure read as
248
+ deliberate; a wall of colored boxes reads as noise.
249
+
250
+ ## Rendering an _existing_ web page or HTML to PDF
251
+
252
+ This skill renders **markdown you author**. To capture an **existing web page or
253
+ a live URL** as a PDF — something Typst cannot do — use the already-installed
254
+ `agent-browser` (Chrome): `agent-browser --allow-file-access open
255
+ file:///agent/workspace/page.html` (or a URL), then `agent-browser pdf
256
+ /agent/workspace/out.pdf`. Its output is fixed US-Letter with default margins, so
257
+ it's the right tool for _archiving web content_, not for authoring styled
258
+ reports. For authored documents, stay on the Typst path above.
259
+
260
+ ## Step 4 — deliver
261
+
262
+ - **Channel asked for the PDF** — attach it:
263
+
264
+ ```
265
+ channel_send(text: "Here's the report.", attachments: [{ path: "/agent/workspace/report.pdf", filename: "Edge-AI-Brief.pdf" }])
266
+ ```
267
+
268
+ Use a human-friendly `filename` and an absolute path. Slack, Discord, Telegram,
269
+ and KakaoTalk upload the file; the GitHub adapter has no attachment support, so
270
+ there post a link or paste the markdown.
271
+
272
+ - **Replying in a thread** — use `channel_reply` with the same `attachments` shape.
273
+
274
+ - **No channel** (TUI session) — just report the path: `report.pdf`.
275
+
276
+ ## If you got the markdown from a subagent
277
+
278
+ The `researcher` subagent writes its report to `research-<slug>.md` and returns a
279
+ `<report>` block naming the file. Point the wrapper's `read(...)` at that file,
280
+ render in that file's directory, and attach. You do the PDF step — the
281
+ researcher's `bash` is read-only and it only emits markdown by design.
282
+
283
+ ## Customizing this skill
284
+
285
+ This is a bundled default. Want a different house style, a cover page with a
286
+ logo, or a different converter? Copy this file to
287
+ `.agents/skills/<your-name>/SKILL.md` (use a **different** `name`; bundled skills
288
+ win name collisions) and edit it there.
289
+
290
+ ## Known limitations
291
+
292
+ `cmarker` covers CommonMark well, but a few markdown features don't render as you
293
+ might expect:
294
+
295
+ - **Task-list checkboxes** (`- [ ]` / `- [x]`) render as literal `[ ]` text, not
296
+ checkboxes. Use a plain bullet list or a status column in a table instead.
297
+ - **Bold/italic directly adjacent to CJK + parenthetical Latin** (e.g.
298
+ `**로컬 우선(local-first)**`) may not be recognized as emphasis — CommonMark's
299
+ flanking rules treat that boundary as non-emphasis. Put a space inside, or bold a
300
+ pure run of text.
301
+ - **Raw HTML** in the markdown is mostly ignored. Express structure in markdown
302
+ (tables, lists) rather than HTML.
303
+
304
+ ## Don'ts
305
+
306
+ - **Don't** hand-write Typst markup for the body. Let `cmarker` render the
307
+ markdown; only style via `#set` / `#show` rules in the wrapper (and the optional
308
+ raw-typst rich elements).
309
+ - **Don't** build a `package.json` / `node_modules` / a render script under
310
+ `workspace/`. The compiler installs at the agent root via `bun add`; the render
311
+ script is bundled with the plugin (at
312
+ `/agent/node_modules/typeclaw/src/bundled-plugins/doc-render/render.ts`).
313
+ - **Don't** attach a PDF to a GitHub channel — that adapter rejects attachments.
314
+ Link or inline instead.
@@ -12,6 +12,7 @@ import {
12
12
  recordGitRemoteTaintIfAny,
13
13
  } from './policies/git-exfil'
14
14
  import { GUARD_OUTBOUND_SECRET_SEVERITY, checkOutboundSecretGuard } from './policies/outbound-secret-scan'
15
+ import { GUARD_PLUGIN_ADDITION_SEVERITY, checkPluginAdditionGuard } from './policies/plugin-addition'
15
16
  import { checkPrivateSurfaceReadGuard } from './policies/private-surface-read'
16
17
  import { applyPromptInjectionDefense } from './policies/prompt-injection'
17
18
  import { clearSessionTaints } from './policies/remote-taint-state'
@@ -68,6 +69,8 @@ const BYPASS_ROLE_HINT = {
68
69
  'owner and trusted have it by default (medium tier); member and guest do not. The privilege-escalation defense for trusted now depends on operator review of `typeclaw.json` backup commits — `roles` is restart-required, so the operator has wall-clock time to revert before the new role table takes effect. Operators who do not review can re-tighten by replacing `roles.trusted.permissions[]` with an explicit list that omits `security.bypass.medium`.',
69
70
  [SECURITY_PERMISSIONS.bypassCronPromotion]:
70
71
  'owner and trusted have it by default (medium tier); member and guest do not. Same shape as rolePromotion but deferred: a new cron job (or a changed scheduledByRole) fires at schedule-time as the stamped role. The operator-review window between write and execution is the trusted-tier defense.',
72
+ [SECURITY_PERMISSIONS.bypassPluginAddition]:
73
+ 'owner and trusted have it by default (medium tier); member and guest do not. Same shape as cronPromotion but for host-side install: a new (or version-bumped) plugins[] entry is materialized into package.json and installed by the next host `typeclaw start`, running npm lifecycle scripts as the operator. The operator-review window between the typeclaw.json write and the next start is the trusted-tier defense.',
71
74
  } as const satisfies Record<PerGuardSecurityPermission, string>
72
75
 
73
76
  function withPermissionHint(
@@ -121,6 +124,18 @@ export default definePlugin({
121
124
  )
122
125
  if (cronPromotionResult) return cronPromotionResult
123
126
 
127
+ const pluginAdditionResult = canBypass(
128
+ GUARD_PLUGIN_ADDITION_SEVERITY,
129
+ SECURITY_PERMISSIONS.bypassPluginAddition,
130
+ )
131
+ ? undefined
132
+ : withPermissionHint(
133
+ await checkPluginAdditionGuard({ tool: event.tool, args: event.args, agentDir: ctx.agentDir }),
134
+ SECURITY_PERMISSIONS.bypassPluginAddition,
135
+ GUARD_PLUGIN_ADDITION_SEVERITY,
136
+ )
137
+ if (pluginAdditionResult) return pluginAdditionResult
138
+
124
139
  // Taint-recording runs FIRST, independently of the gitExfil guard.
125
140
  // The gitRemoteTainted defense depends on it. We pass through
126
141
  // `permittedBypass` for actors who can skip gitExfil (via either the
@@ -11,6 +11,7 @@ export const SECURITY_PERMISSIONS = {
11
11
  bypassGitRemoteTainted: 'security.bypass.gitRemoteTainted',
12
12
  bypassRolePromotion: 'security.bypass.rolePromotion',
13
13
  bypassCronPromotion: 'security.bypass.cronPromotion',
14
+ bypassPluginAddition: 'security.bypass.pluginAddition',
14
15
  // Severity-tier bypasses. Tiers classify guards on a two-axis policy:
15
16
  // high — bypass sends data to a third-party audience outside the
16
17
  // operator's control loop (channel readers, remote git host).