typeclaw 0.6.0 → 0.8.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/README.md CHANGED
@@ -19,34 +19,37 @@ TypeClaw is the agent I wanted to use:
19
19
  - **TypeScript end to end** — agent core, plugins, channel adapters, CLI, TUI all in one language
20
20
  - **Bun-native plugins** — plugins are just TS modules; no IPC, no FFI, hot-reloadable config
21
21
  - **Docker-friendly by default** — every agent runs in its own container; the host CLI is purely a launcher
22
- - **Multi-channel out of the box** — Slack, Discord, TUI, websocket — all routed through one in-process stream
23
22
  - **Self-improving** — the agent observes its own work, distills it into long-term memory and reusable skills, and gets sharper over time without you writing prompts for it
24
23
 
25
- ## Features
24
+ If you're like me, TypeClaw is the right choice. If not, that's fine too.
26
25
 
27
- - 🐳 **Sandboxed by default** — every agent runs in its own Docker container, with an `.env` and bind-mounted host folders
28
- - 🔌 **Plugin system** — plain TypeScript modules contribute tools, skills, subagents, channels, and typed config
29
- - 💬 **Multi-channel** — Slack, Discord, and a websocket TUI out of the box; one agent, many inboxes
30
- - 👥 **Group chat awareness** — knows who's in the room, distinguishes humans from bots, and stays engaged after a reply without re-mentioning
26
+ ## What you'd expect
27
+
28
+ - 🐳 **Sandboxed by default** — every agent runs in its own Docker container with `.env` injection and bind-mounted host folders
29
+ - 🔌 **Plugin system** — plain TypeScript modules contribute tools, skills, subagents, channels, commands, and typed config
30
+ - 💬 **Multi-channel** — Slack, Discord, Telegram, KakaoTalk, GitHub webhooks, and a websocket TUI; one agent, many inboxes
31
31
  - ⏰ **Cron** — schedule prompts or shell commands; per-job coalescing so slow jobs don't pile up
32
32
  - 📚 **Skills on demand** — markdown procedures the agent loads only when relevant; zero token cost until used
33
- - 🌱 **Self-improving** — bundled memory plugin observes the agent's work and consolidates it into long-term memory (see below)
34
- - 🧠 **Muscle memory** — repeated procedures get distilled into reusable skills that the agent writes for itself
35
- - 🔄 **Hot reload** — change `typeclaw.json`, `typeclaw reload` no restart for most fields
36
- - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
37
- - 🌐 **Auto port-forward** — dev servers inside the container appear on `localhost`, even loopback-only ones
38
- - 🌍 **Public tunnels** — Cloudflare Quick (zero signup) or bring-your-own external URL; the agent self-registers GitHub webhooks at the resulting public URL
39
- - 🎼 **Compose** — orchestrate multiple agents across multiple folders
33
+ - 🔎 **Web research** — bundled `scout` subagent plus first-class `websearch` and `webfetch` tools (DuckDuckGo via curl-impersonate, Wikipedia)
34
+ - 🛡 **Security guards** — bundled `tool.before` policies catch secret exfil, SSRF, prompt injection, and tainted git remotes before they fire
35
+ - 📊 **Usage and doctor** — `typeclaw usage` reports token/$ spend per session, model, or day; `typeclaw doctor` diagnoses host, agent folder, and plugin state
40
36
 
41
- ### 🌱 Self-improving, in detail
37
+ ## Where it goes further
42
38
 
43
- The bundled `memory` plugin turns lived experience into reusable knowledge. No manual prompt engineering. No curated example library.
44
-
45
- 1. **Observe.** After every idle turn, a `memory-logger` subagent reads the transcript and appends notable fragments to `memory/yyyy-MM-dd.md`. Cheap, frequent, lossy by design.
46
- 2. **Dream.** On a cron schedule (default 4am), a `dreaming` subagent consolidates daily streams into `MEMORY.md`, and when it spots a procedure worth remembering writes it as **muscle memory**: a new skill at `memory/skills/<name>/SKILL.md`.
47
- 3. **Apply.** Tomorrow's prompt sees the updated `MEMORY.md`. Muscle-memory skills sit alongside bundled and user-installed ones, loaded on demand. Every dream is committed with a one-line summary e.g. `dream: 3 fragments + new skill 'pr-review' 🔮` — so growth is auditable.
39
+ - 🌱 **Self-improving** — bundled `memory` plugin distills sessions into long-term `MEMORY.md` without you writing prompts for it
40
+ - 🧠 **Muscle memory** — repeated procedures get distilled into reusable skills the agent writes for itself and loads on later runs
41
+ - 💾 **Auto-backup** the bundled `backup` plugin commits session logs and memory on every idle window with an LLM-generated commit subject
42
+ - 🪄 **Subagents** first-class child sessions with their own system prompt, payload schema, and per-payload coalescing; cron and the main agent fire them through one in-process Stream
43
+ - 🪪 **Roles and permissions** `owner` / `trusted` / `member` / `guest` with first-message match rules per channel; gates `channel.respond`, cron scheduling, and security bypasses, so a Slack stranger can't tell the agent to push to main
44
+ - 👥 **Group chat awareness** — knows who's in the room, distinguishes humans from bots, and stays engaged after a reply without re-mentioning
45
+ - 🧱 **Managed-file guards** — `typeclaw.json`, `cron.json`, `MEMORY.md`, and bundled skills are protected from accidental rewrites; invalid config writes are rejected at the tool boundary
46
+ - 🌐 **Headed browser inside the container** — bundled `agent-browser` plugin ships Chrome under Xvfb so the agent can drive real web pages past bot fingerprinting
47
+ - 🌍 **Tunnels and auto port-forward** — dev servers inside the container appear on `localhost` (even loopback-only ones); public URLs via Cloudflare Quick (zero signup) or your own external URL, with GitHub webhooks self-registered at the resulting URL
48
+ - 🔄 **Hot reload** — change `typeclaw.json`, run `typeclaw reload` — no restart for most fields
49
+ - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
50
+ - 🎼 **Compose** — orchestrate multiple agents across multiple folders
48
51
 
49
- See [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md) for the full contract.
52
+ Memory loop and subagent architecture are covered in detail in [AGENTS.md](./AGENTS.md) and [`src/bundled-plugins/memory/README.md`](./src/bundled-plugins/memory/README.md).
50
53
 
51
54
  ## Install
52
55
 
@@ -67,59 +70,7 @@ typeclaw tui # attach a terminal UI to the running agent
67
70
 
68
71
  That's it. The agent is now alive, listening on a websocket, ready to receive prompts from the TUI or any wired channel.
69
72
 
70
- ## CLI
71
-
72
- | Command | Purpose |
73
- | ----------------------------------- | ----------------------------------------------------------------------------------- |
74
- | `typeclaw init` | Scaffold a new agent folder |
75
- | `typeclaw start` | Build and run the container |
76
- | `typeclaw stop` | Stop the container |
77
- | `typeclaw restart` | `stop` then `start` |
78
- | `typeclaw status` | Show container + daemon registration state |
79
- | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
80
- | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
81
- | `typeclaw shell` | Open a shell inside the running container |
82
- | `typeclaw reload` | Push a live config reload to the running agent |
83
- | `typeclaw compose` | Orchestrate multiple agents |
84
- | `typeclaw cron list` | List every cron job registered in the running agent (user `cron.json` + plugins) |
85
- | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk, GitHub) |
86
- | `typeclaw channel set <kind>` | Rotate the credentials of an already-configured channel (bot/app tokens, PAT, etc.) |
87
- | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
88
- | `typeclaw tunnel ...` | Add/list/status/remove public tunnels and inspect tunnel logs |
89
-
90
- ## Configuration
91
-
92
- Agent folder layout after `init`:
93
-
94
- ```
95
- my-agent/
96
- ├── typeclaw.json # main config (schema-validated)
97
- ├── cron.json # scheduled jobs (optional)
98
- ├── .env # secrets, injected via --env-file
99
- ├── Dockerfile # auto-managed by typeclaw, refreshed every `start`
100
- ├── package.json # `typeclaw` as a dependency
101
- ├── .gitignore # auto-managed
102
- ├── workspace/ # agent's free-write zone (gitignored)
103
- ├── sessions/ # JSONL session logs (gitignored, force-committed by auto-backup)
104
- └── memory/ # MEMORY.md + muscle-memory skills (gitignored, force-committed by dreaming)
105
- ```
106
-
107
- `typeclaw.json` is JSON Schema–validated (see `typeclaw.schema.json`). Highlights:
108
-
109
- - `port` — preferred host port (CLI falls back to ephemeral on conflict)
110
- - `mounts` — host directories to expose inside the container
111
- - `plugins` — list of plugin module specifiers
112
- - `channels` — `slack-bot` / `discord-bot` config
113
- - `portForward` — allow/deny list for auto port forwarding (default: `*`)
114
- - `tunnels` — declare public URLs for inbound webhooks and ad-hoc exposure (`cloudflare-quick` or `external`)
115
- - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, `cjkFonts`, plus `append` lines
116
- - `memory` — idle window and dreaming schedule for the memory plugin
117
-
118
- `Dockerfile` and `.gitignore` are owned by TypeClaw and rewritten on every `start` — edit `src/init/dockerfile.ts` and re-run `start --build` to ship template changes.
119
-
120
- ### Secrets
121
-
122
- Credentials live in two gitignored files: `.env` (plain `KEY=value` lines, injected into the container via `--env-file`) and `secrets.json` (a structured store managed by TypeClaw). **Env-wins**: when a credential's canonical env var (e.g. `FIREWORKS_API_KEY`, `SLACK_BOT_TOKEN`) is set, that value is used at runtime — `secrets.json` is never auto-mutated to capture it. Every secret-bearing field in `secrets.json` is a `Secret` (`string | { value?, env? }`), so the file can rebind a credential to a custom env-var name on demand. See [AGENTS.md § Secrets](./AGENTS.md#secrets) for the full contract.
73
+ See `typeclaw --help` for the full command surface, or [typeclaw.dev](https://typeclaw.dev) for guides and configuration reference.
123
74
 
124
75
  ## Development
125
76
 
@@ -130,7 +81,7 @@ bun install
130
81
  bun test
131
82
  ```
132
83
 
133
- Pre-commit checks (must all pass — no exceptions):
84
+ Pre-commit checks (all must pass — no exceptions):
134
85
 
135
86
  ```sh
136
87
  bun run typecheck
@@ -138,11 +89,12 @@ bun run lint
138
89
  bun run format
139
90
  ```
140
91
 
141
- See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy.
92
+ See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy. The docs site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/).
142
93
 
143
- ## Website
94
+ ## Acknowledgments
144
95
 
145
- The landing page and documentation site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/). It's a Next.js + Fumadocs app see [`docs/README.md`](./docs/README.md) for layout and the contributor workflow.
96
+ - **Multi-channel** is powered by [agent-messenger](https://github.com/agent-messenger/agent-messenger) every non-GitHub adapter (`slack-bot`, `discord-bot`, `telegram-bot`, `kakaotalk`) is built on its SDK. Thanks to the maintainers for the credential extraction, listener protocols, and platform coverage that made multi-channel a feature instead of a year-long project.
97
+ - **Subagent architecture** is inspired by [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) by [@code-yeongyu](https://github.com/code-yeongyu). Thanks for the shape that made this clean.
146
98
 
147
99
  ## License
148
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -4,12 +4,19 @@ import { parseArgs } from 'node:util'
4
4
 
5
5
  import { composeSystemPrompt, deriveSystemPromptMode, type SystemPromptMode } from '@/agent'
6
6
  import type { SessionOrigin, SessionRoleContext } from '@/agent/session-origin'
7
+ import { renderNowBlock } from '@/agent/system-prompt'
7
8
 
8
9
  type OriginKind = 'tui' | 'cron' | 'channel' | 'subagent'
9
10
  const ALL_KINDS: readonly OriginKind[] = ['tui', 'cron', 'channel', 'subagent'] as const
10
11
 
11
12
  const PLACEHOLDER_RUNTIME_VERSION = '1.2.3-debug'
12
13
 
14
+ // Fixed wall-clock for the `## Now` block. The dumper needs a deterministic
15
+ // timestamp so successive runs produce byte-identical output (and so the
16
+ // snapshot tests in dump-system-prompt.test.ts don't drift). Production
17
+ // callers always pass the live `new Date()` — see `composeSystemPrompt`.
18
+ const PLACEHOLDER_NOW = new Date('2026-05-22T15:11:00+09:00')
19
+
13
20
  const PLACEHOLDER_SELF = [
14
21
  '# Identity',
15
22
  '',
@@ -236,12 +243,14 @@ function dumpSubagentOverridePrompt(): DumpResult {
236
243
  const fixture = buildFixture('subagent')
237
244
  const runtimeBlock = `## Runtime\n\nTypeClaw runtime version: ${PLACEHOLDER_RUNTIME_VERSION}.`
238
245
  const originBlock = `## Session origin\n\nYou are a \`${(fixture.origin as { subagent: string }).subagent}\` subagent spawned by parent session\n\`${(fixture.origin as { parentSessionId: string }).parentSessionId}\`. Stay narrowly within the task you were given.\nReturn cleanly when done; do not sprawl into unrelated work.\n\n## Your role in this session\n\nRole: \`${fixture.roleContext.role}\`. Permissions: ${fixture.roleContext.permissions.map((p) => `\`${p}\``).join(', ')}.\n\nThis is the role the runtime resolved at session creation. Tool calls\nand channel admission are gated by these permissions; a \`blocked:\` or\n"denied by permissions" message means the current actor lacks the\npermission the guard was looking for. See the \`typeclaw-permissions\`\nskill for what each role can do and how to grant access.`
246
+ const nowBlock = renderNowBlock(PLACEHOLDER_NOW)
239
247
 
240
- const prompt = `${PLACEHOLDER_SUBAGENT_OVERRIDE}\n\n${runtimeBlock}\n\n${originBlock}`
248
+ const prompt = `${PLACEHOLDER_SUBAGENT_OVERRIDE}\n\n${runtimeBlock}\n\n${originBlock}\n\n${nowBlock}`
241
249
  const sections: SectionBreakdown[] = [
242
250
  mkSection('Subagent override prompt', PLACEHOLDER_SUBAGENT_OVERRIDE),
243
251
  mkSection('Runtime block', runtimeBlock),
244
252
  mkSection('Session origin + role', originBlock),
253
+ mkSection('Now (wall clock)', nowBlock),
245
254
  ]
246
255
  return {
247
256
  prompt,
@@ -264,6 +273,7 @@ function dumpDefaultLoaderPrompt(kind: Exclude<OriginKind, 'subagent'>, options:
264
273
  roleContext: fixture.roleContext,
265
274
  gitNudge: wantGitNudge ? PLACEHOLDER_GIT_NUDGE : '',
266
275
  memorySection: fixture.memory,
276
+ now: PLACEHOLDER_NOW,
267
277
  } as const
268
278
 
269
279
  const prompt = composeSystemPrompt(parts)
@@ -289,6 +299,7 @@ function dumpDefaultLoaderPrompt(kind: Exclude<OriginKind, 'subagent'>, options:
289
299
  sections.push(mkSection('Git nudge', parts.gitNudge))
290
300
  }
291
301
  sections.push(mkSection('Memory (MEMORY.md + streams)', parts.memorySection))
302
+ sections.push(mkSection('Now (wall clock)', renderNowBlock(PLACEHOLDER_NOW)))
292
303
 
293
304
  return {
294
305
  prompt,
package/src/agent/auth.ts CHANGED
@@ -124,8 +124,8 @@ function missingCredentialMessage(providerId: KnownProviderId): string {
124
124
  }
125
125
  if (apiKeyOnly && provider.apiKeyEnv) {
126
126
  return modelName
127
- ? `Set ${provider.apiKeyEnv} in .env (or secrets.json#providers.${provider.id}.key.value) to use ${modelName} via ${provider.name}.`
128
- : `Set ${provider.apiKeyEnv} in .env (or secrets.json#providers.${provider.id}.key.value) to use ${provider.name} (referenced by a non-default profile).`
127
+ ? `Run \`typeclaw init\` to add an API key for ${modelName} via ${provider.name} (stored in secrets.json#providers.${provider.id}.key.value; ${provider.apiKeyEnv} in .env also works for override).`
128
+ : `Run \`typeclaw init\` to add an API key for ${provider.name} (referenced by a non-default profile; stored in secrets.json#providers.${provider.id}.key.value; ${provider.apiKeyEnv} in .env also works for override).`
129
129
  }
130
- return `No credentials for ${provider.name}. Either set ${provider.apiKeyEnv ?? '<api-key-env>'} in .env or run \`typeclaw init\` and pick "OAuth".`
130
+ return `No credentials for ${provider.name}. Run \`typeclaw init\` to add an API key (stored in secrets.json) or pick "OAuth".`
131
131
  }
@@ -27,13 +27,19 @@ import { createCompactionSettingsManager } from './compaction'
27
27
  import { renderGitNudge } from './git-nudge'
28
28
  import type { LiveSubagentRegistry } from './live-subagents'
29
29
  import { lookAtTool } from './multimodal'
30
- import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystemTool } from './plugin-tools'
30
+ import {
31
+ buildBuiltinPiToolOverrides,
32
+ resolveBuiltinToolRefs,
33
+ wrapPluginTool,
34
+ wrapSystemAgentTool,
35
+ wrapSystemTool,
36
+ } from './plugin-tools'
31
37
  import { createReloadTool } from './reload-tool'
32
38
  import { loadSelf } from './self'
33
39
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
34
40
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
35
41
  import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
36
- import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
42
+ import { DEFAULT_SYSTEM_PROMPT, renderNowBlock, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
37
43
  import {
38
44
  createBudgetState,
39
45
  type ToolResultBudget,
@@ -313,7 +319,22 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
313
319
  stream: options.stream,
314
320
  }),
315
321
  ]
316
- const customToolsPreBudget = [...wrapSystemTools(customSystemTools, options.plugins, getOrigin), ...pluginCustomTools]
322
+ // Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
323
+ // find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
324
+ // way to interpose typeclaw guards is to ship same-named ToolDefinition
325
+ // entries through `customTools`. Skipped when there are no tool hooks,
326
+ // since wrapping reduces to a passthrough in that case.
327
+ const builtinPiToolOverrides =
328
+ options.plugins && hasToolHooks(options.plugins)
329
+ ? buildBuiltinPiToolOverrides({
330
+ agentDir: options.plugins.agentDir,
331
+ sessionId: options.plugins.sessionId,
332
+ hooks: options.plugins.hooks,
333
+ getOrigin,
334
+ })
335
+ : []
336
+ const wrappedCustomSystemTools = wrapSystemTools(customSystemTools, options.plugins, getOrigin)
337
+ const customToolsPreBudget = [...wrappedCustomSystemTools, ...pluginCustomTools, ...builtinPiToolOverrides]
317
338
  const customTools =
318
339
  sessionBudget && sessionBudgetState
319
340
  ? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
@@ -331,6 +352,21 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
331
352
  customTools,
332
353
  })
333
354
 
355
+ // Re-narrow the active tool set after `createAgentSession`. pi 0.67.3's
356
+ // `_refreshToolRegistry` runs with `includeAllExtensionTools: true` and
357
+ // pushes every customTool name into the active set, which would widen
358
+ // a subagent's declared `[edit]` to all 7 builtin overrides plus every
359
+ // typeclaw custom tool. The intended active set is the names the caller
360
+ // would have gotten WITHOUT the builtin overrides: pi's `initialActiveToolNames`
361
+ // (derived from `tools:`) union the names from typeclaw/plugin customTools.
362
+ // `builtinPiToolOverrides` are implementation overrides, never additions.
363
+ if (builtinPiToolOverrides.length > 0) {
364
+ const baseActiveNames = tools !== undefined ? tools.map((t) => t.name) : ['read', 'bash', 'edit', 'write']
365
+ const customToolActiveNames = [...wrappedCustomSystemTools, ...pluginCustomTools].map((t) => t.name)
366
+ const intendedActive = [...new Set([...baseActiveNames, ...customToolActiveNames])]
367
+ session.setActiveToolsByName(intendedActive)
368
+ }
369
+
334
370
  const unsubRestart = subscribeRestartNotice(options.stream, sessionManager)
335
371
 
336
372
  const dispose = async () => {
@@ -591,10 +627,12 @@ export async function createOverrideResourceLoader(
591
627
  origin?: SessionOrigin,
592
628
  permissions?: PermissionService,
593
629
  runtimeVersion?: string,
630
+ now: Date = new Date(),
594
631
  ): Promise<DefaultResourceLoader> {
595
632
  const withRuntime =
596
633
  runtimeVersion !== undefined ? `${systemPrompt}\n\n${renderRuntimeBlock(runtimeVersion)}` : systemPrompt
597
- const finalPrompt = withOrigin(withRuntime, origin, permissions)
634
+ const withOriginRendered = withOrigin(withRuntime, origin, permissions)
635
+ const finalPrompt = `${withOriginRendered}\n\n${renderNowBlock(now)}`
598
636
  const loader = new DefaultResourceLoader({
599
637
  systemPromptOverride: () => finalPrompt,
600
638
  appendSystemPromptOverride: () => [],
@@ -615,6 +653,11 @@ export type CreateResourceLoaderOptions = {
615
653
  // 'full' to force the heavy prompt even on an unattended origin (rarely
616
654
  // useful; mostly an escape hatch for ad-hoc debugging).
617
655
  mode?: SystemPromptMode
656
+ // Wall-clock anchor stamped into the trailing `## Now` block of the
657
+ // rendered system prompt. Production callers omit this so each session
658
+ // gets the current time at creation; tests pass a fixed Date to keep
659
+ // assertions deterministic. See `renderNowBlock` in system-prompt.ts.
660
+ now?: Date
618
661
  }
619
662
 
620
663
  // Origins where the operator-facing DEFAULT_SYSTEM_PROMPT, git-nudge, and the
@@ -672,6 +715,7 @@ export type SystemPromptComposition = {
672
715
  roleContext?: SessionRoleContext
673
716
  gitNudge: string
674
717
  memorySection: string
718
+ now?: Date
675
719
  }
676
720
 
677
721
  // Section-order contract for the system prompt. Kept as a pure string→string
@@ -687,10 +731,15 @@ export type SystemPromptComposition = {
687
731
  // 2. gitNudge — rare changes; agent folders force-commit sessions/ and
688
732
  // memory/ after every turn, so the dirty-files list is empty most of
689
733
  // the time.
690
- // 3. memorySection — most volatile: MEMORY.md grows on every dream cycle
691
- // and memory/yyyy-MM-dd.md grows after every channel turn that triggers
692
- // memory-logger. Pinning it to the end keeps everything above it
693
- // cacheable across session resurrections.
734
+ // 3. memorySection — volatile: MEMORY.md grows on every dream cycle and
735
+ // memory/yyyy-MM-dd.md grows after every channel turn that triggers
736
+ // memory-logger.
737
+ // 4. now block — most volatile: changes per second. Pinned to the very
738
+ // end so every byte UP TO this block stays in the provider's cache
739
+ // prefix; only the trailing ~60 bytes invalidate on each new session.
740
+ // `now` is optional — when omitted (debug dumps without a fixed clock,
741
+ // legacy callers) the block is skipped entirely. See `renderNowBlock`
742
+ // in system-prompt.ts for why this block exists at all.
694
743
  export function composeSystemPrompt(parts: SystemPromptComposition): string {
695
744
  const base = parts.mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
696
745
  let prompt = `${base}\n\n${parts.self}`
@@ -706,6 +755,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
706
755
  if (parts.memorySection !== '') {
707
756
  prompt = `${prompt}\n\n${parts.memorySection}`
708
757
  }
758
+ if (parts.now !== undefined) {
759
+ prompt = `${prompt}\n\n${renderNowBlock(parts.now)}`
760
+ }
709
761
  return prompt
710
762
  }
711
763
 
@@ -750,6 +802,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
750
802
  ...(roleContext !== undefined ? { roleContext } : {}),
751
803
  gitNudge,
752
804
  memorySection,
805
+ now: options.now ?? new Date(),
753
806
  })
754
807
 
755
808
  const additionalSkillPaths = [getBundledSkillsDir()]
@@ -0,0 +1,43 @@
1
+ import path from 'node:path'
2
+
3
+ import { ACKNOWLEDGE_GUARDS, type GuardBlock, isGuardAcknowledged } from '@/bundled-plugins/guard/policy'
4
+
5
+ export const GUARD_IMAGE_READ_REDIRECT = 'imageReadRedirect'
6
+
7
+ // Mirrors the IMAGE_MIME_TYPES set in @mariozechner/pi-coding-agent
8
+ // (dist/utils/mime.ts). Keeping the trigger surface aligned with the upstream
9
+ // read tool's image-attachment behavior means we redirect on exactly the
10
+ // extensions that would otherwise inject `{ type: 'image' }` content parts
11
+ // into the main agent's history.
12
+ //
13
+ // Extension-only matching is preferred over the upstream MIME sniffer's
14
+ // 4100-byte file open because this check runs on every `read` call;
15
+ // extensionless image files still leak as before (no regression), and the
16
+ // agent can force-read via `acknowledgeGuards.imageReadRedirect: true` when
17
+ // it genuinely needs the bytes (e.g. writing image-processing code).
18
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp'])
19
+
20
+ export function checkImageReadRedirect(options: {
21
+ tool: string
22
+ args: Record<string, unknown>
23
+ }): GuardBlock | undefined {
24
+ const { tool, args } = options
25
+ if (tool !== 'read') return undefined
26
+ if (isGuardAcknowledged(args, GUARD_IMAGE_READ_REDIRECT)) return undefined
27
+
28
+ const rawPath = args.path
29
+ if (typeof rawPath !== 'string' || rawPath === '') return undefined
30
+
31
+ const ext = path.extname(rawPath).toLowerCase()
32
+ if (!IMAGE_EXTENSIONS.has(ext)) return undefined
33
+
34
+ return {
35
+ block: true,
36
+ reason: [
37
+ `Guard \`${GUARD_IMAGE_READ_REDIRECT}\` blocked read of an image file: ${rawPath}.`,
38
+ `Reading images via \`read\` injects the raw bytes into your message history as an image attachment, which can quickly fill your context window.`,
39
+ `Use \`look_at\` with \`path: ${JSON.stringify(rawPath)}\` instead — it routes the bytes through a vision-capable subagent and returns only text to you. Pass an optional \`prompt\` to ask a specific question (returns shorter text than the default describe-everything path).`,
40
+ `If you genuinely need the raw image bytes (e.g. writing image-processing code), retry with \`${ACKNOWLEDGE_GUARDS}.${GUARD_IMAGE_READ_REDIRECT}: true\` in the tool arguments.`,
41
+ ].join(' '),
42
+ }
43
+ }
@@ -31,6 +31,7 @@ import type {
31
31
  ToolResult,
32
32
  } from '@/plugin'
33
33
 
34
+ import { checkImageReadRedirect } from './multimodal/read-redirect'
34
35
  import type { SessionOrigin } from './session-origin'
35
36
  import { webfetchTool } from './tools/webfetch'
36
37
  import { websearchTool } from './tools/websearch'
@@ -44,19 +45,20 @@ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
44
45
  ),
45
46
  )
46
47
 
47
- // `BuiltinToolRef.__builtinTool` strings are dual-routed when a plugin
48
- // subagent declares them: pi-coding-agent's own coding tools flow through
49
- // `createAgentSession({ tools: AgentTool[] })` (which pi treats as a strict
50
- // base-tool override exactly the declared subset becomes active), and
51
- // typeclaw's own web tools flow through `customTools: ToolDefinition[]` (the
52
- // only path pi accepts for non-pi tool definitions). Routing typeclaw tools
53
- // through `tools:` silently drops them (pi's `tools` validator rejects shapes
54
- // it doesn't recognize); routing pi tools through `customTools:` would work
55
- // but ALSO auto-injects pi's default 4 base tools (read/bash/edit/write),
56
- // widening every plugin subagent's allowlist beyond what it declared. The
57
- // dual route is the only shape that gives "subagent gets exactly what it
58
- // asked for, nothing more." See `src/agent/index.ts` `createSessionWithDispose`
59
- // for the consumer that splits the resolved arrays into the two pi fields.
48
+ // pi-coding-agent 0.67.3 contract (load-bearing for hook coverage):
49
+ // - `createAgentSession({ tools: AgentTool[] })` is ONLY a name filter for
50
+ // `initialActiveToolNames`. It does NOT swap builtin implementations.
51
+ // - `customTools: ToolDefinition[]` entries override builtins by name in
52
+ // `_refreshToolRegistry` (the registry merge writes customTools last).
53
+ //
54
+ // Consequence: to put a `tool.before` hook around pi's builtin read/bash/edit/
55
+ // write, TypeClaw must wrap them as `ToolDefinition`s and pass them via
56
+ // `customTools` not via `tools`. `wrapAgentToolAsCustomToolDefinition`
57
+ // produces those wrapped definitions; `setupSession` in `src/agent/index.ts`
58
+ // appends them whenever the session has any `tool.before` / `tool.after`
59
+ // hooks registered. Subagent narrowing still comes from `tools:` (the
60
+ // name-filter path); the wrapped customTools just replace the implementation
61
+ // underneath so subagent and channel sessions share the same hook coverage.
60
62
  type PiAgentToolName = 'read' | 'bash' | 'edit' | 'write' | 'grep' | 'find' | 'ls'
61
63
  type TypeclawToolName = 'websearch' | 'webfetch'
62
64
 
@@ -231,6 +233,10 @@ export function wrapSystemTool<TParams extends TSchema, TDetails = unknown, TSta
231
233
  if (guardResult !== undefined) {
232
234
  throw new Error(`blocked: ${guardResult.reason}`)
233
235
  }
236
+ const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
237
+ if (readGuardResult !== undefined) {
238
+ throw new Error(`blocked: ${readGuardResult.reason}`)
239
+ }
234
240
  stripGuardAcknowledgements(mutableArgs)
235
241
 
236
242
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate, ctx)
@@ -280,6 +286,10 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
280
286
  if (guardResult !== undefined) {
281
287
  throw new Error(`blocked: ${guardResult.reason}`)
282
288
  }
289
+ const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
290
+ if (readGuardResult !== undefined) {
291
+ throw new Error(`blocked: ${readGuardResult.reason}`)
292
+ }
283
293
  stripGuardAcknowledgements(mutableArgs)
284
294
 
285
295
  const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
@@ -301,6 +311,74 @@ export function wrapSystemAgentTool<TParams extends TSchema, TDetails = unknown>
301
311
  }
302
312
  }
303
313
 
314
+ // Wraps a pi-coding-agent AgentTool into a ToolDefinition so it can ride in
315
+ // `customTools` and override pi's same-named builtin (see top-of-file contract
316
+ // block). The hook + guard pipeline matches `wrapSystemAgentTool`; only the
317
+ // input/output shape differs.
318
+ export function wrapAgentToolAsCustomToolDefinition<TParams extends TSchema, TDetails = unknown>(
319
+ tool: AgentTool<TParams, TDetails>,
320
+ opts: WrapSystemToolOptions,
321
+ ): ToolDefinition<TParams, TDetails> {
322
+ return piDefineTool({
323
+ name: tool.name,
324
+ label: tool.label,
325
+ description: tool.description,
326
+ parameters: withGuardAcknowledgements(tool.name, tool.parameters),
327
+ prepareArguments: tool.prepareArguments,
328
+ async execute(toolCallId, params, signal, onUpdate) {
329
+ const mutableArgs = params as Record<string, unknown>
330
+ const liveOrigin = opts.getOrigin?.()
331
+ const blockResult = await opts.hooks.runToolBefore({
332
+ tool: tool.name,
333
+ sessionId: opts.sessionId,
334
+ callId: toolCallId,
335
+ args: mutableArgs,
336
+ ...(liveOrigin !== undefined ? { origin: liveOrigin } : {}),
337
+ })
338
+ if (blockResult !== undefined) {
339
+ throw new Error(`blocked: ${blockResult.reason}`)
340
+ }
341
+ const guardResult = await runFinalWriteGuards({
342
+ tool: tool.name,
343
+ args: mutableArgs,
344
+ agentDir: opts.agentDir,
345
+ })
346
+ if (guardResult !== undefined) {
347
+ throw new Error(`blocked: ${guardResult.reason}`)
348
+ }
349
+ const readGuardResult = runFinalReadGuards({ tool: tool.name, args: mutableArgs })
350
+ if (readGuardResult !== undefined) {
351
+ throw new Error(`blocked: ${readGuardResult.reason}`)
352
+ }
353
+ stripGuardAcknowledgements(mutableArgs)
354
+
355
+ const result = await tool.execute(toolCallId, mutableArgs as Static<TParams>, signal, onUpdate)
356
+ const hookResult: ToolResult = {
357
+ content: result.content as ContentPart[],
358
+ details: result.details,
359
+ }
360
+ await opts.hooks.runToolAfter({
361
+ tool: tool.name,
362
+ sessionId: opts.sessionId,
363
+ callId: toolCallId,
364
+ result: hookResult,
365
+ })
366
+ return {
367
+ content: hookResult.content as ContentPart[],
368
+ details: hookResult.details as TDetails,
369
+ }
370
+ },
371
+ })
372
+ }
373
+
374
+ export function defaultBuiltinPiAgentTools(): AgentTool<any, any>[] {
375
+ return [piReadTool, piBashTool, piEditTool, piWriteTool, piGrepTool, piFindTool, piLsTool]
376
+ }
377
+
378
+ export function buildBuiltinPiToolOverrides(opts: WrapSystemToolOptions): ToolDefinition<any, any>[] {
379
+ return defaultBuiltinPiAgentTools().map((tool) => wrapAgentToolAsCustomToolDefinition(tool, opts))
380
+ }
381
+
304
382
  function errorResult(message: string) {
305
383
  return {
306
384
  content: [{ type: 'text' as const, text: message }],
@@ -317,6 +395,10 @@ async function runFinalWriteGuards(options: { tool: string; args: Record<string,
317
395
  )
318
396
  }
319
397
 
398
+ function runFinalReadGuards(options: { tool: string; args: Record<string, unknown> }) {
399
+ return checkImageReadRedirect(options)
400
+ }
401
+
320
402
  function withGuardAcknowledgements<TParams extends TSchema>(toolName: string, parameters: TParams): TParams {
321
403
  if (toolName !== 'write' && toolName !== 'edit') return parameters
322
404
 
@@ -226,20 +226,13 @@ function renderChannelOrigin(
226
226
  'reply, your entire final visible response must be exactly `NO_REPLY`.',
227
227
  'Any other visible text without a channel tool call is blocked.',
228
228
  '',
229
- '**Default to ONE reply per inbound.** Send a second `channel_reply` only',
230
- 'when the user genuinely benefits from it:',
229
+ '**One substantive reply per inbound.** If the answer needs more than one',
230
+ 'tool call, send a one-line ack first ("On it."), keep working, then send',
231
+ 'the answer — both in the same turn. The ack is not your reply; the answer',
232
+ 'is. Once the answer lands, end your turn.',
231
233
  '',
232
- '- the user asked multiple distinct things and each deserves its own',
233
- ' scoped answer,',
234
- '- your reply exceeds the platform message limit and must be chunked,',
235
- '- you need to post an attachment AND commentary on it on Discord (on',
236
- ' Slack, pass `text` and `attachments` in a single `channel_reply` call),',
237
- '- you are emitting progress updates during a long-running task and the',
238
- ' channel would otherwise sit silent.',
239
- '',
240
- 'Do NOT send a second reply just to rephrase, restate, summarize, or',
241
- '"confirm in plain language" something you already said. After the first',
242
- 'reply lands, end your turn — the user will respond if they want more.',
234
+ 'Do not send a second reply just to rephrase, restate, or "confirm in',
235
+ 'plain language" something you already said.',
243
236
  '',
244
237
  'To reply in this conversation, call `channel_reply({ text })`. Addressing',
245
238
  `is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,