typeclaw 0.1.4 → 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 (134) hide show
  1. package/README.md +15 -13
  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 +13 -10
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +137 -7
  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 +809 -300
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +11 -3
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +13 -3
  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 +491 -19
  67. package/src/config/index.ts +15 -1
  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 +6 -1
  73. package/src/container/port.ts +10 -0
  74. package/src/container/require-running.ts +33 -0
  75. package/src/container/start.ts +81 -63
  76. package/src/cron/consumer.ts +22 -2
  77. package/src/cron/index.ts +45 -4
  78. package/src/cron/schema.ts +104 -0
  79. package/src/doctor/checks.ts +51 -34
  80. package/src/doctor/plugin-bridge.ts +28 -4
  81. package/src/git/system-commit.ts +103 -0
  82. package/src/hostd/daemon.ts +16 -0
  83. package/src/hostd/kakao-renewal-manager.ts +223 -0
  84. package/src/hostd/paths.ts +7 -0
  85. package/src/init/dockerfile.ts +36 -10
  86. package/src/init/gitignore.ts +1 -1
  87. package/src/init/index.ts +213 -85
  88. package/src/init/kakaotalk-auth.ts +18 -1
  89. package/src/init/models-dev.ts +26 -1
  90. package/src/init/run-owner-claim.ts +77 -0
  91. package/src/permissions/builtins.ts +70 -0
  92. package/src/permissions/grant.ts +99 -0
  93. package/src/permissions/index.ts +29 -0
  94. package/src/permissions/match-rule.ts +305 -0
  95. package/src/permissions/permissions.ts +196 -0
  96. package/src/permissions/resolve.ts +80 -0
  97. package/src/permissions/schema.ts +79 -0
  98. package/src/plugin/context.ts +8 -4
  99. package/src/plugin/define.ts +2 -0
  100. package/src/plugin/index.ts +2 -0
  101. package/src/plugin/manager.ts +41 -0
  102. package/src/plugin/registry.ts +9 -0
  103. package/src/plugin/types.ts +35 -1
  104. package/src/reload/client.ts +25 -1
  105. package/src/role-claim/client.ts +182 -0
  106. package/src/role-claim/code.ts +53 -0
  107. package/src/role-claim/controller.ts +194 -0
  108. package/src/role-claim/index.ts +19 -0
  109. package/src/role-claim/match-rule.ts +43 -0
  110. package/src/role-claim/pending.ts +100 -0
  111. package/src/run/channel-session-factory.ts +76 -5
  112. package/src/run/index.ts +68 -7
  113. package/src/secrets/encryption.ts +116 -0
  114. package/src/secrets/kakao-renewal.ts +248 -0
  115. package/src/secrets/kakao-store.ts +66 -7
  116. package/src/secrets/keys.ts +173 -0
  117. package/src/secrets/schema.ts +23 -0
  118. package/src/secrets/storage.ts +83 -0
  119. package/src/server/index.ts +198 -71
  120. package/src/shared/index.ts +4 -0
  121. package/src/shared/protocol.ts +27 -0
  122. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  123. package/src/skills/typeclaw-config/SKILL.md +104 -112
  124. package/src/skills/typeclaw-memory/SKILL.md +9 -9
  125. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  126. package/src/stream/types.ts +7 -1
  127. package/src/tui/client.ts +66 -5
  128. package/src/tui/index.ts +61 -9
  129. package/src/usage/aggregate.ts +117 -0
  130. package/src/usage/format.ts +30 -0
  131. package/src/usage/index.ts +68 -0
  132. package/src/usage/report.ts +354 -0
  133. package/src/usage/scan.ts +186 -0
  134. package/typeclaw.schema.json +134 -98
@@ -0,0 +1,182 @@
1
+ import type {
2
+ ClaimCompletedPayload,
3
+ ClaimErrorPayload,
4
+ ClaimStartedPayload,
5
+ ClientMessage,
6
+ ServerMessage,
7
+ } from '@/shared'
8
+
9
+ import { generateClaimCode } from './code'
10
+
11
+ export type ClaimSessionOptions = {
12
+ url: string
13
+ role: string
14
+ channel?: string
15
+ ttlMs?: number
16
+ connectTimeoutMs?: number
17
+ onStarted?: (payload: ClaimStartedPayload) => void
18
+ }
19
+
20
+ export type ClaimSessionResult =
21
+ | { kind: 'completed'; payload: ClaimCompletedPayload }
22
+ | { kind: 'error'; payload: ClaimErrorPayload }
23
+ | { kind: 'timeout' }
24
+
25
+ const DEFAULT_TTL_MS = 10 * 60 * 1000
26
+ const DEFAULT_CONNECT_TIMEOUT_MS = 30_000
27
+
28
+ export async function runClaimSession(opts: ClaimSessionOptions): Promise<ClaimSessionResult> {
29
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
30
+ const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
31
+ const code = generateClaimCode()
32
+
33
+ const ws = new WebSocket(opts.url)
34
+ const displayUrl = redactUrl(opts.url)
35
+ await waitForOpen(ws, displayUrl, connectTimeoutMs)
36
+ await waitForConnected(ws, displayUrl, connectTimeoutMs)
37
+
38
+ try {
39
+ const request: ClientMessage = {
40
+ type: 'claim_start',
41
+ code,
42
+ role: opts.role,
43
+ ttlMs,
44
+ ...(opts.channel !== undefined ? { channel: opts.channel } : {}),
45
+ }
46
+ ws.send(JSON.stringify(request))
47
+
48
+ return await waitForOutcome(ws, code, ttlMs, opts.onStarted)
49
+ } finally {
50
+ try {
51
+ const cancel: ClientMessage = { type: 'claim_cancel' }
52
+ ws.send(JSON.stringify(cancel))
53
+ } catch {}
54
+ ws.close()
55
+ }
56
+ }
57
+
58
+ async function waitForOpen(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
59
+ await new Promise<void>((resolve, reject) => {
60
+ const timer = setTimeout(() => {
61
+ cleanup()
62
+ ws.close()
63
+ reject(new Error(`timed out connecting to ${displayUrl} after ${timeoutMs}ms`))
64
+ }, timeoutMs)
65
+ const onOpen = () => {
66
+ cleanup()
67
+ resolve()
68
+ }
69
+ const onError = (err: unknown) => {
70
+ cleanup()
71
+ reject(new Error(`failed to connect to ${displayUrl}: ${err instanceof Error ? err.message : String(err)}`))
72
+ }
73
+ const onClose = () => {
74
+ cleanup()
75
+ reject(new Error(`connection to ${displayUrl} closed before opening`))
76
+ }
77
+ const cleanup = () => {
78
+ clearTimeout(timer)
79
+ ws.removeEventListener('open', onOpen)
80
+ ws.removeEventListener('error', onError)
81
+ ws.removeEventListener('close', onClose)
82
+ }
83
+ ws.addEventListener('open', onOpen, { once: true })
84
+ ws.addEventListener('error', onError, { once: true })
85
+ ws.addEventListener('close', onClose, { once: true })
86
+ })
87
+ }
88
+
89
+ // The server's WS `open` handler is async (it awaits createSession) and only
90
+ // registers per-ws state and sends `connected` after that resolves. Bun
91
+ // delivers any client messages received before `open` completes, but the
92
+ // server's `claim_start` handler silently drops messages when state is null.
93
+ // Waiting here mirrors what the TUI client does (see src/tui/index.ts) and
94
+ // fixes the hatching-time hang where the spinner stayed on "Generating your
95
+ // claim code..." until the ttl expired.
96
+ async function waitForConnected(ws: WebSocket, displayUrl: string, timeoutMs: number): Promise<void> {
97
+ await new Promise<void>((resolve, reject) => {
98
+ const timer = setTimeout(() => {
99
+ cleanup()
100
+ ws.close()
101
+ reject(new Error(`timed out waiting for connected message from ${displayUrl} after ${timeoutMs}ms`))
102
+ }, timeoutMs)
103
+ const onMessage = (event: MessageEvent): void => {
104
+ let msg: ServerMessage
105
+ try {
106
+ msg = JSON.parse(String(event.data)) as ServerMessage
107
+ } catch {
108
+ return
109
+ }
110
+ if (msg.type === 'connected') {
111
+ cleanup()
112
+ resolve()
113
+ return
114
+ }
115
+ if (msg.type === 'error') {
116
+ cleanup()
117
+ reject(new Error(`server rejected connection to ${displayUrl}: ${msg.message}`))
118
+ }
119
+ }
120
+ const onClose = () => {
121
+ cleanup()
122
+ reject(new Error(`connection to ${displayUrl} closed before the session was ready`))
123
+ }
124
+ const cleanup = () => {
125
+ clearTimeout(timer)
126
+ ws.removeEventListener('message', onMessage)
127
+ ws.removeEventListener('close', onClose)
128
+ }
129
+ ws.addEventListener('message', onMessage)
130
+ ws.addEventListener('close', onClose, { once: true })
131
+ })
132
+ }
133
+
134
+ async function waitForOutcome(
135
+ ws: WebSocket,
136
+ code: string,
137
+ ttlMs: number,
138
+ onStarted?: (payload: ClaimStartedPayload) => void,
139
+ ): Promise<ClaimSessionResult> {
140
+ return new Promise<ClaimSessionResult>((resolve) => {
141
+ const timer = setTimeout(() => {
142
+ ws.removeEventListener('message', onMessage)
143
+ resolve({ kind: 'timeout' })
144
+ }, ttlMs + 5_000)
145
+
146
+ const onMessage = (event: MessageEvent): void => {
147
+ let msg: ServerMessage
148
+ try {
149
+ msg = JSON.parse(String(event.data)) as ServerMessage
150
+ } catch {
151
+ return
152
+ }
153
+ if (msg.type === 'claim_started' && msg.payload.code === code) {
154
+ onStarted?.(msg.payload)
155
+ return
156
+ }
157
+ if (msg.type === 'claim_completed' && msg.payload.code === code) {
158
+ clearTimeout(timer)
159
+ ws.removeEventListener('message', onMessage)
160
+ resolve({ kind: 'completed', payload: msg.payload })
161
+ return
162
+ }
163
+ if (msg.type === 'claim_error' && msg.payload.code === code) {
164
+ clearTimeout(timer)
165
+ ws.removeEventListener('message', onMessage)
166
+ resolve({ kind: 'error', payload: msg.payload })
167
+ return
168
+ }
169
+ }
170
+ ws.addEventListener('message', onMessage)
171
+ })
172
+ }
173
+
174
+ function redactUrl(url: string): string {
175
+ try {
176
+ const parsed = new URL(url)
177
+ if (parsed.searchParams.has('token')) parsed.searchParams.set('token', '<redacted>')
178
+ return parsed.toString()
179
+ } catch {
180
+ return url
181
+ }
182
+ }
@@ -0,0 +1,53 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ // Role-claim codes are short, human-typeable tokens the operator sends from
4
+ // their host CLI to the bot via a channel DM to prove ownership of that
5
+ // channel identity. Shape: `claim-XXXX-YYYY` where each block is 4 chars
6
+ // from a Crockford-style base32 alphabet (0-9 + A-Z minus I, L, O, U to
7
+ // dodge OCR-confusable / profane shapes). 8 chars * 5 bits = 40 bits of
8
+ // entropy, which is overkill for a TTL'd in-memory window but cheap to
9
+ // display and dictate over voice.
10
+ //
11
+ // The `claim-` prefix lets the channel router recognize potential claim
12
+ // attempts in a DM body without scanning the whole text for hex blocks,
13
+ // and distinguishes claim DMs from normal first-message text like "hi"
14
+ // which would otherwise need a regex of its own to disambiguate.
15
+
16
+ export const CLAIM_CODE_PREFIX = 'claim-'
17
+
18
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
19
+ const BLOCK_SIZE = 4
20
+ const BLOCK_COUNT = 2
21
+
22
+ export function generateClaimCode(): string {
23
+ const bytes = randomBytes(BLOCK_SIZE * BLOCK_COUNT)
24
+ const chars: string[] = []
25
+ for (let i = 0; i < bytes.length; i++) {
26
+ chars.push(ALPHABET[bytes[i]! % ALPHABET.length]!)
27
+ }
28
+ const blocks: string[] = []
29
+ for (let b = 0; b < BLOCK_COUNT; b++) {
30
+ blocks.push(chars.slice(b * BLOCK_SIZE, (b + 1) * BLOCK_SIZE).join(''))
31
+ }
32
+ return `${CLAIM_CODE_PREFIX}${blocks.join('-')}`
33
+ }
34
+
35
+ // Extracts the first claim-code-shaped token from inbound text. Returns
36
+ // the canonical-case (upper) code, or null. Tolerates surrounding
37
+ // whitespace, punctuation, and case — chat clients may auto-correct case
38
+ // or surround pastes with quotes/backticks.
39
+ export function extractClaimCode(text: string): string | null {
40
+ const pattern = new RegExp(
41
+ `${CLAIM_CODE_PREFIX}([0-9a-zA-Z]{${BLOCK_SIZE}}(?:-[0-9a-zA-Z]{${BLOCK_SIZE}}){${BLOCK_COUNT - 1}})`,
42
+ 'i',
43
+ )
44
+ const match = pattern.exec(text)
45
+ if (!match) return null
46
+ return `${CLAIM_CODE_PREFIX}${match[1]!.toUpperCase()}`
47
+ }
48
+
49
+ export function normalizeClaimCode(code: string): string {
50
+ const trimmed = code.trim()
51
+ if (!trimmed.toLowerCase().startsWith(CLAIM_CODE_PREFIX)) return trimmed.toUpperCase()
52
+ return `${CLAIM_CODE_PREFIX}${trimmed.slice(CLAIM_CODE_PREFIX.length).toUpperCase()}`
53
+ }
@@ -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
+ }