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.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
168
|
+
return SessionManager.open(path)
|
|
98
169
|
} catch (err) {
|
|
99
170
|
const reason = err instanceof Error ? err.message : String(err)
|
|
100
|
-
|
|
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
|
|
156
|
-
const
|
|
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:
|
|
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:
|
|
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,
|