typeclaw 0.1.5 → 0.1.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 (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
package/README.md CHANGED
@@ -68,18 +68,20 @@ That's it. The agent is now alive, listening on a websocket, ready to receive pr
68
68
 
69
69
  ## CLI
70
70
 
71
- | Command | Purpose |
72
- | ------------------ | -------------------------------------------------------------------- |
73
- | `typeclaw init` | Scaffold a new agent folder |
74
- | `typeclaw start` | Build and run the container |
75
- | `typeclaw stop` | Stop the container |
76
- | `typeclaw restart` | `stop` then `start` |
77
- | `typeclaw status` | Show container + daemon registration state |
78
- | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
79
- | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
80
- | `typeclaw shell` | Open a shell inside the running container |
81
- | `typeclaw reload` | Push a live config reload to the running agent |
82
- | `typeclaw compose` | Orchestrate multiple agents |
71
+ | Command | Purpose |
72
+ | ----------------------------------- | ---------------------------------------------------------------------------------- |
73
+ | `typeclaw init` | Scaffold a new agent folder |
74
+ | `typeclaw start` | Build and run the container |
75
+ | `typeclaw stop` | Stop the container |
76
+ | `typeclaw restart` | `stop` then `start` |
77
+ | `typeclaw status` | Show container + daemon registration state |
78
+ | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
79
+ | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
80
+ | `typeclaw shell` | Open a shell inside the running container |
81
+ | `typeclaw reload` | Push a live config reload to the running agent |
82
+ | `typeclaw compose` | Orchestrate multiple agents |
83
+ | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk) |
84
+ | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
83
85
 
84
86
  ## Configuration
85
87
 
package/auth.schema.json CHANGED
@@ -233,6 +233,47 @@
233
233
  },
234
234
  "updated_at": {
235
235
  "type": "string"
236
+ },
237
+ "email": {
238
+ "type": "string"
239
+ },
240
+ "encryptedPassword": {
241
+ "type": "object",
242
+ "properties": {
243
+ "v": {
244
+ "type": "number",
245
+ "const": 1
246
+ },
247
+ "alg": {
248
+ "type": "string",
249
+ "const": "AES-256-GCM"
250
+ },
251
+ "kid": {
252
+ "type": "string"
253
+ },
254
+ "iv": {
255
+ "type": "string"
256
+ },
257
+ "ciphertext": {
258
+ "type": "string"
259
+ },
260
+ "authTag": {
261
+ "type": "string"
262
+ },
263
+ "createdAt": {
264
+ "type": "string"
265
+ }
266
+ },
267
+ "required": [
268
+ "v",
269
+ "alg",
270
+ "kid",
271
+ "iv",
272
+ "ciphertext",
273
+ "authTag",
274
+ "createdAt"
275
+ ],
276
+ "additionalProperties": false
236
277
  }
237
278
  },
238
279
  "required": [
package/cron.schema.json CHANGED
@@ -29,6 +29,10 @@
29
29
  "timezone": {
30
30
  "type": "string"
31
31
  },
32
+ "scheduledByRole": {
33
+ "type": "string"
34
+ },
35
+ "scheduledByOrigin": {},
32
36
  "kind": {
33
37
  "type": "string",
34
38
  "const": "prompt"
@@ -69,6 +73,10 @@
69
73
  "timezone": {
70
74
  "type": "string"
71
75
  },
76
+ "scheduledByRole": {
77
+ "type": "string"
78
+ },
79
+ "scheduledByOrigin": {},
72
80
  "kind": {
73
81
  "type": "string",
74
82
  "const": "exec"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -233,6 +233,47 @@
233
233
  },
234
234
  "updated_at": {
235
235
  "type": "string"
236
+ },
237
+ "email": {
238
+ "type": "string"
239
+ },
240
+ "encryptedPassword": {
241
+ "type": "object",
242
+ "properties": {
243
+ "v": {
244
+ "type": "number",
245
+ "const": 1
246
+ },
247
+ "alg": {
248
+ "type": "string",
249
+ "const": "AES-256-GCM"
250
+ },
251
+ "kid": {
252
+ "type": "string"
253
+ },
254
+ "iv": {
255
+ "type": "string"
256
+ },
257
+ "ciphertext": {
258
+ "type": "string"
259
+ },
260
+ "authTag": {
261
+ "type": "string"
262
+ },
263
+ "createdAt": {
264
+ "type": "string"
265
+ }
266
+ },
267
+ "required": [
268
+ "v",
269
+ "alg",
270
+ "kid",
271
+ "iv",
272
+ "ciphertext",
273
+ "authTag",
274
+ "createdAt"
275
+ ],
276
+ "additionalProperties": false
236
277
  }
237
278
  },
238
279
  "required": [
package/src/agent/auth.ts CHANGED
@@ -23,12 +23,18 @@ function secretsJsonPath(): string {
23
23
  return join(process.cwd(), 'secrets.json')
24
24
  }
25
25
 
26
- let cached: Auth | null = null
26
+ // Per-provider cache. Sessions that use a profile mapped to provider X share
27
+ // a single AuthStorage + ModelRegistry for that provider; first use of a new
28
+ // provider lazily resolves its credentials. This replaces the singleton
29
+ // `getAuth()` from before multi-model — the singleton couldn't represent
30
+ // "auth for `default` is OpenAI, auth for `vision` is Fireworks" without
31
+ // constructing both at boot.
32
+ const cached = new Map<KnownProviderId, Auth>()
33
+
34
+ export function getAuthFor(providerId: KnownProviderId): Auth {
35
+ const existing = cached.get(providerId)
36
+ if (existing) return existing
27
37
 
28
- export function getAuth(): Auth {
29
- if (cached) return cached
30
-
31
- const providerId = providerForModelRef(getConfig().model)
32
38
  const provider = KNOWN_PROVIDERS[providerId]
33
39
 
34
40
  if (process.env.NODE_ENV === 'test' && !hasAnyCredentialInEnv(provider.apiKeyEnv)) {
@@ -37,8 +43,9 @@ export function getAuth(): Auth {
37
43
  authStorage.setRuntimeApiKey(provider.id, TEST_DUMMY_API_KEY)
38
44
  }
39
45
  const modelRegistry = ModelRegistry.create(authStorage)
40
- cached = { authStorage, modelRegistry }
41
- return cached
46
+ const auth = { authStorage, modelRegistry }
47
+ cached.set(providerId, auth)
48
+ return auth
42
49
  }
43
50
 
44
51
  const authStorage = createSecretsStoreForAgent(secretsJsonPath())
@@ -53,16 +60,11 @@ export function getAuth(): Auth {
53
60
  // is set. OAuth credentials in the file still take precedence on read
54
61
  // because AuthStorage's hasAuth checks runtimeOverrides first only for
55
62
  // api-key-shaped credentials — OAuth on disk wins on its own.
56
- //
57
- // The previous code branch that wrote the env value into secrets.json and
58
- // stripped the matching `.env` line has been removed. Env values stay in
59
- // env; the file stays user-owned. See src/secrets/hydrate.ts for the same
60
- // policy on the channels side.
61
63
  if (supportsApiKey(provider) && provider.apiKeyEnv) {
62
64
  const envKey = process.env[provider.apiKeyEnv]
63
65
  if (envKey !== undefined && envKey !== '') {
64
- const existing = authStorage.get(provider.id)
65
- if (existing === undefined || existing.type === 'api_key') {
66
+ const existingCred = authStorage.get(provider.id)
67
+ if (existingCred === undefined || existingCred.type === 'api_key') {
66
68
  authStorage.setRuntimeApiKey(provider.id, envKey)
67
69
  }
68
70
  }
@@ -74,12 +76,20 @@ export function getAuth(): Auth {
74
76
  }
75
77
 
76
78
  const modelRegistry = ModelRegistry.create(authStorage)
77
- cached = { authStorage, modelRegistry }
78
- return cached
79
+ const auth = { authStorage, modelRegistry }
80
+ cached.set(providerId, auth)
81
+ return auth
82
+ }
83
+
84
+ // Back-compat shim for callers that still want the `default` profile's auth
85
+ // (the main session path). Equivalent to `getAuthFor(provider-of-default)`.
86
+ export function getAuth(): Auth {
87
+ const defaultRef = getConfig().models.default
88
+ return getAuthFor(providerForModelRef(defaultRef))
79
89
  }
80
90
 
81
91
  export function resetAuthForTesting(): void {
82
- cached = null
92
+ cached.clear()
83
93
  }
84
94
 
85
95
  function hasAnyCredentialInEnv(apiKeyEnv: string | null): boolean {
@@ -88,19 +98,32 @@ function hasAnyCredentialInEnv(apiKeyEnv: string | null): boolean {
88
98
 
89
99
  function missingCredentialMessage(providerId: KnownProviderId): string {
90
100
  const provider = KNOWN_PROVIDERS[providerId]
91
- const ref = getConfig().model
92
- const slash = ref.indexOf('/')
101
+ const defaultRef = getConfig().models.default
102
+ const defaultProviderId = providerForModelRef(defaultRef)
103
+ // For the `default` profile, name the model in the error message (matches
104
+ // pre-multi-model behavior). For any other profile, the user is mixing
105
+ // providers across profiles and the error must name the failing provider
106
+ // without claiming it's tied to the `default` model.
107
+ const isDefault = defaultProviderId === providerId
108
+ const ref = isDefault ? defaultRef : null
93
109
  const modelName =
94
- (provider.models as Record<string, { name: string }>)[ref.slice(slash + 1)]?.name ?? ref.slice(slash + 1)
110
+ ref !== null
111
+ ? ((provider.models as Record<string, { name: string }>)[ref.slice(ref.indexOf('/') + 1)]?.name ??
112
+ ref.slice(ref.indexOf('/') + 1))
113
+ : null
95
114
 
96
115
  const oauthOnly = supportsOAuth(provider) && !supportsApiKey(provider)
97
116
  const apiKeyOnly = supportsApiKey(provider) && !supportsOAuth(provider)
98
117
 
99
118
  if (oauthOnly) {
100
- return `No credentials for ${provider.name}. Run \`typeclaw init\` and pick "OAuth" to log in to ${modelName}.`
119
+ return modelName
120
+ ? `No credentials for ${provider.name}. Run \`typeclaw init\` and pick "OAuth" to log in to ${modelName}.`
121
+ : `No credentials for ${provider.name} (referenced by a non-default profile). Run \`typeclaw init\` and pick "OAuth" to log in.`
101
122
  }
102
123
  if (apiKeyOnly && provider.apiKeyEnv) {
103
- return `Set ${provider.apiKeyEnv} in .env (or secrets.json#providers.${provider.id}.key.value) to use ${modelName} via ${provider.name}.`
124
+ return modelName
125
+ ? `Set ${provider.apiKeyEnv} in .env (or secrets.json#providers.${provider.id}.key.value) to use ${modelName} via ${provider.name}.`
126
+ : `Set ${provider.apiKeyEnv} in .env (or secrets.json#providers.${provider.id}.key.value) to use ${provider.name} (referenced by a non-default profile).`
104
127
  }
105
128
  return `No credentials for ${provider.name}. Either set ${provider.apiKeyEnv ?? '<api-key-env>'} in .env or run \`typeclaw init\` and pick "OAuth".`
106
129
  }
@@ -5,8 +5,11 @@ import { fileURLToPath } from 'node:url'
5
5
  import { createAgentSession, DefaultResourceLoader, SessionManager } from '@mariozechner/pi-coding-agent'
6
6
  import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent'
7
7
 
8
+ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
8
9
  import type { ChannelRouter } from '@/channels/router'
9
- import { getConfig, resolveModel } from '@/config'
10
+ import { getConfig, resolveModel, resolveProfile } from '@/config'
11
+ import { providerForModelRef } from '@/config/providers'
12
+ import type { PermissionService } from '@/permissions'
10
13
  import type {
11
14
  BuiltinToolRef,
12
15
  HookBus,
@@ -19,14 +22,21 @@ import { materializeSkills } from '@/plugin'
19
22
  import type { ReloadRegistry } from '@/reload'
20
23
  import type { Stream } from '@/stream'
21
24
 
22
- import { getAuth } from './auth'
25
+ import { getAuthFor } from './auth'
23
26
  import { createCompactionSettingsManager } from './compaction'
24
27
  import { renderGitNudge } from './git-nudge'
28
+ import { lookAtTool } from './multimodal'
25
29
  import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystemTool } from './plugin-tools'
26
30
  import { createReloadTool } from './reload-tool'
27
31
  import { loadSelf } from './self'
28
- import { renderSessionOrigin, type SessionOrigin } from './session-origin'
32
+ import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
29
33
  import { DEFAULT_SYSTEM_PROMPT } from './system-prompt'
34
+ import {
35
+ createBudgetState,
36
+ type ToolResultBudget,
37
+ wrapAgentToolWithBudget,
38
+ wrapToolDefinitionWithBudget,
39
+ } from './tool-result-budget'
30
40
  import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
31
41
  import { createChannelHistoryTool } from './tools/channel-history'
32
42
  import { createChannelReplyTool } from './tools/channel-reply'
@@ -56,6 +66,15 @@ export type PluginSubagentSelection = {
56
66
  toolNamePrefix: string
57
67
  }
58
68
 
69
+ // Mutable holder for the live session origin. Pass this when the origin
70
+ // must be updated turn-by-turn after session creation (channel sessions
71
+ // whose `lastInboundAuthorId` changes with each inbound message). Tool
72
+ // wrappers read `.current` at execute time, not at wrap time, so the
73
+ // `tool.before` event carries the per-turn actor identity rather than the
74
+ // stale session-creation snapshot. Sessions that never mutate origin
75
+ // (TUI, cron, subagent) can omit it and pass `origin` instead.
76
+ export type SessionOriginRef = { current: SessionOrigin | undefined }
77
+
59
78
  export type CreateSessionOptions = {
60
79
  reloadRegistry?: ReloadRegistry
61
80
  sessionManager?: SessionManager
@@ -68,6 +87,13 @@ export type CreateSessionOptions = {
68
87
  // Rendered into the system prompt so the agent knows who's listening, where
69
88
  // its output goes, and what to pass to channel_send.
70
89
  origin?: SessionOrigin
90
+ // Live origin holder. When provided, the tool wrappers read this at execute
91
+ // time so `tool.before` events see the current-turn origin. Caller is
92
+ // responsible for keeping `.current` up to date. If both `origin` and
93
+ // `originRef` are passed, the ref wins for tool stamping; the static
94
+ // `origin` still drives the initial system-prompt rendering and channel
95
+ // tool addressing (those are only valid at session-creation time).
96
+ originRef?: SessionOriginRef
71
97
  tools?: AgentSessionTools
72
98
  customTools?: ToolDefinition[]
73
99
  plugins?: PluginSessionWiring
@@ -78,6 +104,41 @@ export type CreateSessionOptions = {
78
104
  // Enables the `restart` tool. Set when the agent is running inside a
79
105
  // typeclaw-managed container. Read from TYPECLAW_CONTAINER_NAME at the call site.
80
106
  containerName?: string
107
+ // The permission service the runtime resolved at boot. When provided, the
108
+ // resolved role and permission list for `options.origin` are rendered into
109
+ // the system prompt under `## Your role in this session`. The block is
110
+ // emitted for channel/cron/subagent sessions, and for TUI sessions only
111
+ // when the resolved role is not the built-in `owner` (because TUI
112
+ // resolving to `owner` is the common case and we save tokens on every
113
+ // interactive session). Omitting `permissions` falls back to the previous
114
+ // behavior (no role annotation), which is what tests and stand-alone
115
+ // callers want.
116
+ //
117
+ // The role rendered here is a session-creation snapshot. Channel sessions
118
+ // re-resolve per-turn through `originRef` for tool gating, but the system
119
+ // prompt is not regenerated; see `typeclaw-permissions` skill for how the
120
+ // agent should interpret the snapshot on later turns.
121
+ permissions?: PermissionService
122
+ // Model profile name. Resolved against `config.models` to pick the concrete
123
+ // model ref this session binds to. Unknown profile names fall back to
124
+ // `default` with a one-time console warning. Omitted → `default`. Threaded
125
+ // through from the caller (subagent declarations, future per-spawn tool
126
+ // overrides) so different sessions on the same agent can run different
127
+ // models without per-session config edits.
128
+ profile?: string
129
+ // Defensive ceiling on cumulative bytes of tool-result text per session,
130
+ // applied to the named tools only. See `src/agent/tool-result-budget.ts`
131
+ // for the rationale. Intended for subagents that read large files
132
+ // (memory-logger, dreaming); leaving this undefined disables the budget
133
+ // entirely, which is the right default for TUI / channel / plugin-tool
134
+ // sessions where the human (or hooks) bound tool-result size.
135
+ toolResultBudget?: ToolResultBudget
136
+ // Optional override for the message returned to the agent once
137
+ // `toolResultBudget` is exhausted. Subagents whose recovery path differs
138
+ // from the default ("advance the watermark from a recent id you have
139
+ // already seen") provide their own here. See `ToolResultBudget` for the
140
+ // shared shape.
141
+ toolResultBudgetMessage?: ToolResultBudget['exhaustedMessage']
81
142
  }
82
143
 
83
144
  export type CreateSessionResult = {
@@ -91,7 +152,11 @@ export async function createSession(options: CreateSessionOptions = {}): Promise
91
152
  }
92
153
 
93
154
  export async function createSessionWithDispose(options: CreateSessionOptions = {}): Promise<CreateSessionResult> {
94
- const { authStorage, modelRegistry } = getAuth()
155
+ const resolved = resolveProfile(getConfig().models, options.profile)
156
+ if (resolved.fellBackToDefault && options.profile !== undefined && options.profile !== 'default') {
157
+ warnProfileFallbackOnce(options.profile, resolved.ref)
158
+ }
159
+ const { authStorage, modelRegistry } = getAuthFor(providerForModelRef(resolved.ref))
95
160
 
96
161
  const materializedSkills =
97
162
  options.plugins && options.plugins.registry.skills.length > 0
@@ -106,23 +171,46 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
106
171
 
107
172
  const resourceLoader =
108
173
  options.systemPromptOverride !== undefined
109
- ? await createOverrideResourceLoader(options.systemPromptOverride, options.origin)
174
+ ? await createOverrideResourceLoader(options.systemPromptOverride, options.origin, options.permissions)
110
175
  : await createResourceLoader({
111
176
  ...(options.plugins ? { plugins: options.plugins, materializedSkills } : {}),
112
177
  ...(options.origin ? { origin: options.origin } : {}),
178
+ ...(options.permissions ? { permissions: options.permissions } : {}),
113
179
  })
114
180
 
181
+ const getOrigin: () => SessionOrigin | undefined =
182
+ options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
183
+
115
184
  const subagentBuiltinTools = options.pluginSubagent?.toolRefs
116
185
  ? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
117
186
  : undefined
118
187
  const pluginCustomTools = options.pluginSubagent
119
- ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins)
120
- : wrapRegistryTools(options.plugins)
188
+ ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
189
+ : wrapRegistryTools(options.plugins, getOrigin)
190
+
191
+ // Per-run budget state for the tool-result byte ceiling. Allocated once per
192
+ // session creation and threaded into every wrapped tool so they share the
193
+ // same counter. Only used when the session declares a budget; the wrappers
194
+ // pass non-listed tools through unchanged, so the counter stays at zero for
195
+ // sessions without a budget configured.
196
+ const sessionBudget: ToolResultBudget | undefined = options.toolResultBudget
197
+ ? options.toolResultBudgetMessage !== undefined
198
+ ? { ...options.toolResultBudget, exhaustedMessage: options.toolResultBudgetMessage }
199
+ : options.toolResultBudget
200
+ : undefined
201
+ const sessionBudgetState = sessionBudget ? createBudgetState() : undefined
121
202
 
122
- const tools = wrapSystemAgentTools(
203
+ const hookWrappedTools = wrapSystemAgentTools(
123
204
  options.tools ?? (subagentBuiltinTools as AgentSessionTools | undefined),
124
205
  options.plugins,
206
+ getOrigin,
125
207
  )
208
+ const tools =
209
+ sessionBudget && sessionBudgetState && hookWrappedTools
210
+ ? (hookWrappedTools.map((t) =>
211
+ wrapAgentToolWithBudget(t, sessionBudget, sessionBudgetState),
212
+ ) as typeof hookWrappedTools)
213
+ : hookWrappedTools
126
214
 
127
215
  // Hoisted above tool construction so the restart tool can be wired with the
128
216
  // session's stable identity (sessionManager.getSessionId()). Subscribers use
@@ -138,6 +226,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
138
226
  : [
139
227
  websearchTool,
140
228
  webfetchTool,
229
+ lookAtTool,
141
230
  ...(options.reloadRegistry ? [createReloadTool({ registry: options.reloadRegistry })] : []),
142
231
  ...(options.stream ? [createStreamSnapshotTool({ stream: options.stream })] : []),
143
232
  ...buildChannelTools(options.channelRouter, options.origin),
@@ -151,9 +240,13 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
151
240
  ]
152
241
  : []),
153
242
  ]
154
- const customTools = [...wrapSystemTools(customSystemTools, options.plugins), ...pluginCustomTools]
243
+ const customToolsPreBudget = [...wrapSystemTools(customSystemTools, options.plugins, getOrigin), ...pluginCustomTools]
244
+ const customTools =
245
+ sessionBudget && sessionBudgetState
246
+ ? customToolsPreBudget.map((t) => wrapToolDefinitionWithBudget(t, sessionBudget, sessionBudgetState))
247
+ : customToolsPreBudget
155
248
 
156
- const model = resolveModel(getConfig().model)
249
+ const model = resolveModel(resolved.ref)
157
250
  const { session } = await createAgentSession({
158
251
  model,
159
252
  sessionManager,
@@ -294,7 +387,10 @@ export function buildChannelTools(
294
387
  return tools
295
388
  }
296
389
 
297
- function wrapRegistryTools(plugins: PluginSessionWiring | undefined): ToolDefinition[] {
390
+ function wrapRegistryTools(
391
+ plugins: PluginSessionWiring | undefined,
392
+ getOrigin: () => SessionOrigin | undefined,
393
+ ): ToolDefinition[] {
298
394
  if (!plugins) return []
299
395
  return plugins.registry.tools.map((t: PluginRegisteredTool) =>
300
396
  wrapPluginTool(t.tool, {
@@ -304,6 +400,7 @@ function wrapRegistryTools(plugins: PluginSessionWiring | undefined): ToolDefini
304
400
  sessionId: plugins.sessionId,
305
401
  logger: t.logger,
306
402
  hooks: plugins.hooks,
403
+ getOrigin,
307
404
  }),
308
405
  )
309
406
  }
@@ -311,6 +408,7 @@ function wrapRegistryTools(plugins: PluginSessionWiring | undefined): ToolDefini
311
408
  function wrapSystemAgentTools(
312
409
  tools: AgentSessionTools | undefined,
313
410
  plugins: PluginSessionWiring | undefined,
411
+ getOrigin: () => SessionOrigin | undefined,
314
412
  ): AgentSessionTools | undefined {
315
413
  if (!tools || !hasToolHooks(plugins)) return tools
316
414
  return tools.map((tool) =>
@@ -318,17 +416,23 @@ function wrapSystemAgentTools(
318
416
  agentDir: plugins.agentDir,
319
417
  sessionId: plugins.sessionId,
320
418
  hooks: plugins.hooks,
419
+ getOrigin,
321
420
  }),
322
421
  )
323
422
  }
324
423
 
325
- function wrapSystemTools(tools: ToolDefinition[], plugins: PluginSessionWiring | undefined): ToolDefinition[] {
424
+ function wrapSystemTools(
425
+ tools: ToolDefinition[],
426
+ plugins: PluginSessionWiring | undefined,
427
+ getOrigin: () => SessionOrigin | undefined,
428
+ ): ToolDefinition[] {
326
429
  if (!hasToolHooks(plugins)) return tools
327
430
  return tools.map((tool) =>
328
431
  wrapSystemTool(tool, {
329
432
  agentDir: plugins.agentDir,
330
433
  sessionId: plugins.sessionId,
331
434
  hooks: plugins.hooks,
435
+ getOrigin,
332
436
  }),
333
437
  )
334
438
  }
@@ -341,6 +445,7 @@ function hasToolHooks(plugins: PluginSessionWiring | undefined): plugins is Plug
341
445
  function wrapSubagentCustomTools(
342
446
  selection: PluginSubagentSelection,
343
447
  plugins: PluginSessionWiring | undefined,
448
+ getOrigin: () => SessionOrigin | undefined,
344
449
  ): ToolDefinition[] {
345
450
  if (!selection.customTools || !plugins) return []
346
451
  const logger = makePluginLogger(selection.pluginName)
@@ -352,6 +457,7 @@ function wrapSubagentCustomTools(
352
457
  sessionId: plugins.sessionId,
353
458
  logger,
354
459
  hooks: plugins.hooks,
460
+ getOrigin,
355
461
  }),
356
462
  )
357
463
  }
@@ -368,9 +474,11 @@ function makePluginLogger(pluginName: string) {
368
474
  export async function createOverrideResourceLoader(
369
475
  systemPrompt: string,
370
476
  origin?: SessionOrigin,
477
+ permissions?: PermissionService,
371
478
  ): Promise<DefaultResourceLoader> {
479
+ const finalPrompt = withOrigin(systemPrompt, origin, permissions)
372
480
  const loader = new DefaultResourceLoader({
373
- systemPromptOverride: () => withOrigin(systemPrompt, origin),
481
+ systemPromptOverride: () => finalPrompt,
374
482
  appendSystemPromptOverride: () => [],
375
483
  })
376
484
  await loader.reload()
@@ -382,6 +490,7 @@ export type CreateResourceLoaderOptions = {
382
490
  plugins?: PluginSessionWiring
383
491
  materializedSkills?: MaterializedSkills | null
384
492
  origin?: SessionOrigin
493
+ permissions?: PermissionService
385
494
  }
386
495
 
387
496
  export async function createResourceLoader(options: CreateResourceLoaderOptions = {}): Promise<DefaultResourceLoader> {
@@ -395,14 +504,32 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
395
504
  systemPrompt = event.prompt
396
505
  }
397
506
 
398
- // Appended last so the dirty-files snapshot is the most-recent context the
399
- // agent reads, and so its bytes sit in the cache-suffix region rather than
400
- // splitting the cacheable prefix shared by clean-worktree sessions.
507
+ // Cache-suffix ordering: least-volatile sections first, most-volatile last.
508
+ // This minimises the number of cached prompt bytes invalidated when a
509
+ // section changes (the provider's prompt cache hits up to the first byte
510
+ // that differs).
511
+ //
512
+ // 1. origin block — stable across all sessions of the same kind.
513
+ // 2. gitNudge — rare changes; agent folders force-commit sessions/ and
514
+ // memory/ after every turn, so the dirty-files list is empty most of
515
+ // the time.
516
+ // 3. memorySection — most volatile: MEMORY.md grows on every dream cycle
517
+ // and memory/yyyy-MM-dd.md grows after every channel turn that triggers
518
+ // memory-logger. Pinning it to the end keeps everything above it
519
+ // cacheable across session resurrections.
520
+ systemPrompt = withOrigin(systemPrompt, options.origin, options.permissions)
521
+
401
522
  const gitNudge = await renderGitNudge(agentDir)
402
523
  if (gitNudge !== '') {
403
524
  systemPrompt = `${systemPrompt}\n\n${gitNudge}`
404
525
  }
405
526
 
527
+ const memorySection = await loadMemory(agentDir, {
528
+ ...(options.origin !== undefined ? { origin: options.origin } : {}),
529
+ ...(options.plugins?.sessionId !== undefined ? { currentSessionId: options.plugins.sessionId } : {}),
530
+ })
531
+ systemPrompt = `${systemPrompt}\n\n${memorySection}`
532
+
406
533
  const additionalSkillPaths = [getBundledSkillsDir()]
407
534
  // pi-coding-agent's DefaultResourceLoader auto-discovers <agentDir>/skills/
408
535
  // but not <agentDir>/.agents/skills/. We do not scaffold <agentDir>/skills/
@@ -433,7 +560,7 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
433
560
  }
434
561
 
435
562
  const loader = new DefaultResourceLoader({
436
- systemPromptOverride: () => withOrigin(systemPrompt, options.origin),
563
+ systemPromptOverride: () => systemPrompt,
437
564
  appendSystemPromptOverride: () => [],
438
565
  additionalSkillPaths,
439
566
  })
@@ -441,11 +568,54 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
441
568
  return loader
442
569
  }
443
570
 
444
- function withOrigin(systemPrompt: string, origin: SessionOrigin | undefined): string {
571
+ function withOrigin(
572
+ systemPrompt: string,
573
+ origin: SessionOrigin | undefined,
574
+ permissions: PermissionService | undefined,
575
+ ): string {
445
576
  if (!origin) return systemPrompt
446
- return `${systemPrompt}\n\n${renderSessionOrigin(origin)}`
577
+ const roleContext = resolveRoleContext(origin, permissions)
578
+ return `${systemPrompt}\n\n${renderSessionOrigin(origin, Date.now(), roleContext)}`
579
+ }
580
+
581
+ function resolveRoleContext(
582
+ origin: SessionOrigin,
583
+ permissions: PermissionService | undefined,
584
+ ): SessionRoleContext | undefined {
585
+ if (permissions === undefined) return undefined
586
+ const described = permissions.describe(origin)
587
+ // TUI normally resolves to `owner` via the built-in `owner.match = [tui]`
588
+ // entry, and we skip the role block in that case to save tokens on every
589
+ // interactive session. But user-declared roles can match TUI first (the
590
+ // resolver is first-match-wins in declaration order), so a non-owner TUI
591
+ // role is possible and the agent needs to see it. The "TUI is always owner"
592
+ // shorthand in docs is the common case, not an invariant.
593
+ if (origin.kind === 'tui' && described.role === 'owner') return undefined
594
+ return described
447
595
  }
448
596
 
449
597
  export function getBundledSkillsDir(): string {
450
598
  return join(dirname(fileURLToPath(import.meta.url)), '..', 'skills')
451
599
  }
600
+
601
+ // Profile-fallback warning is fired once per (profile, ref) pair per process.
602
+ // Without rate-limiting, every memory-logger spawn (~every idle event) would
603
+ // emit a fresh warning when the user has only `default` configured — tens of
604
+ // warnings per channel session is noise the operator will learn to ignore.
605
+ // The pair includes `ref` so a config reload that changes `default` re-warns.
606
+ const profileFallbackWarned = new Set<string>()
607
+
608
+ function warnProfileFallbackOnce(profile: string, ref: string): void {
609
+ const key = `${profile}\x00${ref}`
610
+ if (profileFallbackWarned.has(key)) return
611
+ profileFallbackWarned.add(key)
612
+ console.warn(
613
+ `[agent] unknown model profile "${profile}"; falling back to "default" (${ref}). Add it under \`models\` in typeclaw.json to remove this warning. (further occurrences suppressed)`,
614
+ )
615
+ }
616
+
617
+ // Test-only: clear the rate-limit cache so a test can assert the warning fires
618
+ // once after rate-limit reset.
619
+ export function __resetProfileFallbackWarningsForTesting(): void {
620
+ profileFallbackWarned.clear()
621
+ }