typeclaw 0.7.0 → 0.9.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.
Files changed (107) hide show
  1. package/README.md +15 -9
  2. package/package.json +5 -3
  3. package/scripts/dump-system-prompt.ts +12 -1
  4. package/scripts/require-parallel.ts +41 -0
  5. package/src/agent/auth.ts +3 -3
  6. package/src/agent/index.ts +116 -14
  7. package/src/agent/live-sessions.ts +34 -0
  8. package/src/agent/multimodal/read-redirect.ts +43 -0
  9. package/src/agent/plugin-tools.ts +97 -13
  10. package/src/agent/session-meta.ts +21 -2
  11. package/src/agent/session-origin.ts +6 -13
  12. package/src/agent/subagent-completion-reminder.ts +89 -0
  13. package/src/agent/subagents.ts +3 -2
  14. package/src/agent/system-prompt.ts +49 -15
  15. package/src/bundled-plugins/explorer/explorer.ts +2 -2
  16. package/src/bundled-plugins/guard/index.ts +14 -1
  17. package/src/bundled-plugins/guard/policies/managed-config.ts +43 -13
  18. package/src/bundled-plugins/guard/policies/memory-retrieval-cache-write.ts +37 -0
  19. package/src/bundled-plugins/guard/policies/memory-topics-delete.ts +67 -0
  20. package/src/bundled-plugins/guard/policies/memory-topics-write.ts +33 -0
  21. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -2
  22. package/src/bundled-plugins/guard/policy.ts +7 -0
  23. package/src/bundled-plugins/memory/README.md +76 -62
  24. package/src/bundled-plugins/memory/append-tool.ts +3 -2
  25. package/src/bundled-plugins/memory/citation-superset.ts +49 -11
  26. package/src/bundled-plugins/memory/citations.ts +19 -8
  27. package/src/bundled-plugins/memory/delete-tool.ts +57 -0
  28. package/src/bundled-plugins/memory/dreaming-state.ts +1 -1
  29. package/src/bundled-plugins/memory/dreaming.ts +364 -146
  30. package/src/bundled-plugins/memory/frontmatter.ts +165 -0
  31. package/src/bundled-plugins/memory/index.ts +236 -16
  32. package/src/bundled-plugins/memory/injection-plan.ts +15 -0
  33. package/src/bundled-plugins/memory/load-memory.ts +102 -103
  34. package/src/bundled-plugins/memory/load-shards.ts +156 -0
  35. package/src/bundled-plugins/memory/memory-logger.ts +16 -15
  36. package/src/bundled-plugins/memory/memory-retrieval.ts +105 -0
  37. package/src/bundled-plugins/memory/migration.ts +282 -1
  38. package/src/bundled-plugins/memory/paths.ts +42 -0
  39. package/src/bundled-plugins/memory/search-tool.ts +232 -0
  40. package/src/bundled-plugins/memory/secret-detector.ts +2 -2
  41. package/src/bundled-plugins/memory/shard-snapshot.ts +51 -0
  42. package/src/bundled-plugins/memory/slug.ts +59 -0
  43. package/src/bundled-plugins/memory/stream-io.ts +110 -1
  44. package/src/bundled-plugins/memory/strength.ts +3 -3
  45. package/src/bundled-plugins/memory/topics.ts +70 -16
  46. package/src/bundled-plugins/security/index.ts +24 -0
  47. package/src/bundled-plugins/security/permissions.ts +4 -0
  48. package/src/bundled-plugins/security/policies/cron-promotion.ts +349 -0
  49. package/src/bundled-plugins/security/policies/git-exfil.ts +2 -0
  50. package/src/bundled-plugins/security/policies/prompt-injection.ts +3 -0
  51. package/src/bundled-plugins/security/policies/role-promotion.ts +419 -0
  52. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +1 -0
  53. package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
  54. package/src/channels/adapters/discord-bot.ts +163 -1
  55. package/src/channels/adapters/kakaotalk-attachment.ts +7 -17
  56. package/src/channels/adapters/kakaotalk.ts +64 -37
  57. package/src/channels/adapters/slack-bot-classify.ts +2 -27
  58. package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
  59. package/src/channels/adapters/slack-bot.ts +139 -1
  60. package/src/channels/index.ts +5 -0
  61. package/src/channels/router.ts +328 -18
  62. package/src/channels/subagent-completion-bridge.ts +84 -0
  63. package/src/cli/builtins.ts +1 -0
  64. package/src/cli/index.ts +1 -0
  65. package/src/cli/init.ts +122 -14
  66. package/src/cli/inspect.ts +151 -0
  67. package/src/cli/role.ts +7 -2
  68. package/src/cli/tunnel.ts +13 -1
  69. package/src/cli/ui.ts +25 -1
  70. package/src/config/index.ts +1 -0
  71. package/src/config/models-mutation.ts +10 -2
  72. package/src/cron/consumer.ts +1 -1
  73. package/src/init/dockerfile.ts +353 -2
  74. package/src/init/hatching.ts +5 -6
  75. package/src/init/kakaotalk-auth.ts +6 -47
  76. package/src/init/validate-api-key.ts +121 -0
  77. package/src/inspect/index.ts +213 -0
  78. package/src/inspect/label.ts +50 -0
  79. package/src/inspect/live.ts +221 -0
  80. package/src/inspect/render.ts +163 -0
  81. package/src/inspect/replay.ts +265 -0
  82. package/src/inspect/session-list.ts +160 -0
  83. package/src/inspect/types.ts +110 -0
  84. package/src/plugin/hooks.ts +23 -1
  85. package/src/plugin/index.ts +2 -0
  86. package/src/plugin/manager.ts +1 -1
  87. package/src/plugin/registry.ts +1 -1
  88. package/src/plugin/types.ts +10 -0
  89. package/src/run/channel-session-factory.ts +7 -1
  90. package/src/run/index.ts +87 -21
  91. package/src/secrets/kakao-renewal.ts +3 -47
  92. package/src/server/index.ts +241 -60
  93. package/src/shared/index.ts +4 -1
  94. package/src/shared/local-time.ts +17 -0
  95. package/src/shared/protocol.ts +49 -0
  96. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +9 -9
  97. package/src/skills/typeclaw-claude-code/SKILL.md +83 -40
  98. package/src/skills/typeclaw-claude-code/references/stop-hook.md +2 -0
  99. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +102 -16
  100. package/src/skills/typeclaw-config/SKILL.md +38 -33
  101. package/src/skills/typeclaw-cron/SKILL.md +1 -1
  102. package/src/skills/typeclaw-git/SKILL.md +2 -2
  103. package/src/skills/typeclaw-memory/SKILL.md +16 -163
  104. package/src/skills/typeclaw-permissions/SKILL.md +2 -2
  105. package/src/skills/typeclaw-plugins/SKILL.md +26 -15
  106. package/src/test-helpers/wait-for.ts +7 -1
  107. package/typeclaw.schema.json +7 -0
package/README.md CHANGED
@@ -2,18 +2,24 @@
2
2
 
3
3
  > A TypeScript-native, Bun-powered, Docker-friendly general-purpose agent runtime.
4
4
 
5
- Full docs: **[typeclaw.dev](https://typeclaw.dev)**.
6
-
7
5
  ## Why?
8
6
 
9
- There are great agents out there. None of them were quite the shape I wanted — most are written in Go, Rust, or Python, which means plugins live outside the runtime (IPC, FFI, or a separate process). The ones in TypeScript are either too heavy or too bare.
7
+ There are great agents out there. None of them were quite the shape I wanted:
8
+
9
+ - **OpenClaw** — feature-rich, but heavy
10
+ - **NanoClaw** — simple, but no plugin system
11
+ - **PicoClaw** — fast, but Go (so plugins live outside the runtime)
12
+ - **ZeroClaw** — light, but Rust (same problem, different ecosystem)
13
+ - **Hermes Agent** — awesome, but Python
14
+
15
+ None of that matters to most people. It matters to me. If you're like me, TypeClaw is the right choice.
10
16
 
11
17
  TypeClaw is the agent I wanted to use:
12
18
 
13
19
  - **TypeScript end to end** — agent core, plugins, channel adapters, CLI, TUI all in one language
14
20
  - **Bun-native plugins** — plugins are just TS modules; no IPC, no FFI, hot-reloadable config
15
21
  - **Docker-friendly by default** — every agent runs in its own container; the host CLI is purely a launcher
16
- - **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
22
+ - **Self-improving** — the agent observes its own work, distills it into sharded long-term memory and reusable skills, and gets sharper over time without you writing prompts for it
17
23
 
18
24
  If you're like me, TypeClaw is the right choice. If not, that's fine too.
19
25
 
@@ -25,18 +31,18 @@ If you're like me, TypeClaw is the right choice. If not, that's fine too.
25
31
  - ⏰ **Cron** — schedule prompts or shell commands; per-job coalescing so slow jobs don't pile up
26
32
  - 📚 **Skills on demand** — markdown procedures the agent loads only when relevant; zero token cost until used
27
33
  - 🔎 **Web research** — bundled `scout` subagent plus first-class `websearch` and `webfetch` tools (DuckDuckGo via curl-impersonate, Wikipedia)
28
- - 🛡 **Security guards** — bundled `tool.before` policies catch secret exfil, SSRF, prompt injection, and tainted git remotes before they fire
29
- - 📊 **Usage and doctor** — `typeclaw usage` reports token/$ spend per session, model, or day; `typeclaw doctor` diagnoses host, agent folder, and plugin state
34
+ - 🛡 **Security guards** — bundled `tool.before` policies catch secret exfil, SSRF, prompt injection, tainted git remotes, and silent privilege escalation (role/cron promotion) before they fire
35
+ - 📊 **Usage, inspect, doctor** — `typeclaw usage` reports token/$ spend per session, model, or day; `typeclaw inspect` replays a session transcript and tails live activity; `typeclaw doctor` diagnoses host, agent folder, and plugin state
30
36
 
31
37
  ## Where it goes further
32
38
 
33
- - 🌱 **Self-improving** — bundled `memory` plugin distills sessions into long-term `MEMORY.md` without you writing prompts for it
39
+ - 🌱 **Self-improving** — bundled `memory` plugin logs sessions to daily streams, then a `dreaming` subagent distills them into sharded long-term memory (`memory/topics/`) on its own schedule; no prompts to write
34
40
  - 🧠 **Muscle memory** — repeated procedures get distilled into reusable skills the agent writes for itself and loads on later runs
35
41
  - 💾 **Auto-backup** — the bundled `backup` plugin commits session logs and memory on every idle window with an LLM-generated commit subject
36
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
37
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
38
44
  - 👥 **Group chat awareness** — knows who's in the room, distinguishes humans from bots, and stays engaged after a reply without re-mentioning
39
- - 🧱 **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
45
+ - 🧱 **Managed-file guards** — `typeclaw.json`, `cron.json`, memory shards, and bundled skills are protected from accidental rewrites; invalid config writes and silent role/cron privilege grants are rejected at the tool boundary
40
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
41
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
42
48
  - 🔄 **Hot reload** — change `typeclaw.json`, run `typeclaw reload` — no restart for most fields
@@ -72,7 +78,7 @@ See `typeclaw --help` for the full command surface, or [typeclaw.dev](https://ty
72
78
  git clone https://github.com/typeclaw/typeclaw
73
79
  cd typeclaw
74
80
  bun install
75
- bun test
81
+ bun run test
76
82
  ```
77
83
 
78
84
  Pre-commit checks (all must pass — no exceptions):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -36,7 +36,7 @@
36
36
  "format": "oxfmt --write .",
37
37
  "format:check": "oxfmt --check .",
38
38
  "check": "bun run typecheck && bun run lint && bun run format:check",
39
- "test": "bun test",
39
+ "test": "bun test --parallel",
40
40
  "generate:schema": "bun run scripts/generate-schema.ts",
41
41
  "debug:prompt": "bun run scripts/dump-system-prompt.ts",
42
42
  "postinstall": "bun run scripts/generate-schema.ts"
@@ -46,7 +46,7 @@
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
48
  "@mozilla/readability": "^0.6.0",
49
- "agent-messenger": "2.15.0",
49
+ "agent-messenger": "2.17.0",
50
50
  "cheerio": "^1.2.0",
51
51
  "citty": "^0.2.2",
52
52
  "cron-parser": "^5.5.0",
@@ -56,9 +56,11 @@
56
56
  "zod": "^4.3.6"
57
57
  },
58
58
  "devDependencies": {
59
+ "@sinonjs/fake-timers": "^15.4.0",
59
60
  "@types/bun": "latest",
60
61
  "@types/jsdom": "^28.0.1",
61
62
  "@types/proper-lockfile": "^4.1.4",
63
+ "@types/sinonjs__fake-timers": "^15.0.1",
62
64
  "@types/turndown": "^5.0.6",
63
65
  "@types/ws": "^8.18.1",
64
66
  "@typescript/native-preview": "^7.0.0-dev.20260416.1",
@@ -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,
@@ -0,0 +1,41 @@
1
+ // Preloaded by bunfig.toml `[test] preload`. Denies `bun test` without
2
+ // --parallel. Serial runs are ~3.4x slower (44s → 13s, see commit
3
+ // 1c66d5e), and Bun has no bunfig knob for the flag yet (verified
4
+ // against bunfig.zig in oven-sh/bun main, May 2026). Without this
5
+ // guard, IDE test runners and ad-hoc shells silently fall back to the
6
+ // slow path.
7
+ //
8
+ // Detection: Bun strips CLI flags from `Bun.argv` before invoking the
9
+ // preload, so we can't scrape the flag directly. Instead we look for
10
+ // BUN_TEST_WORKER_ID, which Bun sets in the preload env exactly when
11
+ // `--parallel` is active (the variable carries the worker index for
12
+ // the IPC handshake between coordinator and workers). Empirically
13
+ // verified against bun 1.3.14: present under --parallel, absent under
14
+ // serial. If a future Bun version renames this var, the guard fails
15
+ // closed (treats every run as serial → always denies), which is the
16
+ // safe direction.
17
+ //
18
+ // Bypass with TYPECLAW_ALLOW_SERIAL_TESTS=1 when debugging a flaky
19
+ // test where worker contention obscures the failure.
20
+
21
+ const isParallelWorker = typeof process.env.BUN_TEST_WORKER_ID === 'string'
22
+
23
+ if (isParallelWorker) {
24
+ // proceed
25
+ } else if (process.env.TYPECLAW_ALLOW_SERIAL_TESTS === '1') {
26
+ console.warn('[require-parallel] Running serially — TYPECLAW_ALLOW_SERIAL_TESTS=1 set.')
27
+ } else {
28
+ console.error('')
29
+ console.error(' ✗ `bun test` without --parallel is denied in this repo.')
30
+ console.error('')
31
+ console.error(' Serial runs take ~46s; --parallel cuts that to ~14s on a multi-core')
32
+ console.error(' machine and is what CI uses. Bun does not (yet) accept `[test] parallel`')
33
+ console.error(' in bunfig.toml, so we enforce it via this preload.')
34
+ console.error('')
35
+ console.error(' Use one of:')
36
+ console.error(' bun run test # preferred')
37
+ console.error(' bun test --parallel # direct')
38
+ console.error(' TYPECLAW_ALLOW_SERIAL_TESTS=1 bun test # intentional serial run')
39
+ console.error('')
40
+ process.exit(1)
41
+ }
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
 
@@ -713,7 +765,40 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
713
765
  const agentDir = options.agentDir ?? process.cwd()
714
766
  const mode: SystemPromptMode = options.mode ?? deriveSystemPromptMode(options.origin)
715
767
  const basePrompt = mode === 'slim' ? SLIM_SYSTEM_PROMPT : DEFAULT_SYSTEM_PROMPT
716
- let self = await loadSelf(agentDir)
768
+
769
+ // Kick off the three independent I/O paths concurrently. Sequential awaits
770
+ // here used to be the dominant cold-start cost amplifier: loadSelf is 2
771
+ // file reads, renderGitNudge spawns a subprocess, loadMemory reads N topic
772
+ // shards. None of them depend on each other, so we run them in parallel.
773
+ // The plugin hook (runSessionPrompt) only needs `self`, so it can overlap
774
+ // with the gitNudge subprocess and the shard reads while `self` is in
775
+ // flight too.
776
+ //
777
+ // Plugin-hook contract: `runSessionPrompt` runs AFTER gitNudge/memory I/O
778
+ // has been kicked off. A hook that mutates `memory/topics/` or git-tracked
779
+ // files during its body races those in-flight reads -- mutations may or
780
+ // may not be reflected in the resulting prompt. The bundled hooks only
781
+ // mutate the prompt string itself; third-party plugins that need to mutate
782
+ // disk before the suffix sections see it must do so before/outside the
783
+ // session-prompt hook.
784
+ //
785
+ // We wrap gitNudge and memory promises in `settled` shells so any
786
+ // rejection from them cannot surface as an unhandled rejection during the
787
+ // window where we're awaiting selfPromise + runSessionPrompt. Production
788
+ // callers don't reject (renderGitNudge swallows internally, loadMemory
789
+ // catches ENOENT) but a non-ENOENT fs error (EACCES/EIO) on the agent
790
+ // folder would otherwise terminate the process before we reach the
791
+ // gather point.
792
+ const selfPromise = loadSelf(agentDir)
793
+ const gitNudgeSettled = mode === 'slim' ? Promise.resolve(ok('')) : settle(renderGitNudge(agentDir))
794
+ const memorySettled = settle(
795
+ loadMemory(agentDir, {
796
+ ...(options.origin !== undefined ? { origin: options.origin } : {}),
797
+ ...(options.plugins?.sessionId !== undefined ? { currentSessionId: options.plugins.sessionId } : {}),
798
+ }),
799
+ )
800
+
801
+ let self = await selfPromise
717
802
 
718
803
  if (options.plugins) {
719
804
  // The plugin hook receives the partially-assembled prompt (base + identity)
@@ -736,11 +821,9 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
736
821
  // commit guidance the nudge points back to is itself excluded from the slim
737
822
  // base prompt. Memory is still included so cron jobs that depend on MEMORY.md
738
823
  // context (e.g. "send today's standup summary") keep working.
739
- const gitNudge = mode === 'slim' ? '' : await renderGitNudge(agentDir)
740
- const memorySection = await loadMemory(agentDir, {
741
- ...(options.origin !== undefined ? { origin: options.origin } : {}),
742
- ...(options.plugins?.sessionId !== undefined ? { currentSessionId: options.plugins.sessionId } : {}),
743
- })
824
+ const [gitNudgeResult, memoryResult] = await Promise.all([gitNudgeSettled, memorySettled])
825
+ const gitNudge = unwrapSettled(gitNudgeResult)
826
+ const memorySection = unwrapSettled(memoryResult)
744
827
 
745
828
  const systemPrompt = composeSystemPrompt({
746
829
  mode,
@@ -750,6 +833,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
750
833
  ...(roleContext !== undefined ? { roleContext } : {}),
751
834
  gitNudge,
752
835
  memorySection,
836
+ now: options.now ?? new Date(),
753
837
  })
754
838
 
755
839
  const additionalSkillPaths = [getBundledSkillsDir()]
@@ -819,3 +903,21 @@ function resolveRoleContext(
819
903
  export function getBundledSkillsDir(): string {
820
904
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
821
905
  }
906
+
907
+ type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown }
908
+
909
+ function ok<T>(value: T): Settled<T> {
910
+ return { ok: true, value }
911
+ }
912
+
913
+ function settle<T>(promise: Promise<T>): Promise<Settled<T>> {
914
+ return promise.then(
915
+ (value): Settled<T> => ({ ok: true, value }),
916
+ (error: unknown): Settled<T> => ({ ok: false, error }),
917
+ )
918
+ }
919
+
920
+ function unwrapSettled<T>(result: Settled<T>): T {
921
+ if (result.ok) return result.value
922
+ throw result.error
923
+ }
@@ -0,0 +1,34 @@
1
+ import type { AgentSession } from './index'
2
+
3
+ export type LiveAgentSession = {
4
+ sessionId: string
5
+ session: Pick<AgentSession, 'subscribe'>
6
+ }
7
+
8
+ export class LiveSessionRegistry {
9
+ private readonly entries = new Map<string, LiveAgentSession>()
10
+
11
+ register(live: LiveAgentSession): void {
12
+ this.entries.set(live.sessionId, live)
13
+ }
14
+
15
+ unregister(sessionId: string): void {
16
+ this.entries.delete(sessionId)
17
+ }
18
+
19
+ get(sessionId: string): LiveAgentSession | undefined {
20
+ return this.entries.get(sessionId)
21
+ }
22
+
23
+ has(sessionId: string): boolean {
24
+ return this.entries.has(sessionId)
25
+ }
26
+
27
+ size(): number {
28
+ return this.entries.size
29
+ }
30
+
31
+ clear(): void {
32
+ this.entries.clear()
33
+ }
34
+ }
@@ -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
+ }