typeclaw 0.1.5 → 0.2.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 (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 +209 -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 +190 -61
  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
@@ -0,0 +1,194 @@
1
+ import type { ClaimHandler } from '@/channels/router'
2
+ import { grantRole, type PermissionService } from '@/permissions'
3
+
4
+ import { extractClaimCode } from './code'
5
+ import { formatClaimMatchRule } from './match-rule'
6
+ import { createPendingClaimRegistry, type PendingClaim, type PendingClaimRegistry } from './pending'
7
+
8
+ // ClaimController is the runtime singleton that ties the four moving parts
9
+ // of the role-claim flow together:
10
+ //
11
+ // 1. The host CLI (typeclaw role claim) opens a WS and sends `claim_start`.
12
+ // 2. The WS server forwards that to controller.startClaim().
13
+ // 3. The channel router's claimHandler (also wired here) intercepts DMs
14
+ // bearing the code and calls controller.tryConsumeInbound().
15
+ // 4. On consume, the controller writes to typeclaw.json#roles.<role>.match
16
+ // via grantRole, then reloads the live PermissionService so the new
17
+ // match rule takes effect without a container restart.
18
+ //
19
+ // Result events (completed / error / cancelled) are pushed to subscribers
20
+ // the WS server registers, so the host CLI's open WS receives the outcome
21
+ // over the same connection.
22
+
23
+ export type ClaimCompletedEvent = {
24
+ kind: 'completed'
25
+ code: string
26
+ role: string
27
+ matchRule: string
28
+ adapter: string
29
+ authorId: string
30
+ }
31
+
32
+ export type ClaimErrorEvent = {
33
+ kind: 'error'
34
+ code: string
35
+ reason: string
36
+ }
37
+
38
+ export type ClaimCancelledEvent = {
39
+ kind: 'cancelled'
40
+ code: string
41
+ }
42
+
43
+ export type ClaimResultEvent = ClaimCompletedEvent | ClaimErrorEvent | ClaimCancelledEvent
44
+
45
+ export type ClaimController = {
46
+ startClaim: (input: { code: string; role: string; channel?: string; ttlMs: number }) =>
47
+ | {
48
+ ok: true
49
+ expiresAt: number
50
+ }
51
+ | { ok: false; reason: string }
52
+ cancelClaim: (code: string) => boolean
53
+ current: () => PendingClaim | null
54
+ onResult: (subscriber: (event: ClaimResultEvent) => void) => () => void
55
+ claimHandler: ClaimHandler
56
+ }
57
+
58
+ export type CreateClaimControllerOptions = {
59
+ cwd: string
60
+ permissions: PermissionService
61
+ rolesProvider: () => import('@/permissions').RolesConfig | undefined
62
+ now?: () => number
63
+ registry?: PendingClaimRegistry
64
+ // Test seam: injectable role granter so tests don't touch disk. Production
65
+ // wires the real `grantRole` from src/permissions/grant.ts.
66
+ grant?: (roleName: string, matchRule: string) => { ok: true; added: boolean } | { ok: false; reason: string }
67
+ logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
68
+ }
69
+
70
+ const KNOWN_BUILT_IN_ROLES = new Set(['owner', 'member', 'trusted'])
71
+
72
+ export function createClaimController(opts: CreateClaimControllerOptions): ClaimController {
73
+ const now = opts.now ?? Date.now
74
+ const registry = opts.registry ?? createPendingClaimRegistry({ now })
75
+ const grant =
76
+ opts.grant ?? ((roleName: string, matchRule: string) => grantRole({ cwd: opts.cwd, roleName, matchRule }))
77
+ const logger = opts.logger ?? defaultLogger
78
+ const subscribers = new Set<(event: ClaimResultEvent) => void>()
79
+
80
+ const emit = (event: ClaimResultEvent): void => {
81
+ for (const sub of subscribers) {
82
+ try {
83
+ sub(event)
84
+ } catch (err) {
85
+ logger.warn(`[role-claim] subscriber threw: ${describe(err)}`)
86
+ }
87
+ }
88
+ }
89
+
90
+ const startClaim: ClaimController['startClaim'] = ({ code, role, channel, ttlMs }) => {
91
+ if (!isValidRoleName(role)) {
92
+ return { ok: false, reason: `unknown role '${role}' — built-in roles are owner, member, trusted` }
93
+ }
94
+ const startedAt = now()
95
+ const pending: PendingClaim = {
96
+ code,
97
+ role,
98
+ ttlMs,
99
+ startedAt,
100
+ expiresAt: startedAt + ttlMs,
101
+ ...(channel !== undefined ? { channel } : {}),
102
+ }
103
+ registry.start(pending)
104
+ return { ok: true, expiresAt: pending.expiresAt }
105
+ }
106
+
107
+ const claimHandler: ClaimHandler = async (input) => {
108
+ const code = extractClaimCode(input.text)
109
+ if (code === null) return { kind: 'fallthrough' }
110
+
111
+ const result = registry.tryConsume(
112
+ code,
113
+ {
114
+ adapter: input.adapter,
115
+ workspace: input.workspace,
116
+ chat: input.chat,
117
+ isDm: input.isDm,
118
+ authorId: input.authorId,
119
+ },
120
+ formatClaimMatchRule,
121
+ )
122
+
123
+ if (result.kind === 'no-pending') return { kind: 'fallthrough' }
124
+ if (result.kind === 'no-match') return { kind: 'fallthrough' }
125
+ if (result.kind === 'wrong-channel') {
126
+ const reply = `That claim is for a different channel — please run typeclaw role claim again on this one.`
127
+ emit({ kind: 'error', code, reason: 'wrong-channel' })
128
+ return { kind: 'fail', reply }
129
+ }
130
+ if (result.kind === 'expired') {
131
+ const reply = `That claim code has expired. Run typeclaw role claim again to start a new one.`
132
+ emit({ kind: 'error', code, reason: 'expired' })
133
+ return { kind: 'fail', reply }
134
+ }
135
+
136
+ const granted = grant(result.role, result.matchRule)
137
+ if (!granted.ok) {
138
+ const reply = `Sorry, I couldn't save your role: ${granted.reason}`
139
+ emit({ kind: 'error', code, reason: granted.reason })
140
+ return { kind: 'fail', reply }
141
+ }
142
+
143
+ try {
144
+ opts.permissions.replaceRoles(opts.rolesProvider())
145
+ } catch (err) {
146
+ logger.warn(`[role-claim] replaceRoles failed after grant: ${describe(err)}`)
147
+ }
148
+
149
+ emit({
150
+ kind: 'completed',
151
+ code,
152
+ role: result.role,
153
+ matchRule: result.matchRule,
154
+ adapter: result.origin.adapter,
155
+ authorId: result.origin.authorId,
156
+ })
157
+
158
+ const noteAdded = granted.added ? '' : ' (already on file)'
159
+ const reply = `You're paired as ${result.role}${noteAdded}. Welcome aboard!`
160
+ return { kind: 'consumed', reply }
161
+ }
162
+
163
+ return {
164
+ startClaim,
165
+ cancelClaim: (code) => {
166
+ const cancelled = registry.cancel(code)
167
+ if (cancelled) emit({ kind: 'cancelled', code })
168
+ return cancelled
169
+ },
170
+ current: () => registry.current(),
171
+ onResult: (sub) => {
172
+ subscribers.add(sub)
173
+ return () => {
174
+ subscribers.delete(sub)
175
+ }
176
+ },
177
+ claimHandler,
178
+ }
179
+ }
180
+
181
+ function isValidRoleName(role: string): boolean {
182
+ if (KNOWN_BUILT_IN_ROLES.has(role)) return true
183
+ return /^[a-z][a-z0-9-]*$/.test(role) && role !== 'guest'
184
+ }
185
+
186
+ function describe(err: unknown): string {
187
+ return err instanceof Error ? err.message : String(err)
188
+ }
189
+
190
+ const defaultLogger = {
191
+ info: (m: string) => console.log(m),
192
+ warn: (m: string) => console.warn(m),
193
+ error: (m: string) => console.error(m),
194
+ }
@@ -0,0 +1,19 @@
1
+ export { runClaimSession, type ClaimSessionOptions, type ClaimSessionResult } from './client'
2
+ export { CLAIM_CODE_PREFIX, extractClaimCode, generateClaimCode, normalizeClaimCode } from './code'
3
+ export {
4
+ createClaimController,
5
+ type ClaimCancelledEvent,
6
+ type ClaimCompletedEvent,
7
+ type ClaimController,
8
+ type ClaimErrorEvent,
9
+ type ClaimResultEvent,
10
+ type CreateClaimControllerOptions,
11
+ } from './controller'
12
+ export { formatClaimMatchRule, type PartialChannelOrigin } from './match-rule'
13
+ export {
14
+ createPendingClaimRegistry,
15
+ type ClaimResult,
16
+ type PendingClaim,
17
+ type PendingClaimRegistry,
18
+ type PendingClaimRegistryOptions,
19
+ } from './pending'
@@ -0,0 +1,43 @@
1
+ // Builds a canonical match-rule DSL string from an inbound channel origin,
2
+ // for the role table. Output shapes:
3
+ //
4
+ // slack:T0123 author:U_ALICE
5
+ // discord:9999 author:U_ALICE
6
+ // telegram:42 author:U_ALICE
7
+ // kakao:dm/<chatId> author:<authorId>
8
+ //
9
+ // The author qualifier is always emitted so a claim grants the specific
10
+ // human, not the whole workspace. To grant the whole workspace, the
11
+ // operator edits typeclaw.json by hand or runs a future `typeclaw role grant`
12
+ // without --claim.
13
+
14
+ import type { ChannelKey } from '@/channels/types'
15
+
16
+ export type PartialChannelOrigin = {
17
+ adapter: ChannelKey['adapter']
18
+ workspace: string
19
+ chat: string
20
+ isDm: boolean
21
+ authorId: string
22
+ }
23
+
24
+ const ADAPTER_TO_PLATFORM: Record<ChannelKey['adapter'], 'slack' | 'discord' | 'telegram' | 'kakao'> = {
25
+ 'slack-bot': 'slack',
26
+ 'discord-bot': 'discord',
27
+ 'telegram-bot': 'telegram',
28
+ kakaotalk: 'kakao',
29
+ }
30
+
31
+ export function formatClaimMatchRule(origin: PartialChannelOrigin): string {
32
+ const platform = ADAPTER_TO_PLATFORM[origin.adapter]
33
+ const authorQual = ` author:${origin.authorId}`
34
+ if (origin.adapter === 'kakaotalk') {
35
+ // Kakao has no workspace; routes use dm/group/open buckets. We can't
36
+ // know which bucket from a partial origin alone (adapter-side classifies
37
+ // it), so claim flows are restricted to DM and we emit the specific
38
+ // chat-id form so the rule grants only this 1:1 conversation, not every
39
+ // DM the agent is in.
40
+ return `${platform}:dm/${origin.chat}${authorQual}`
41
+ }
42
+ return `${platform}:${origin.workspace}${authorQual}`
43
+ }
@@ -0,0 +1,100 @@
1
+ import type { PartialChannelOrigin } from './match-rule'
2
+
3
+ export type PendingClaim = {
4
+ code: string
5
+ role: string
6
+ channel?: string
7
+ ttlMs: number
8
+ startedAt: number
9
+ expiresAt: number
10
+ }
11
+
12
+ export type ClaimResult =
13
+ | { kind: 'consumed'; code: string; role: string; matchRule: string; origin: PartialChannelOrigin }
14
+ | { kind: 'no-pending' }
15
+ | { kind: 'no-match' }
16
+ | { kind: 'expired' }
17
+ | { kind: 'wrong-channel' }
18
+
19
+ export type PendingClaimRegistry = {
20
+ start: (claim: PendingClaim) => void
21
+ cancel: (code: string) => boolean
22
+ current: () => PendingClaim | null
23
+ // Snapshot of consumption result without actually committing the grant.
24
+ // The router calls this on every DM-shaped inbound; the grant only fires
25
+ // when the result is 'consumed'.
26
+ tryConsume: (
27
+ code: string,
28
+ origin: PartialChannelOrigin,
29
+ formatMatchRule: (origin: PartialChannelOrigin) => string,
30
+ ) => ClaimResult
31
+ size: () => number
32
+ }
33
+
34
+ export type PendingClaimRegistryOptions = {
35
+ now?: () => number
36
+ }
37
+
38
+ // Single-claim-at-a-time registry. A second `start` while one is pending
39
+ // replaces the prior code (cancels it implicitly): the operator running
40
+ // `typeclaw role claim` twice from two terminals expects the second invocation
41
+ // to take over, not error.
42
+ //
43
+ // Stored in-memory only — claim sessions are short-lived (default 10 min)
44
+ // and a container restart legitimately invalidates any pending window.
45
+ export function createPendingClaimRegistry(opts: PendingClaimRegistryOptions = {}): PendingClaimRegistry {
46
+ const now = opts.now ?? Date.now
47
+ let pending: PendingClaim | null = null
48
+
49
+ type ExpiryCheck = { live: PendingClaim } | { live: null; reason: 'no-pending' | 'expired' }
50
+
51
+ const expireIfDue = (): ExpiryCheck => {
52
+ if (pending === null) return { live: null, reason: 'no-pending' }
53
+ if (now() >= pending.expiresAt) {
54
+ pending = null
55
+ return { live: null, reason: 'expired' }
56
+ }
57
+ return { live: pending }
58
+ }
59
+
60
+ return {
61
+ start(claim) {
62
+ pending = { ...claim }
63
+ },
64
+ cancel(code) {
65
+ if (pending !== null && pending.code === code) {
66
+ pending = null
67
+ return true
68
+ }
69
+ return false
70
+ },
71
+ current() {
72
+ const check = expireIfDue()
73
+ return check.live
74
+ },
75
+ tryConsume(code, origin, formatMatchRule) {
76
+ const check = expireIfDue()
77
+ if (check.live === null) {
78
+ return { kind: check.reason }
79
+ }
80
+ const live = check.live
81
+ if (code !== live.code) return { kind: 'no-match' }
82
+ if (live.channel !== undefined && live.channel !== origin.adapter) {
83
+ return { kind: 'wrong-channel' }
84
+ }
85
+ const matchRule = formatMatchRule(origin)
86
+ const consumed: ClaimResult = {
87
+ kind: 'consumed',
88
+ code: live.code,
89
+ role: live.role,
90
+ matchRule,
91
+ origin,
92
+ }
93
+ pending = null
94
+ return consumed
95
+ },
96
+ size() {
97
+ return expireIfDue().live === null ? 0 : 1
98
+ },
99
+ }
100
+ }
@@ -1,13 +1,26 @@
1
1
  import { SessionManager } from '@mariozechner/pi-coding-agent'
2
2
 
3
3
  import { createSession as defaultCreateSession } from '@/agent'
4
+ import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
5
+ import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
4
6
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
7
+ import type { PermissionService } from '@/permissions'
5
8
  import type { ReloadRegistry } from '@/reload'
6
9
  import type { SessionFactory } from '@/sessions'
7
10
  import type { Stream } from '@/stream'
8
11
 
9
12
  import type { PluginRuntime } from './plugin-runtime'
10
13
 
14
+ export type FactoryLogger = {
15
+ info: (message: string) => void
16
+ warn: (message: string) => void
17
+ }
18
+
19
+ const consoleLogger: FactoryLogger = {
20
+ info: (m) => console.info(m),
21
+ warn: (m) => console.warn(m),
22
+ }
23
+
11
24
  export type BuildChannelSessionFactoryDeps = {
12
25
  cwd: string
13
26
  sessionFactory: SessionFactory
@@ -20,12 +33,35 @@ export type BuildChannelSessionFactoryDeps = {
20
33
  // their inbound messages came from.
21
34
  getChannelRouter: () => ChannelRouter
22
35
  containerName?: string
36
+ // When set, rehydrating a session JSONL caps oversized tool results in the
37
+ // file before pi-coding-agent reads it. `null` disables the load-time pass
38
+ // (tool-result-cap.enabled=false in config, or no plugin block at all).
39
+ rehydrateCapOptions: CapOptions | null
40
+ logger?: FactoryLogger
41
+ // Forwarded to createSession so the resolved role / permissions for the
42
+ // session origin get rendered into the agent's system prompt. Optional so
43
+ // the production wiring can plumb in pluginsLoaded.permissions while tests
44
+ // (or stand-alone callers) keep the previous no-annotation behavior.
45
+ permissions?: PermissionService
23
46
  // Test seam: lets a fake stand in for the agent session creator so tests
24
47
  // can assert exactly which CreateSessionOptions the factory builds without
25
48
  // needing a live LLM, plugin runtime, or session manager on disk.
26
49
  createSession?: typeof defaultCreateSession
27
50
  }
28
51
 
52
+ // Tight basename validation so a tampered or corrupt channels/sessions.json
53
+ // can't point the load-time rewrite (or SessionManager.open) at a file
54
+ // outside `sessionDir`. We never receive sessionFile from a remote source
55
+ // during normal operation, but the file is operator-editable, so defense-
56
+ // in-depth is cheap. Match pi-coding-agent's filename convention loosely:
57
+ // no path separators, no NUL, must end in `.jsonl`.
58
+ function isValidSessionFileBasename(name: string): boolean {
59
+ if (name.length === 0 || name.length > 255) return false
60
+ if (name.includes('/') || name.includes('\\') || name.includes('\0')) return false
61
+ if (name === '.' || name === '..' || name.startsWith('.')) return false
62
+ return name.endsWith('.jsonl')
63
+ }
64
+
29
65
  // The production wiring for channel-routed sessions. Channel inbounds arrive
30
66
  // at the router, the router calls this factory to get an AgentSession, and
31
67
  // the agent uses `channel_send` to reply. If `channelRouter` is missing here
@@ -35,11 +71,19 @@ export type BuildChannelSessionFactoryDeps = {
35
71
  // "channel-aware" sessions that need the same full plumbing.
36
72
  export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps): CreateSessionForChannel {
37
73
  const createSession = deps.createSession ?? defaultCreateSession
38
- return async ({ existingSessionId, existingSessionFile, origin }) => {
74
+ const logger = deps.logger ?? consoleLogger
75
+ return async ({ existingSessionId, existingSessionFile, origin, originRef }) => {
39
76
  const sessionDir = deps.sessionFactory.sessionDir()
40
77
  const sessionManager =
41
78
  existingSessionId !== undefined
42
- ? tryReopenOrCreate(deps.cwd, sessionDir, existingSessionId, existingSessionFile)
79
+ ? tryReopenOrCreate(
80
+ deps.cwd,
81
+ sessionDir,
82
+ existingSessionId,
83
+ existingSessionFile,
84
+ deps.rehydrateCapOptions,
85
+ logger,
86
+ )
43
87
  : SessionManager.create(deps.cwd, sessionDir)
44
88
 
45
89
  const snap = deps.pluginRuntime.get()
@@ -49,6 +93,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
49
93
  stream: deps.stream,
50
94
  channelRouter: deps.getChannelRouter(),
51
95
  origin,
96
+ originRef,
52
97
  ...(snap.hasAnyPluginContent
53
98
  ? {
54
99
  plugins: {
@@ -60,6 +105,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
60
105
  }
61
106
  : {}),
62
107
  ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
108
+ ...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
63
109
  })
64
110
 
65
111
  return {
@@ -86,18 +132,43 @@ function tryReopenOrCreate(
86
132
  sessionDir: string,
87
133
  existingSessionId: string,
88
134
  existingSessionFile: string | undefined,
135
+ capOptions: CapOptions | null,
136
+ logger: FactoryLogger,
89
137
  ): SessionManager {
90
138
  if (existingSessionFile === undefined) {
91
- console.warn(
139
+ logger.warn(
92
140
  `[channels] session ${existingSessionId} has no sessionFile (v2 mapping not yet migrated); creating new`,
93
141
  )
94
142
  return SessionManager.create(cwd, sessionDir)
95
143
  }
144
+ if (!isValidSessionFileBasename(existingSessionFile)) {
145
+ logger.warn(
146
+ `[channels] session ${existingSessionId} has invalid sessionFile (${JSON.stringify(existingSessionFile)}); creating new`,
147
+ )
148
+ return SessionManager.create(cwd, sessionDir)
149
+ }
150
+ const path = `${sessionDir}/${existingSessionFile}`
151
+ if (capOptions !== null) {
152
+ try {
153
+ const stats = capJsonlFileInPlace(path, capOptions)
154
+ if (stats.entriesMutated > 0) {
155
+ logger.info(
156
+ `[channels] rehydrate-cap ${existingSessionFile}: entriesMutated=${stats.entriesMutated} imagesReplaced=${stats.imagesReplaced} textsTruncated=${stats.textsTruncated} bytesElided=${stats.bytesElided}`,
157
+ )
158
+ }
159
+ } catch (err) {
160
+ // Capping is best-effort: if the rewrite fails, fall through to the
161
+ // regular open path so the session still rehydrates uncapped rather
162
+ // than being killed by a transient FS error.
163
+ const reason = err instanceof Error ? err.message : String(err)
164
+ logger.warn(`[channels] rehydrate-cap failed for ${existingSessionFile}: ${reason}; continuing with open`)
165
+ }
166
+ }
96
167
  try {
97
- return SessionManager.open(`${sessionDir}/${existingSessionFile}`)
168
+ return SessionManager.open(path)
98
169
  } catch (err) {
99
170
  const reason = err instanceof Error ? err.message : String(err)
100
- console.warn(
171
+ logger.warn(
101
172
  `[channels] could not rehydrate session ${existingSessionId} from ${existingSessionFile}: ${reason}; creating new`,
102
173
  )
103
174
  return SessionManager.create(cwd, sessionDir)
package/src/run/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SessionManager } from '@mariozechner/pi-coding-agent'
2
2
 
3
3
  import { createSession, createSessionWithDispose } from '@/agent'
4
+ import type { SessionOrigin } from '@/agent/session-origin'
4
5
  import {
5
6
  createSubagentConsumer,
6
7
  defaultCreateSessionForSubagent,
@@ -9,6 +10,7 @@ import {
9
10
  type SubagentConsumer,
10
11
  type SubagentRegistry,
11
12
  } from '@/agent/subagents'
13
+ import { resolveCapOptionsFromConfig } from '@/bundled-plugins/tool-result-cap'
12
14
  import { createChannelManager, createChannelsReloadable, type ChannelManager } from '@/channels'
13
15
  import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
14
16
  import {
@@ -25,6 +27,7 @@ import {
25
27
  import { loadPlugins, type LoadPluginsResult, pluginCronJobs, type PluginRegistry, summarizeLoaded } from '@/plugin'
26
28
  import { createContainerBroker, publishForwardResult } from '@/portbroker'
27
29
  import { ReloadRegistry } from '@/reload'
30
+ import { createClaimController } from '@/role-claim'
28
31
  import { hydrateChannelEnvFromSecrets } from '@/secrets'
29
32
  import { createServer, type Server } from '@/server'
30
33
  import { createSessionFactory, type SessionFactory } from '@/sessions'
@@ -89,7 +92,6 @@ export async function startAgent({
89
92
  const containerNameOpt = containerName !== undefined ? { containerName } : {}
90
93
  const tuiToken = process.env.TYPECLAW_TUI_TOKEN
91
94
  const tuiTokenOpt = tuiToken !== undefined && tuiToken !== '' ? { tuiToken } : {}
92
- reloadRegistry.register(createConfigReloadable({ cwd }))
93
95
 
94
96
  const pluginConfigsByName = loadPluginConfigsSync(cwd)
95
97
  const cwdConfig = loadConfigSync(cwd)
@@ -98,7 +100,10 @@ export async function startAgent({
98
100
  agentDir: cwd,
99
101
  configsByName: pluginConfigsByName,
100
102
  bundled: BUNDLED_PLUGINS,
103
+ ...(cwdConfig.roles !== undefined ? { roles: cwdConfig.roles } : {}),
101
104
  })
105
+
106
+ reloadRegistry.register(createConfigReloadable({ cwd, permissions: pluginsLoaded.permissions }))
102
107
  const pluginRegistry = pluginsLoaded.registry
103
108
  const pluginHooks = pluginsLoaded.hooks
104
109
 
@@ -130,6 +135,12 @@ export async function startAgent({
130
135
  // stay in env, the file stays user-owned. See src/secrets/hydrate.ts.
131
136
  hydrateChannelEnvFromSecrets({ agentDir: cwd })
132
137
 
138
+ const claimController = createClaimController({
139
+ cwd,
140
+ permissions: pluginsLoaded.permissions,
141
+ rolesProvider: () => getConfig().roles,
142
+ })
143
+
133
144
  const channelManager = createChannelManager({
134
145
  agentDir: cwd,
135
146
  channelsConfigRef: () => getConfig().channels,
@@ -141,8 +152,12 @@ export async function startAgent({
141
152
  reloadRegistry,
142
153
  pluginRuntime,
143
154
  getChannelRouter: () => channelManager.router,
155
+ rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
156
+ permissions: pluginsLoaded.permissions,
144
157
  ...containerNameOpt,
145
158
  }),
159
+ permissions: pluginsLoaded.permissions,
160
+ claimHandler: claimController.claimHandler,
146
161
  })
147
162
 
148
163
  const createSessionForSubagent: import('@/agent/subagents').CreateSessionForSubagent = async (
@@ -152,16 +167,21 @@ export async function startAgent({
152
167
  const snap = pluginRuntime.get()
153
168
  const entry = snap.pluginSubagentByShim.get(subagent)
154
169
  if (entry) {
155
- const sessionId = `subagent-${entry.pluginName}-${crypto.randomUUID()}`
156
- const origin = {
170
+ const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
171
+ const sessionId = sessionManager.getSessionId()
172
+ const origin: SessionOrigin = {
157
173
  kind: 'subagent' as const,
158
174
  subagent: subagentOptions?.name ?? entry.subagentName,
159
175
  parentSessionId: subagentOptions?.parentSessionId ?? '<unknown>',
176
+ ...(subagentOptions?.spawnedByRole !== undefined ? { spawnedByRole: subagentOptions.spawnedByRole } : {}),
177
+ ...(subagentOptions?.spawnedByOrigin !== undefined ? { spawnedByOrigin: subagentOptions.spawnedByOrigin } : {}),
160
178
  }
161
179
  const created = await createSessionWithDispose({
162
180
  systemPromptOverride: entry.pluginSubagent.systemPrompt,
181
+ sessionManager,
163
182
  channelRouter: channelManager.router,
164
183
  origin,
184
+ permissions: pluginsLoaded.permissions,
165
185
  plugins: {
166
186
  registry: snap.registry,
167
187
  hooks: snap.hooks,
@@ -174,6 +194,10 @@ export async function startAgent({
174
194
  ...(entry.pluginSubagent.customTools ? { customTools: entry.pluginSubagent.customTools } : {}),
175
195
  toolNamePrefix: `__plugin_${entry.pluginName}_${entry.subagentName}`,
176
196
  },
197
+ ...(entry.pluginSubagent.profile !== undefined ? { profile: entry.pluginSubagent.profile } : {}),
198
+ ...(entry.pluginSubagent.toolResultBudget !== undefined
199
+ ? { toolResultBudget: entry.pluginSubagent.toolResultBudget }
200
+ : {}),
177
201
  })
178
202
  return {
179
203
  ...created,
@@ -181,6 +205,7 @@ export async function startAgent({
181
205
  sessionId,
182
206
  agentDir: cwd,
183
207
  origin,
208
+ getTranscriptPath: () => sessionManager.getSessionFile(),
184
209
  }
185
210
  }
186
211
  return defaultCreateSessionForSubagent(subagent, subagentOptions)
@@ -213,12 +238,24 @@ export async function startAgent({
213
238
  const snap = pluginRuntime.get()
214
239
  const sessionManager = SessionManager.create(cwd, sessionFactory.sessionDir())
215
240
  const sessionId = sessionManager.getSessionId()
241
+ const cronOrigin: SessionOrigin = {
242
+ kind: 'cron',
243
+ jobId: job.id,
244
+ jobKind: 'prompt',
245
+ ...(job.scheduledByRole !== undefined ? { scheduledByRole: job.scheduledByRole } : {}),
246
+ // Honor the persisted audit snapshot when present (TUI-authored
247
+ // crons, or jobs scheduled by a future `cron_schedule` tool).
248
+ // Hand-authored entries fall back to the config-file synthetic
249
+ // marker so the audit trail records "user edited cron.json".
250
+ scheduledByOrigin: (job.scheduledByOrigin as SessionOrigin | undefined) ?? { kind: 'config-file' },
251
+ }
216
252
  const session = await createSession({
217
253
  reloadRegistry,
218
254
  sessionManager,
219
255
  stream,
220
256
  channelRouter: channelManager.router,
221
- origin: { kind: 'cron', jobId: job.id, jobKind: 'prompt' },
257
+ origin: cronOrigin,
258
+ permissions: pluginsLoaded.permissions,
222
259
  ...(snap.hasAnyPluginContent
223
260
  ? {
224
261
  plugins: {
@@ -236,7 +273,7 @@ export async function startAgent({
236
273
  dispose: () => session.dispose(),
237
274
  sessionId,
238
275
  agentDir: cwd,
239
- origin: { kind: 'cron' as const, jobId: job.id, jobKind: 'prompt' as const },
276
+ origin: cronOrigin,
240
277
  ...(snap.hasAnyPluginContent ? { hooks: snap.hooks } : {}),
241
278
  getTranscriptPath: () => sessionManager.getSessionFile(),
242
279
  }
@@ -264,13 +301,24 @@ export async function startAgent({
264
301
  reloadRegistry.register(createChannelsReloadable({ manager: channelManager }))
265
302
  await channelManager.start()
266
303
 
267
- pluginsLoaded.setSpawnSubagent(async (name, payload) => {
304
+ pluginsLoaded.setSpawnSubagent(async (name, payload, options) => {
305
+ // Resolve the spawning session's role from its origin so the subagent
306
+ // inherits it. Callers (hooks like session.idle) pass the parent origin
307
+ // verbatim; we look up the role rather than letting the caller forge it,
308
+ // closing the laundering vector the design doc calls out for cron.
309
+ const spawnedByRole =
310
+ options?.spawnedByOrigin !== undefined
311
+ ? pluginsLoaded.permissions.resolveRole(options.spawnedByOrigin)
312
+ : undefined
268
313
  await invokeSubagent(name, {
269
314
  registry: pluginRuntime.get().subagents,
270
315
  createSessionForSubagent,
271
316
  agentDir: cwd,
272
317
  userPrompt: '',
273
318
  payload,
319
+ ...(options?.parentSessionId !== undefined ? { parentSessionId: options.parentSessionId } : {}),
320
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
321
+ ...(options?.spawnedByOrigin !== undefined ? { spawnedByOrigin: options.spawnedByOrigin } : {}),
274
322
  })
275
323
  })
276
324
  pluginsLoaded.markBooted()
@@ -313,6 +361,7 @@ export async function startAgent({
313
361
  channelRouter: channelManager.router,
314
362
  agentDir: cwd,
315
363
  pluginRuntime,
364
+ claimController,
316
365
  ...containerNameOpt,
317
366
  ...tuiTokenOpt,
318
367
  ...containerBrokerOpt,