typeclaw 0.37.4 → 0.37.6

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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/agent/doctor.ts +6 -1
  3. package/src/agent/plugin-tools.ts +23 -1
  4. package/src/agent/subagents.ts +146 -14
  5. package/src/agent/todo/scope.ts +4 -2
  6. package/src/agent/tools/channel-reply.ts +7 -9
  7. package/src/bundled-plugins/doc-render/index.ts +10 -0
  8. package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
  9. package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
  10. package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
  11. package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
  12. package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
  13. package/src/bundled-plugins/memory/index.ts +9 -6
  14. package/src/bundled-plugins/memory/load-memory.ts +16 -2
  15. package/src/bundled-plugins/memory/slug.ts +19 -0
  16. package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
  17. package/src/channels/adapters/github/inbound.ts +68 -43
  18. package/src/channels/adapters/github/index.ts +57 -9
  19. package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
  20. package/src/channels/adapters/kakaotalk.ts +5 -1
  21. package/src/channels/adapters/mention-hints.ts +17 -0
  22. package/src/channels/manager.ts +77 -1
  23. package/src/channels/router.ts +181 -12
  24. package/src/cli/compose.ts +11 -2
  25. package/src/cli/dreams.ts +2 -2
  26. package/src/cli/inspect.ts +2 -2
  27. package/src/cli/logs.ts +2 -2
  28. package/src/cli/mount.ts +5 -5
  29. package/src/cli/require-agent-dir.ts +31 -0
  30. package/src/cli/restart.ts +2 -1
  31. package/src/cli/shell.ts +2 -2
  32. package/src/cli/start.ts +2 -1
  33. package/src/cli/stop.ts +2 -2
  34. package/src/cli/tui.ts +20 -6
  35. package/src/cli/ui.ts +13 -0
  36. package/src/compose/restart.ts +1 -1
  37. package/src/compose/start.ts +4 -2
  38. package/src/config/config.ts +200 -9
  39. package/src/container/shared.ts +18 -0
  40. package/src/container/start.ts +1 -1
  41. package/src/cron/consumer.ts +3 -3
  42. package/src/hostd/client.ts +48 -52
  43. package/src/hostd/daemon.ts +82 -39
  44. package/src/hostd/paths.ts +22 -2
  45. package/src/hostd/spawn.ts +7 -0
  46. package/src/init/dockerfile.ts +11 -8
  47. package/src/init/kakaotalk-auth.ts +2 -2
  48. package/src/init/packagejson.ts +2 -2
  49. package/src/plugin/loader.ts +7 -4
  50. package/src/sandbox/session-tmp.ts +6 -1
  51. package/src/secrets/export-claude-credentials-file.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.37.4",
3
+ "version": "0.37.6",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -1,4 +1,9 @@
1
- import { isAbsolute, normalize } from 'node:path'
1
+ import { posix } from 'node:path'
2
+
3
+ // changedPaths are a wire format: agentDir-relative POSIX paths the container
4
+ // emits and the host re-validates. Resolved with `path.posix` so a win32 test
5
+ // runner keeps `/`-separators instead of rewriting `memory/x.md` to `memory\x.md`.
6
+ const { isAbsolute, normalize } = posix
2
7
 
3
8
  import type {
4
9
  PluginCheckResult,
@@ -576,6 +576,8 @@ async function applyBashSandbox(
576
576
  const { dirs, files } = resolveHiddenPaths(permissions, origin, agentDir)
577
577
  if (dirs.length === 0 && files.length === 0) return
578
578
 
579
+ const sandboxEnvOverlay = buildRoleScopedConfigEnv(agentDir, dirs, envOverlay)
580
+
579
581
  await ensureBwrapAvailable()
580
582
  // Per-session /tmp: bind this session's scratch dir over the default
581
583
  // --tmpfs /tmp so writes survive across the role's sandboxed bash calls AND
@@ -671,11 +673,31 @@ async function applyBashSandbox(
671
673
  cwd: agentDir,
672
674
  proc,
673
675
  procSelfExe: resolveProcSelfExe(),
674
- ...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
676
+ ...(sandboxEnvOverlay !== undefined ? { env: { set: sandboxEnvOverlay } } : {}),
675
677
  })
676
678
  mutableArgs.command = commandString
677
679
  }
678
680
 
681
+ function buildRoleScopedConfigEnv(
682
+ agentDir: string,
683
+ hiddenDirs: string[],
684
+ envOverlay: BashEnvOverlay | undefined,
685
+ ): BashEnvOverlay | undefined {
686
+ // Low-trust roles have workspace/ masked. Do not let container-global config
687
+ // env vars point CLIs back at that private surface: apps that honor XDG should
688
+ // still run, but their config must land in the sandbox's per-session /tmp.
689
+ // Trusted/owner never get here (no hidden dirs), so their Dockerfile-level
690
+ // persistent GWS_CONFIG_HOME remains /agent/workspace/.config/gws.
691
+ const workspaceHidden = hiddenDirs.includes(join(agentDir, 'workspace'))
692
+ if (!workspaceHidden) return envOverlay
693
+
694
+ return {
695
+ ...envOverlay,
696
+ XDG_CONFIG_HOME: '/tmp/.config',
697
+ GWS_CONFIG_HOME: '/tmp/.config/gws',
698
+ }
699
+ }
700
+
679
701
  function subtractMaskedProtected(
680
702
  zones: { root: string; protected: { dirs: string[]; files: string[] } },
681
703
  masked: { dirs: string[]; files: string[] },
@@ -241,6 +241,11 @@ export type InvokeSubagentOptions = {
241
241
  sessionId: string | undefined
242
242
  abort: () => Promise<void>
243
243
  }) => void
244
+ // Sink for the subagent's captured final message (a reviewer `<review>` block,
245
+ // a researcher `<report>` block, or the last free-form assistant text).
246
+ // `runSession` owns the capture so the required-block guard can re-prompt
247
+ // before the result settles; `startSubagent` passes this to receive the output.
248
+ onFinalMessageCaptured?: (msg: string) => void
244
249
  }
245
250
 
246
251
  export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
@@ -261,6 +266,8 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
261
266
  normalizeSubagentSession(await createSessionForSubagent(subagent, sessionOptions))
262
267
  let aborted = false
263
268
  let drainWatch: SubagentDrainWatch | undefined
269
+ const requiredBlockTag = REQUIRED_FINAL_BLOCK[name]
270
+ const capture = attachFinalMessageCapture(session, requiredBlockTag, options.onFinalMessageCaptured ?? (() => {}))
264
271
  if (options.onSessionCreated !== undefined) {
265
272
  options.onSessionCreated({
266
273
  session,
@@ -312,6 +319,28 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
312
319
  cancelled: () => aborted,
313
320
  })
314
321
  }
322
+ // Required-block guard (mirrors the channel empty-response guard): a subagent
323
+ // that owes a result block but ended without one gets a bounded re-prompt to
324
+ // emit it as text, then an honest fallback — never a silent stale-preamble
325
+ // result or a loud failure. Runs strictly after the drain settles so it is a
326
+ // final contract-repair pass, not another research phase; it deliberately
327
+ // does NOT re-run the drain.
328
+ if (requiredBlockTag !== undefined) {
329
+ for (
330
+ let attempt = 1;
331
+ !aborted && !capture.hasRequiredBlock() && attempt <= MAX_REQUIRED_BLOCK_RETRIES;
332
+ attempt++
333
+ ) {
334
+ console.warn(
335
+ `[subagent] ${name} required_block_retry attempt=${attempt}/${MAX_REQUIRED_BLOCK_RETRIES} tag=${requiredBlockTag}`,
336
+ )
337
+ await session.prompt(`${renderTurnTimeAnchor()}\n\n${renderRequiredBlockRetryNudge(requiredBlockTag)}`)
338
+ }
339
+ if (!aborted && !capture.hasRequiredBlock()) {
340
+ console.warn(`[subagent] ${name} required_block_fallback tag=${requiredBlockTag}`)
341
+ capture.setSyntheticFinalMessage(renderMissingRequiredBlockFallback(name, requiredBlockTag))
342
+ }
343
+ }
315
344
  if (hooks && sessionId !== undefined) {
316
345
  await hooks.runSessionIdle({
317
346
  sessionId,
@@ -432,6 +461,9 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
432
461
 
433
462
  const work = invokeSubagent(name, {
434
463
  ...options,
464
+ onFinalMessageCaptured: (msg) => {
465
+ finalMessage = msg
466
+ },
435
467
  onSessionCreated: (event) => {
436
468
  handleSettled = true
437
469
  abortSession = event.abort
@@ -439,9 +471,6 @@ export function startSubagent(name: string, options: StartSubagentOptions): Star
439
471
  if (options.onSession !== undefined) {
440
472
  options.onSession(event)
441
473
  }
442
- attachFinalMessageCapture(event.session, (msg) => {
443
- finalMessage = msg
444
- })
445
474
  },
446
475
  })
447
476
  .then(() => ({ ok: true as const, ...(finalMessage !== undefined ? { finalMessage } : {}) }))
@@ -497,22 +526,102 @@ function raceSubagentCompletion(
497
526
  })
498
527
  }
499
528
 
500
- // A complete <review>...</review> block. The reviewer's contract is that this
501
- // block IS its result; same-message preamble/trailing chatter or a later
502
- // summary turn must not become the captured final message. `[\s\S]` spans
503
- // newlines (the block is multi-line); non-greedy stops at the first close so an
504
- // incidental `<review>` literal in reviewed text cannot swallow real content.
505
- // Global so a message with several blocks yields the last (the revision).
506
- const REVIEW_BLOCK_RE = /<review>[\s\S]*?<\/review>/g
529
+ // The tags a subagent can use to wrap its structured result: the reviewer's
530
+ // `<review>`, the researcher's `<report>`. Fixed literals never user input —
531
+ // so the per-tag patterns below are injection-safe.
532
+ type FinalBlockTag = 'review' | 'report'
533
+
534
+ // A complete <TAG>...</TAG> block. The block IS the result: same-message
535
+ // preamble/trailing chatter or a later summary turn must not become the captured
536
+ // final message. `[\s\S]` spans newlines (the block is multi-line); non-greedy
537
+ // stops at the first close so an incidental `<TAG>` literal in the wrapped text
538
+ // cannot swallow real content. Global so a message with several blocks yields the
539
+ // last (the revision).
540
+ const FINAL_BLOCK_RE: Readonly<Record<FinalBlockTag, RegExp>> = {
541
+ review: /<review>[\s\S]*?<\/review>/g,
542
+ report: /<report>[\s\S]*?<\/report>/g,
543
+ }
507
544
 
508
- function lastReviewBlock(text: string): string | null {
509
- const matches = text.match(REVIEW_BLOCK_RE)
545
+ function lastTaggedBlock(text: string, tag: FinalBlockTag): string | null {
546
+ const matches = text.match(FINAL_BLOCK_RE[tag])
510
547
  return matches === null ? null : (matches[matches.length - 1] ?? null)
511
548
  }
512
549
 
513
- function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg: string) => void): void {
550
+ // Subagents whose result IS a REQUIRED tagged block — the parent must receive
551
+ // that block or a loud failure, never a stale earlier turn. The researcher's
552
+ // contract (src/bundled-plugins/researcher/researcher.ts) mandates a closing
553
+ // `<report>` block; when an upstream provider retry loop ends the run on
554
+ // unexecuted `write_report` tool calls, the researcher never emits it, and
555
+ // without this gate the capture would silently return its earlier `<analysis>`
556
+ // preamble as a "successful" result — the production regression this guards.
557
+ // Keyed by the stable bundled-subagent registry name. This is STRICTER than the
558
+ // reviewer's `<review>` (preferred, but falls back to free-form text): only the
559
+ // subagents listed here fail loud when their block is absent.
560
+ const REQUIRED_FINAL_BLOCK: Readonly<Record<string, FinalBlockTag>> = { researcher: 'report' }
561
+
562
+ // Bounded re-prompt budget for the required-block guard, mirroring the channel
563
+ // empty-response guard's MAX_EMPTY_TURN_RETRIES. A subagent that owes a result
564
+ // block but ended without one is nudged at most this many times before the
565
+ // honest fallback is installed.
566
+ const MAX_REQUIRED_BLOCK_RETRIES = 2
567
+
568
+ // The recovery nudge. It MUST forbid tools: the known failure mode is a provider
569
+ // retry loop on the report-writing tool call, so re-driving the tool path would
570
+ // just re-trigger the loop. Asking for the block as plain text is the repair.
571
+ function renderRequiredBlockRetryNudge(tag: FinalBlockTag): string {
572
+ return `---
573
+ **[SYSTEM MESSAGE — not from a human]**
574
+
575
+ Your previous turn ended without the required <${tag}> block. This is an automated runtime recovery signal, not a human message.
576
+
577
+ Emit the final <${tag}>...</${tag}> block NOW as plain assistant text. Do NOT call any tools — in particular do NOT call write_report. Do NOT continue researching or spawn more subagents.
578
+
579
+ If the report file was not successfully written, still emit the block: set <report_file>none</report_file>, <confidence>low</confidence> (explain the report artifact was not completed), and note in <open_questions> that the run should be retried.
580
+
581
+ Output exactly one <${tag}> block and nothing else.`
582
+ }
583
+
584
+ // The terminal graceful fallback when the nudges are exhausted. It fabricates NO
585
+ // findings — only a structured, low-confidence "could not complete" notice — so
586
+ // the parent gets a usable result instead of stale `<analysis>` or a hard error.
587
+ function renderMissingRequiredBlockFallback(name: string, tag: FinalBlockTag): string {
588
+ if (tag === 'report') {
589
+ return `<report>
590
+ <summary>
591
+ The ${name} subagent could not complete a research report in this run: it ended without emitting the required <report> block (a known cause is the report tool not completing). Do not treat any earlier analysis text as findings — rerun the researcher or gather the sources directly if the answer is still needed.
592
+ </summary>
593
+ <report_file>
594
+ none
595
+ </report_file>
596
+ <confidence>
597
+ low — no complete research report artifact was produced.
598
+ </confidence>
599
+ <open_questions>
600
+ The original research request remains unresolved; rerun the ${name} subagent or gather the sources directly.
601
+ </open_questions>
602
+ </report>`
603
+ }
604
+ return `<${tag}>\nThe ${name} subagent ended without emitting the required <${tag}> block and could not recover; rerun it or inspect the transcript.\n</${tag}>`
605
+ }
606
+
607
+ type SubagentCapture = {
608
+ // True once a required-block subagent (researcher) has emitted its `<report>`
609
+ // block; always false for subagents without a required block.
610
+ hasRequiredBlock: () => boolean
611
+ // Install a captured final message directly — used by the required-block guard
612
+ // to set an honest fallback when the block was never emitted, so the parent
613
+ // gets a structured result rather than stale preamble.
614
+ setSyntheticFinalMessage: (msg: string) => void
615
+ }
616
+
617
+ function attachFinalMessageCapture(
618
+ session: AgentSession,
619
+ requiredBlockTag: FinalBlockTag | undefined,
620
+ onFinalMessage: (msg: string) => void,
621
+ ): SubagentCapture {
514
622
  let lastAssistant: string | null = null
515
623
  let lastReview: string | null = null
624
+ let lastRequired: string | null = null
516
625
  try {
517
626
  session.subscribe((event: unknown) => {
518
627
  const ev = event as { type?: string; message?: { role?: string; content?: unknown } }
@@ -524,7 +633,23 @@ function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg:
524
633
  const text = extractFinalMessageText(ev.message?.content)
525
634
  if (text === null) return
526
635
  lastAssistant = text
527
- const review = lastReviewBlock(text)
636
+
637
+ // Required-block contract (researcher): the result IS the block. A turn
638
+ // with text but no block — the `<analysis>` preamble, a process narrative —
639
+ // must NOT become the captured result, so `hasRequiredBlock` stays false and
640
+ // the guard in runSession re-prompts rather than returning stale preamble.
641
+ if (requiredBlockTag !== undefined) {
642
+ const block = lastTaggedBlock(text, requiredBlockTag)
643
+ if (block !== null) {
644
+ lastRequired = block
645
+ onFinalMessage(lastRequired)
646
+ }
647
+ return
648
+ }
649
+
650
+ // Preferred-block contract (reviewer) / free-form: a `<review>` block wins
651
+ // when present; otherwise the last free-form assistant text is the result.
652
+ const review = lastTaggedBlock(text, 'review')
528
653
  if (review !== null) lastReview = review
529
654
  onFinalMessage(lastReview ?? lastAssistant)
530
655
  })
@@ -532,6 +657,13 @@ function attachFinalMessageCapture(session: AgentSession, onFinalMessage: (msg:
532
657
  // session.subscribe is a stable upstream API; defensive try is for test
533
658
  // doubles that don't implement it.
534
659
  }
660
+ return {
661
+ hasRequiredBlock: () => lastRequired !== null,
662
+ setSyntheticFinalMessage: (msg) => {
663
+ lastRequired = msg
664
+ onFinalMessage(msg)
665
+ },
666
+ }
535
667
  }
536
668
 
537
669
  function extractFinalMessageText(content: unknown): string | null {
@@ -51,6 +51,8 @@ export function resolveTodoScope(origin: SessionOrigin): TodoScope | null {
51
51
  }
52
52
  }
53
53
 
54
+ const CHANNEL_SCOPE_SEPARATOR = ','
55
+
54
56
  function channelScopeKey(origin: { adapter: string; workspace: string; chat: string; thread: string | null }): string {
55
57
  const parts = [
56
58
  encodeComponent(origin.adapter),
@@ -58,7 +60,7 @@ function channelScopeKey(origin: { adapter: string; workspace: string; chat: str
58
60
  encodeComponent(origin.chat),
59
61
  encodeComponent(origin.thread),
60
62
  ]
61
- return `channel/${parts.join(':')}`
63
+ return `channel/${parts.join(CHANNEL_SCOPE_SEPARATOR)}`
62
64
  }
63
65
 
64
66
  // Encode one scope component injectively. Every component is emitted as a
@@ -69,7 +71,7 @@ function channelScopeKey(origin: { adapter: string; workspace: string; chat: str
69
71
  // confused: a null thread vs a literal "n" string, an empty string vs a
70
72
  // literal "_empty" string, and any value vs another whose unsafe chars happen
71
73
  // to map together. `encodeURIComponent` is itself injective and never emits
72
- // `/` or `:`, so the joined key is both a single filesystem-safe path segment
74
+ // `/` or `,`, so the joined key is both a single filesystem-safe path segment
73
75
  // and a collision-free identity for the conversation whose todo file it names.
74
76
  function encodeComponent(value: string | null): string {
75
77
  if (value === null) return 'n'
@@ -78,15 +78,13 @@ export function createChannelReplyTool({
78
78
  },
79
79
  ),
80
80
  ),
81
- continue: Type.Optional(
82
- Type.Boolean({
83
- description:
84
- 'Set `true` when this reply is a mid-turn status update (e.g. "working on it…") and you still have work to do THIS turn fetching data, running a tool, spawning a subagent, then replying again. ' +
85
- 'Omitting it on such an ack silently truncates the turn: a successful reply ends the turn by default, so the fetch/subagent/answer you intended to do next never runs. ' +
86
- 'A normal final reply omits this (no wasted follow-up LLM call). ' +
87
- 'Do not set it just to seem responsive; only when genuine multi-step work follows in the same turn.',
88
- }),
89
- ),
81
+ continue: Type.Boolean({
82
+ description:
83
+ 'REQUIRED on every channel_reply — you must explicitly choose, there is no default. Set `true` when this reply is a mid-turn status update (e.g. "working on it…") and you still have work to do THIS turn — fetching data, running a tool, spawning a subagent, then replying again; `true` keeps the turn alive so that follow-up actually runs. ' +
84
+ 'Set `false` when this reply is your final message for the turn (the common case). ' +
85
+ 'This choice is mandatory precisely because a missing value used to default to ending the turn silently: a successful reply ends the turn unless `continue` is `true`, so a `false` on an ack you meant to keep working from drops the work you promised. ' +
86
+ 'Do not set `true` just to seem responsive; only when genuine multi-step work follows in the same turn.',
87
+ }),
90
88
  resolve_review_thread: Type.Optional(
91
89
  Type.Boolean({
92
90
  description:
@@ -9,10 +9,20 @@ import { definePlugin } from '@/plugin'
9
9
  // path — keep them in lockstep.
10
10
  export const RENDER_SCRIPT_AGENT_RELATIVE_PATH = 'node_modules/typeclaw/src/bundled-plugins/doc-render/render.ts'
11
11
 
12
+ // In-container path of the bundled themed report library, relative to the agent
13
+ // root. The skill tells the agent to copy this next to its markdown and
14
+ // `#import "lib.typ"`, because Typst's workspace sandbox only resolves imports
15
+ // under the render's working directory. Keep in lockstep with the skill.
16
+ export const TEMPLATE_LIB_AGENT_RELATIVE_PATH = 'node_modules/typeclaw/src/bundled-plugins/doc-render/templates/lib.typ'
17
+
12
18
  export function renderScriptPath(): string {
13
19
  return join(import.meta.dir, 'render.ts')
14
20
  }
15
21
 
22
+ export function templateLibPath(): string {
23
+ return join(import.meta.dir, 'templates', 'lib.typ')
24
+ }
25
+
16
26
  export default definePlugin({
17
27
  plugin: async () => ({
18
28
  skillsDirs: [join(import.meta.dir, 'skills')],