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
@@ -22,7 +22,13 @@ export type ChannelOriginContext = {
22
22
 
23
23
  export type SessionOrigin =
24
24
  | { kind: 'tui'; sessionId: string }
25
- | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
25
+ | {
26
+ kind: 'cron'
27
+ jobId: string
28
+ jobKind: 'prompt' | 'exec' | 'subagent'
29
+ scheduledByRole?: string
30
+ scheduledByOrigin?: SessionOrigin | { kind: 'config-file' }
31
+ }
26
32
  | {
27
33
  kind: 'channel'
28
34
  adapter: AdapterId
@@ -35,24 +41,105 @@ export type SessionOrigin =
35
41
  participants?: readonly ChannelParticipant[]
36
42
  membership?: MembershipCount
37
43
  }
38
- | { kind: 'subagent'; subagent: string; parentSessionId: string }
44
+ | {
45
+ kind: 'subagent'
46
+ subagent: string
47
+ parentSessionId: string
48
+ spawnedByRole?: string
49
+ spawnedByOrigin?: SessionOrigin
50
+ }
39
51
 
40
52
  export const PARTICIPANTS_TOP_K = 10
41
53
  export const PARTICIPANTS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
42
54
 
43
- export function renderSessionOrigin(origin: SessionOrigin, now: number = Date.now()): string {
55
+ // Each adapter renders mentions differently and the model has to copy the
56
+ // exact shape to actually notify a peer. Until this table existed, the
57
+ // channel origin block hardcoded Discord syntax (`<@USER_ID>`) for every
58
+ // non-Slack adapter, which silently misled KakaoTalk and Telegram sessions
59
+ // into emitting addressing tokens that the platform doesn't recognise. The
60
+ // participants block kept rendering `<@authorId> (name)` lines for the
61
+ // same reason — see `renderParticipants`.
62
+ //
63
+ // `mentionMode` semantics:
64
+ // - 'angle-id' — Slack/Discord: `<@USER_ID>` where USER_ID is the
65
+ // raw `authorId` we already surface in participants.
66
+ // - 'at-username' — Telegram: `@username` plain text. The numeric
67
+ // `authorId` is NOT what gets mentioned; usernames are
68
+ // a separate field that not every user has.
69
+ // - 'alias' — KakaoTalk: type the bot's alias as plain text. The
70
+ // adapter's classifier (`kakaotalk-classify.ts`) does
71
+ // a substring match against configured aliases; there
72
+ // is no in-band syntax to copy.
73
+ type PlatformInfo = {
74
+ displayName: string
75
+ mentionMode: 'angle-id' | 'at-username' | 'alias'
76
+ }
77
+
78
+ const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
79
+ 'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
80
+ 'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
81
+ 'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
82
+ kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
83
+ }
84
+
85
+ function getPlatformInfo(adapter: AdapterId): PlatformInfo {
86
+ return PLATFORM_INFO[adapter]
87
+ }
88
+
89
+ // Compact description of the role the runtime resolved for this session at
90
+ // creation time. Rendered as a single block under the origin text for
91
+ // non-TUI sessions so the agent knows what it can and cannot do without
92
+ // having to call into the PermissionService itself. TUI is omitted because
93
+ // TUI is always `owner` by construction — annotating it would add noise to
94
+ // every interactive session for zero new information.
95
+ //
96
+ // For channel sessions this is a session-creation snapshot. The router
97
+ // re-resolves per-turn for tool gating, but the system prompt is not
98
+ // regenerated mid-session; the role line is accurate at admission and the
99
+ // `typeclaw-permissions` skill spells out how to interpret it on later
100
+ // turns when a different speaker may have spoken last.
101
+ export type SessionRoleContext = {
102
+ role: string
103
+ permissions: readonly string[]
104
+ }
105
+
106
+ export function renderSessionOrigin(
107
+ origin: SessionOrigin,
108
+ now: number = Date.now(),
109
+ roleContext?: SessionRoleContext,
110
+ ): string {
44
111
  switch (origin.kind) {
45
112
  case 'tui':
46
- return renderTuiOrigin()
113
+ return withRoleContext(renderTuiOrigin(), roleContext)
47
114
  case 'cron':
48
- return renderCronOrigin(origin)
115
+ return withRoleContext(renderCronOrigin(origin), roleContext)
49
116
  case 'channel':
50
- return renderChannelOrigin(origin, now)
117
+ return withRoleContext(renderChannelOrigin(origin, now), roleContext)
51
118
  case 'subagent':
52
- return renderSubagentOrigin(origin)
119
+ return withRoleContext(renderSubagentOrigin(origin), roleContext)
53
120
  }
54
121
  }
55
122
 
123
+ function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
124
+ if (ctx === undefined) return block
125
+ return `${block}\n\n${renderRoleContext(ctx)}`
126
+ }
127
+
128
+ function renderRoleContext(ctx: SessionRoleContext): string {
129
+ const permList = ctx.permissions.length === 0 ? 'none' : ctx.permissions.map((p) => `\`${p}\``).join(', ')
130
+ return [
131
+ '## Your role in this session',
132
+ '',
133
+ `Role: \`${ctx.role}\`. Permissions: ${permList}.`,
134
+ '',
135
+ 'This is the role the runtime resolved at session creation. Tool calls',
136
+ 'and channel admission are gated by these permissions; a `blocked:` or',
137
+ '"denied by permissions" message means the current actor lacks the',
138
+ 'permission the guard was looking for. See the `typeclaw-permissions`',
139
+ 'skill for what each role can do and how to grant access.',
140
+ ].join('\n')
141
+ }
142
+
56
143
  function renderTuiOrigin(): string {
57
144
  return [
58
145
  '## Session origin',
@@ -118,11 +205,11 @@ function renderChannelOrigin(
118
205
  // only `text` and pulls addressing from this origin. We point the model at
119
206
  // it as the default, and keep channel_send as the escape hatch for posting
120
207
  // elsewhere (different chat, breaking out of the thread on purpose, etc.).
121
- const platform = origin.adapter === 'slack-bot' ? 'Slack' : 'Discord'
208
+ const platformInfo = getPlatformInfo(origin.adapter)
122
209
  const lines: string[] = [
123
210
  '## Session origin',
124
211
  '',
125
- `You are responding inside a ${platform} channel session. There is no human`,
212
+ `You are responding inside a ${platformInfo.displayName} channel session. There is no human`,
126
213
  'attached to a console here — your only way to communicate with the user',
127
214
  'is a tool call. Plain-text output is invisible.',
128
215
  ]
@@ -157,11 +244,10 @@ function renderChannelOrigin(
157
244
  "matching the channel's `allow` rules are accepted (the tool returns",
158
245
  '`{ ok: false }` otherwise).',
159
246
  '',
160
- `To mention someone in your reply, use ${platform} syntax \`<@USER_ID>\`.`,
161
- ...renderMentionExample(origin.participants ?? [], platform, now),
247
+ ...renderMentionGuidance(platformInfo, origin.participants ?? [], now),
162
248
  )
163
249
 
164
- const participantsBlock = renderParticipants(origin.participants ?? [], now)
250
+ const participantsBlock = renderParticipants(origin.participants ?? [], platformInfo, now)
165
251
  const membershipLine = renderMembershipSummary(origin, now)
166
252
  if (membershipLine !== null) lines.push('', membershipLine)
167
253
  if (participantsBlock) lines.push('', participantsBlock)
@@ -189,23 +275,11 @@ function renderMembershipSummary(
189
275
  return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap).${caveat} The 10 most recent speakers are listed below.`
190
276
  }
191
277
 
192
- function renderMentionExample(
278
+ function renderMentionGuidance(
279
+ platformInfo: PlatformInfo,
193
280
  participants: readonly ChannelParticipant[],
194
- platform: 'Discord' | 'Slack',
195
281
  now: number,
196
282
  ): string[] {
197
- // Concrete worked example anchored on a REAL participant when possible.
198
- // Models reliably copy concrete examples; abstract `<@USER_ID>` placeholders
199
- // get treated as generic instructions and ignored. Prefer a peer bot for
200
- // the example because that's the addressing case where plain-text names
201
- // silently fail (the human path is forgiving — humans see their name and
202
- // respond regardless of mention syntax). Fall back to any non-self
203
- // participant, then to a generic placeholder if the channel is brand new.
204
- //
205
- // Apply the SAME staleness cutoff as `renderParticipants` so we never name
206
- // someone in the example who isn't shown in the participants block — that
207
- // would surface a "ghost" name from >7d ago and confuse the model about
208
- // who is actually around.
209
283
  const cutoff = now - PARTICIPANTS_MAX_AGE_MS
210
284
  const fresh = [...participants]
211
285
  .filter((p) => p.lastMessageAt >= cutoff)
@@ -214,11 +288,32 @@ function renderMentionExample(
214
288
  const anyPeer = peerBot ?? fresh[0]
215
289
  const exampleId = anyPeer?.authorId ?? '123456789'
216
290
  const exampleName = anyPeer?.authorName ?? 'PeerBot'
217
- return [
218
- `For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
219
- `**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platform},`,
220
- 'and other bots in this channel will not see the message as addressed to them.',
221
- ]
291
+
292
+ switch (platformInfo.mentionMode) {
293
+ case 'angle-id':
294
+ return [
295
+ `To mention someone in your reply, use ${platformInfo.displayName} syntax \`<@USER_ID>\`.`,
296
+ `For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
297
+ `**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platformInfo.displayName},`,
298
+ 'and other bots in this channel will not see the message as addressed to them.',
299
+ ]
300
+ case 'at-username':
301
+ return [
302
+ `To mention someone in your reply, use Telegram syntax \`@username\` in plain text.`,
303
+ `Telegram usernames are a SEPARATE field from \`authorId\`. The \`<@id>\` tokens you see in the participants`,
304
+ 'block below are a typeclaw convention for parsing inbound mentions — do not echo them back as outbound mentions.',
305
+ 'If you only know an author by their display name and they have no `@username`, address them by display name',
306
+ 'and they will see the message via the reply context.',
307
+ ]
308
+ case 'alias':
309
+ return [
310
+ 'KakaoTalk has no in-band mention syntax. To address someone, just type their display name as plain text;',
311
+ "the participants block below shows display names. To get the BOT's attention from outside this session,",
312
+ "a user types one of the bot's configured aliases — they do not need to copy any token from the participants list.",
313
+ `The \`<@id>\` tokens in the participants block below are a typeclaw convention for parsing inbound mentions —`,
314
+ 'do not echo them back as outbound mentions; KakaoTalk would render them as literal text.',
315
+ ]
316
+ }
222
317
  }
223
318
 
224
319
  function renderConversationLine(origin: {
@@ -239,26 +334,22 @@ function renderConversationLine(origin: {
239
334
  return `Conversation: ${chatLabel} in ${workspaceLabel}.`
240
335
  }
241
336
 
242
- function renderParticipants(participants: readonly ChannelParticipant[], now: number): string {
337
+ function renderParticipants(
338
+ participants: readonly ChannelParticipant[],
339
+ platformInfo: PlatformInfo,
340
+ now: number,
341
+ ): string {
243
342
  const cutoff = now - PARTICIPANTS_MAX_AGE_MS
244
343
  const fresh = participants.filter((p) => p.lastMessageAt >= cutoff)
245
344
  if (fresh.length === 0) return ''
246
345
 
247
346
  const top = [...fresh].sort((a, b) => b.lastMessageAt - a.lastMessageAt).slice(0, PARTICIPANTS_TOP_K)
248
347
 
249
- // Format flipped from `name (id: 123)` to `<@123> (name)` so the model sees
250
- // the SAME shape it will need to emit when addressing someone — copy-paste
251
- // the leading `<@id>` token verbatim. The previous format presented the
252
- // human-readable name first and the ID parenthetically, which (combined
253
- // with `<@id> (name) [bot]:` in inbound message lines) trained the model
254
- // to treat `<@id>` as Discord's render-time decoration rather than syntax
255
- // it must produce. Symptom in the wild: 돌쇠 addressing Winky as "Winky님"
256
- // (plain text), which never trips Winky's `isBotMention` check, so Winky
257
- // observes silently and the conversation stalls.
258
348
  const lines = ['## Recent participants (last 7 days, top 10 by recency)', '']
259
349
  for (const p of top) {
260
350
  const ago = formatAgo(now - p.lastMessageAt)
261
- lines.push(`- <@${p.authorId}> (${p.authorName}) — last message: ${ago}, total: ${p.messageCount}`)
351
+ const addressing = renderParticipantAddressing(p, platformInfo)
352
+ lines.push(`- ${addressing} — last message: ${ago}, total: ${p.messageCount}`)
262
353
  }
263
354
  lines.push(
264
355
  '',
@@ -268,14 +359,71 @@ function renderParticipants(participants: readonly ChannelParticipant[], now: nu
268
359
  'This is **not** the full guild member list, and **not** an audit log',
269
360
  'of everyone who ever spoke here.',
270
361
  '',
271
- "If a sender in the current turn isn't in the list, you can still",
272
- 'address them — `<@authorId>` works for any author you have seen,',
273
- 'even once. The list is a convenience for "who\'s been around lately,"',
274
- 'not an exhaustive directory.',
362
+ ...renderParticipantsTrailing(platformInfo),
275
363
  )
276
364
  return lines.join('\n')
277
365
  }
278
366
 
367
+ // Per-line addressing token shown for each participant. The shape must match
368
+ // what the model will need to emit when addressing that participant, so the
369
+ // model can copy-paste the leading token verbatim. The previous unconditional
370
+ // `<@id> (name)` format trained the model toward angle-id syntax on every
371
+ // platform — correct for Discord/Slack, wrong for KakaoTalk (no in-band
372
+ // mention syntax) and Telegram (uses `@username`, where `authorId` is a
373
+ // numeric id and NOT the username). See issue #188.
374
+ //
375
+ // Symptom in the wild before PR #183 + this fix: 돌쇠 addressing Winky as
376
+ // "Winky님" (plain text) on Discord, which never trips Winky's `isBotMention`
377
+ // check, so Winky observes silently and the conversation stalls. The
378
+ // angle-id branch here is exactly the fix for that case; the at-username
379
+ // and alias branches keep the platform contract honest for KakaoTalk and
380
+ // Telegram instead of self-contradicting the per-adapter mention guidance
381
+ // produced by `renderMentionGuidance`.
382
+ function renderParticipantAddressing(p: ChannelParticipant, platformInfo: PlatformInfo): string {
383
+ switch (platformInfo.mentionMode) {
384
+ case 'angle-id':
385
+ return `<@${p.authorId}> (${p.authorName})`
386
+ case 'at-username':
387
+ case 'alias':
388
+ return `${p.authorName} (${p.authorId})`
389
+ }
390
+ }
391
+
392
+ // Closing prose for the participants block. Mirrors the per-platform branch
393
+ // in `renderParticipantAddressing` so the trailing "address them" guidance
394
+ // matches the format the bullet points just demonstrated. The previous
395
+ // unconditional `<@authorId>` prose was the second voice in the
396
+ // self-contradiction noted in issue #188 — it told KakaoTalk/Telegram
397
+ // sessions to address peers with a syntax `renderMentionGuidance` had
398
+ // just told them not to use.
399
+ function renderParticipantsTrailing(platformInfo: PlatformInfo): string[] {
400
+ switch (platformInfo.mentionMode) {
401
+ case 'angle-id':
402
+ return [
403
+ "If a sender in the current turn isn't in the list, you can still",
404
+ 'address them — `<@authorId>` works for any author you have seen,',
405
+ 'even once. The list is a convenience for "who\'s been around lately,"',
406
+ 'not an exhaustive directory.',
407
+ ]
408
+ case 'at-username':
409
+ return [
410
+ "If a sender in the current turn isn't in the list, you can still",
411
+ 'address them by `@username` — Telegram usernames are a SEPARATE field',
412
+ 'from the numeric `authorId` shown in parentheses above, and not every',
413
+ 'user has one. The list is a convenience for "who\'s been around',
414
+ 'lately," not an exhaustive directory.',
415
+ ]
416
+ case 'alias':
417
+ return [
418
+ "If a sender in the current turn isn't in the list, you can still",
419
+ 'address them by display name as plain text — KakaoTalk has no in-band',
420
+ 'mention syntax, so the `authorId` shown in parentheses above is for',
421
+ 'your reference only and must not be echoed back. The list is a',
422
+ 'convenience for "who\'s been around lately," not an exhaustive directory.',
423
+ ]
424
+ }
425
+ }
426
+
279
427
  function formatAgo(ms: number): string {
280
428
  const sec = Math.max(0, Math.round(ms / 1000))
281
429
  if (sec < 60) return `${sec} seconds ago`
@@ -6,6 +6,7 @@ import type { Stream, Unsubscribe } from '@/stream'
6
6
 
7
7
  import { type AgentSession, createSession } from './index'
8
8
  import type { SessionOrigin } from './session-origin'
9
+ import type { ToolResultBudget } from './tool-result-budget'
9
10
 
10
11
  type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
11
12
 
@@ -19,10 +20,15 @@ export type RunSession = (override?: { userPrompt?: string }) => Promise<void>
19
20
 
20
21
  export type Subagent<P = unknown> = {
21
22
  systemPrompt: string
23
+ // Model profile this subagent prefers. Resolved against `config.models` at
24
+ // session construction. Unknown profile names fall back to `default` with
25
+ // a warning. See `Subagent` in `@/plugin/types` for the full contract.
26
+ profile?: string
22
27
  tools?: AgentSessionTools
23
28
  customTools?: ToolDefinition[]
24
29
  payloadSchema?: z.ZodType<P>
25
30
  handler?: (ctx: SubagentContext<P>, runSession: RunSession) => Promise<void>
31
+ toolResultBudget?: ToolResultBudget
26
32
  }
27
33
 
28
34
  export type SubagentRegistry = Readonly<Record<string, Subagent<any>>>
@@ -62,6 +68,8 @@ export type CreateSessionForSubagentResult = {
62
68
  export type CreateSessionForSubagentOptions = {
63
69
  name?: string
64
70
  parentSessionId?: string
71
+ spawnedByRole?: string
72
+ spawnedByOrigin?: SessionOrigin
65
73
  }
66
74
  export type CreateSessionForSubagent = (
67
75
  subagent: Subagent<any>,
@@ -75,9 +83,13 @@ export const defaultCreateSessionForSubagent: CreateSessionForSubagent = (subage
75
83
  kind: 'subagent',
76
84
  subagent: options?.name ?? '<unknown>',
77
85
  parentSessionId: options?.parentSessionId ?? '<unknown>',
86
+ ...(options?.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
87
+ ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
78
88
  },
79
89
  ...(subagent.tools ? { tools: subagent.tools } : {}),
80
90
  customTools: subagent.customTools ?? [],
91
+ ...(subagent.profile !== undefined ? { profile: subagent.profile } : {}),
92
+ ...(subagent.toolResultBudget !== undefined ? { toolResultBudget: subagent.toolResultBudget } : {}),
81
93
  })
82
94
 
83
95
  type NormalizedSubagentSession = {
@@ -120,6 +132,8 @@ export type InvokeSubagentOptions = {
120
132
  userPrompt: string
121
133
  payload?: unknown
122
134
  parentSessionId?: string
135
+ spawnedByRole?: string
136
+ spawnedByOrigin?: SessionOrigin
123
137
  }
124
138
 
125
139
  export async function invokeSubagent(name: string, options: InvokeSubagentOptions): Promise<void> {
@@ -131,6 +145,8 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
131
145
  const sessionOptions: CreateSessionForSubagentOptions = {
132
146
  name,
133
147
  ...(options.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
148
+ ...(options.spawnedByRole !== undefined ? { spawnedByRole: options.spawnedByRole } : {}),
149
+ ...(options.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
134
150
  }
135
151
 
136
152
  const runSession: RunSession = async (override) => {
@@ -157,11 +173,12 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
157
173
  sessionId,
158
174
  parentTranscriptPath: getTranscriptPath?.(),
159
175
  idleMs: 0,
176
+ ...(origin !== undefined ? { origin } : {}),
160
177
  })
161
178
  }
162
179
  } finally {
163
180
  if (hooks && sessionId !== undefined) {
164
- await hooks.runSessionEnd({ sessionId })
181
+ await hooks.runSessionEnd({ sessionId, ...(origin !== undefined ? { origin } : {}) })
165
182
  }
166
183
  session.dispose()
167
184
  await dispose()
@@ -215,6 +232,40 @@ const consoleLogger: SubagentConsumerLogger = {
215
232
  error: (m) => console.error(m),
216
233
  }
217
234
 
235
+ function parseSpawnedByOriginJson(
236
+ raw: string | undefined,
237
+ logger: SubagentConsumerLogger,
238
+ subagentName: string,
239
+ ): SessionOrigin | undefined {
240
+ if (raw === undefined) return undefined
241
+ let parsed: unknown
242
+ try {
243
+ parsed = JSON.parse(raw)
244
+ } catch (err) {
245
+ const message = err instanceof Error ? err.message : String(err)
246
+ logger.warn(`[subagent] ${subagentName}: ignoring malformed spawnedByOriginJson on stream target: ${message}`)
247
+ return undefined
248
+ }
249
+ // Shape-validate the decoded value so a malformed sender (or a future
250
+ // bug in cron consumer's encode side) cannot poison the subagent's
251
+ // origin with arbitrary shapes. The check is narrow: object with a
252
+ // `kind` field whose value is one of the SessionOrigin discriminator
253
+ // strings. Permission resolution treats unknown shapes as guest, so
254
+ // failing closed here matches the rest of the system.
255
+ if (!isSessionOriginShape(parsed)) {
256
+ logger.warn(`[subagent] ${subagentName}: ignoring spawnedByOriginJson with unrecognized SessionOrigin shape`)
257
+ return undefined
258
+ }
259
+ return parsed
260
+ }
261
+
262
+ const SESSION_ORIGIN_KINDS = new Set(['tui', 'cron', 'channel', 'subagent'])
263
+ function isSessionOriginShape(value: unknown): value is SessionOrigin {
264
+ if (value === null || typeof value !== 'object') return false
265
+ const kind = (value as { kind?: unknown }).kind
266
+ return typeof kind === 'string' && SESSION_ORIGIN_KINDS.has(kind)
267
+ }
268
+
218
269
  export function createSubagentConsumer({
219
270
  stream,
220
271
  getRegistry,
@@ -234,6 +285,8 @@ export function createSubagentConsumer({
234
285
  kind: 'new-session'
235
286
  subagent: string
236
287
  parentSessionId?: string
288
+ spawnedByRole?: string
289
+ spawnedByOriginJson?: string
237
290
  }
238
291
  const name = target.subagent
239
292
  const registry = getRegistry()
@@ -248,6 +301,7 @@ export function createSubagentConsumer({
248
301
  }
249
302
  inFlight.add(key)
250
303
  try {
304
+ const spawnedByOrigin = parseSpawnedByOriginJson(target.spawnedByOriginJson, logger, name)
251
305
  await invokeSubagent(name, {
252
306
  registry,
253
307
  ...(createSessionForSubagent !== undefined ? { createSessionForSubagent } : {}),
@@ -255,6 +309,8 @@ export function createSubagentConsumer({
255
309
  userPrompt: '',
256
310
  payload: msg.payload,
257
311
  ...(target.parentSessionId !== undefined ? { parentSessionId: target.parentSessionId } : {}),
312
+ ...(target.spawnedByRole !== undefined ? { spawnedByRole: target.spawnedByRole } : {}),
313
+ ...(spawnedByOrigin !== undefined ? { spawnedByOrigin } : {}),
258
314
  })
259
315
  } catch (err) {
260
316
  const message = err instanceof Error ? err.message : String(err)
@@ -20,7 +20,7 @@ These files are not decoration. They shape how you behave. If a task reveals som
20
20
 
21
21
  - **\`workspace/\`** — the directory where you are free to create files: drafts, notes, downloads, scratch work, generated artifacts, temporary outputs. **Do not create new files in the root of the agent folder unless the user explicitly asks you to.** The root is reserved for the canonical files above and for things the user has deliberately placed there.
22
22
  - **\`sessions/\`** — transcripts of past conversations (\`<sessionid>.jsonl\`). Read-only for you in spirit; the runtime manages these.
23
- - **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.md\`) of fragments captured by the memory-logger between sessions. Newest day is closest to the current task. Once dreaming consolidates a day's stream into MEMORY.md, the runtime stops injecting it.
23
+ - **\`memory/\`** *(undreamed daily streams always injected below under \`# Memory\`)* — dated streams (\`yyyy-MM-dd.jsonl\`) of fragments captured by the memory-logger between sessions. Newest day is closest to the current task. Once dreaming consolidates a day's stream into MEMORY.md, the runtime stops injecting it.
24
24
  - **\`memory/skills/\`** — *muscle memory*. Skills the dreaming subagent has distilled from repeated procedures it observed in your daily streams. Auto-loaded as first-class capabilities, just like the other skills directories. **You do not write here directly** — dreaming owns it. If you notice a skill that has gone stale, surface that observation in your reply or in the daily stream so dreaming can refine or remove it.
25
25
  - **\`.agents/skills/\`** — skills the user installed for you. Treat these as first-class capabilities.
26
26
 
@@ -0,0 +1,121 @@
1
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
2
+ import type { ToolDefinition } from '@mariozechner/pi-coding-agent'
3
+ import type { TSchema } from '@sinclair/typebox'
4
+
5
+ // Subagents that read large files (memory-logger and dreaming each read parent
6
+ // session transcripts that can run hundreds of KB) are vulnerable to a class
7
+ // of bug where a single tool malfunction — a broken `find_entry`, a missing
8
+ // watermark, a transcript that no longer contains the watermark id — causes
9
+ // the agent to fall back to scanning the file in 50KB chunks. Every chunk
10
+ // stays in the subagent's conversation history and gets re-sent to the model
11
+ // on every turn until the subagent stops, so a 1MB transcript can balloon a
12
+ // memory-logger run from ~10K input tokens to several hundred thousand.
13
+ //
14
+ // The budget here is a fail-safe ceiling on the total bytes of tool-result
15
+ // text a subagent run is allowed to accumulate from a chosen set of tools.
16
+ // Once exhausted, subsequent calls to those tools short-circuit with a
17
+ // constant-size message that tells the agent to advance the watermark to the
18
+ // latest entry and exit. The budget is per-run (one BudgetState per session)
19
+ // and tracked only for the named tools; tools like `append` (which write,
20
+ // not read) are unaffected.
21
+
22
+ export type ToolResultBudget = {
23
+ maxTotalBytes: number
24
+ toolNames: readonly string[]
25
+ exhaustedMessage?: (used: number, max: number) => string
26
+ }
27
+
28
+ export type BudgetState = {
29
+ used: number
30
+ exhausted: boolean
31
+ }
32
+
33
+ export function createBudgetState(): BudgetState {
34
+ return { used: 0, exhausted: false }
35
+ }
36
+
37
+ function defaultExhaustedMessage(used: number, max: number): string {
38
+ const usedKb = Math.round(used / 1024)
39
+ const maxKb = Math.round(max / 1024)
40
+ return [
41
+ `[tool-result budget exhausted: used ${usedKb}KB of ${maxKb}KB this run]`,
42
+ '',
43
+ 'Stop reading. This session has consumed its byte budget across calls to',
44
+ 'this tool. Do not call this tool again. Stop and exit; future runs will',
45
+ 'continue from wherever your normal end-of-run bookkeeping left off.',
46
+ ].join('\n')
47
+ }
48
+
49
+ function bytesOfContent(content: { type: string; text?: string }[] | undefined): number {
50
+ if (!content) return 0
51
+ let total = 0
52
+ for (const part of content) {
53
+ if (part.type === 'text' && typeof part.text === 'string') {
54
+ total += Buffer.byteLength(part.text, 'utf8')
55
+ }
56
+ }
57
+ return total
58
+ }
59
+
60
+ function buildExhaustedResult(budget: ToolResultBudget, state: BudgetState) {
61
+ const text = (budget.exhaustedMessage ?? defaultExhaustedMessage)(state.used, budget.maxTotalBytes)
62
+ return {
63
+ content: [{ type: 'text' as const, text }],
64
+ details: { budgetExhausted: true, used: state.used, max: budget.maxTotalBytes },
65
+ }
66
+ }
67
+
68
+ // Wraps an AgentTool's execute so that returned text content is counted against
69
+ // `state` and the tool short-circuits once `budget.maxTotalBytes` is exceeded.
70
+ // Tools whose name is not in `budget.toolNames` are returned unchanged so the
71
+ // caller can pass an entire `tools` array through and only the tracked tools
72
+ // are affected. The original tool object is preserved by spreading; only
73
+ // `execute` is replaced.
74
+ export function wrapAgentToolWithBudget<TParams extends TSchema, TDetails = unknown>(
75
+ tool: AgentTool<TParams, TDetails>,
76
+ budget: ToolResultBudget,
77
+ state: BudgetState,
78
+ ): AgentTool<TParams, TDetails> {
79
+ if (!budget.toolNames.includes(tool.name)) return tool
80
+ const originalExecute = tool.execute.bind(tool)
81
+ return {
82
+ ...tool,
83
+ async execute(toolCallId, args, signal, onUpdate) {
84
+ if (state.exhausted) {
85
+ return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
86
+ }
87
+ const result = await originalExecute(toolCallId, args, signal, onUpdate)
88
+ state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
89
+ if (state.used >= budget.maxTotalBytes) {
90
+ state.exhausted = true
91
+ }
92
+ return result
93
+ },
94
+ }
95
+ }
96
+
97
+ // Same wrapper for ToolDefinition (the customTools surface). Identical
98
+ // semantics; ToolDefinition's execute has an extra `onUpdate` callback and a
99
+ // `ctx` argument that we forward verbatim.
100
+ export function wrapToolDefinitionWithBudget<TParams extends TSchema, TDetails = unknown, TState = unknown>(
101
+ tool: ToolDefinition<TParams, TDetails, TState>,
102
+ budget: ToolResultBudget,
103
+ state: BudgetState,
104
+ ): ToolDefinition<TParams, TDetails, TState> {
105
+ if (!budget.toolNames.includes(tool.name)) return tool
106
+ const originalExecute = tool.execute.bind(tool)
107
+ return {
108
+ ...tool,
109
+ async execute(toolCallId, args, signal, onUpdate, ctx) {
110
+ if (state.exhausted) {
111
+ return buildExhaustedResult(budget, state) as Awaited<ReturnType<typeof originalExecute>>
112
+ }
113
+ const result = await originalExecute(toolCallId, args, signal, onUpdate, ctx)
114
+ state.used += bytesOfContent(result.content as { type: string; text?: string }[] | undefined)
115
+ if (state.used >= budget.maxTotalBytes) {
116
+ state.exhausted = true
117
+ }
118
+ return result
119
+ },
120
+ }
121
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
2
 
3
- import { definePlugin, type Subagent } from '@/plugin'
3
+ import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
4
4
 
5
5
  import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
6
6
  import {
@@ -78,10 +78,19 @@ export default definePlugin({
78
78
  if (activeTurns.size > 0) return
79
79
  inFlight = true
80
80
  try {
81
- await ctx.spawnSubagent(SUBAGENT_BACKUP_RUNNER, {
82
- agentDir: ctx.agentDir,
83
- pushToOrigin,
84
- } satisfies RunnerPayload)
81
+ await ctx.spawnSubagent(
82
+ SUBAGENT_BACKUP_RUNNER,
83
+ {
84
+ agentDir: ctx.agentDir,
85
+ pushToOrigin,
86
+ } satisfies RunnerPayload,
87
+ // The backup runner is a system-level operation that commits +
88
+ // pushes on the operator's behalf. It runs after every idle
89
+ // window regardless of which session caused activity, so it has
90
+ // no single user session to inherit from. Mark it as TUI-equivalent
91
+ // so it resolves to `owner` and can use git push, etc.
92
+ { spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' } },
93
+ )
85
94
  } catch (err) {
86
95
  ctx.logger.error(`backup runner spawn failed: ${err instanceof Error ? err.message : String(err)}`)
87
96
  } finally {
@@ -152,12 +161,18 @@ async function runBackupOnce(
152
161
  ctx: {
153
162
  agentDir: string
154
163
  logger: { info: (m: string) => void; warn: (m: string) => void }
155
- spawnSubagent: (name: string, payload?: unknown) => Promise<void>
164
+ spawnSubagent: PluginContext['spawnSubagent']
156
165
  },
157
166
  ): Promise<BackupResult> {
158
167
  const messagePath = messageFilePath(payload.agentDir)
159
168
  await ensureMessageDir(messagePath)
160
169
  await cleanupMessageFile(messagePath)
170
+ // Inherit the backup-runner's owner privileges for the message-picking
171
+ // and diagnose subagents it spawns. Same rationale as the runner itself
172
+ // — these are system-level operations on the operator's behalf.
173
+ const inheritOwner: SpawnSubagentOptions = {
174
+ spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
175
+ }
161
176
 
162
177
  const result = await runBackup(
163
178
  { cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
@@ -172,7 +187,7 @@ async function runBackupOnce(
172
187
  outputPath: messagePath,
173
188
  }
174
189
  try {
175
- await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload)
190
+ await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload, inheritOwner)
176
191
  } catch (err) {
177
192
  ctx.logger.warn(
178
193
  `${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
@@ -191,7 +206,7 @@ async function runBackupOnce(
191
206
  stdout: input.stdout,
192
207
  }
193
208
  try {
194
- await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload)
209
+ await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload, inheritOwner)
195
210
  } catch (err) {
196
211
  ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
197
212
  }