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
@@ -4,11 +4,6 @@ export const ADAPTER_IDS = ['discord-bot', 'kakaotalk', 'slack-bot', 'telegram-b
4
4
 
5
5
  export type AdapterId = (typeof ADAPTER_IDS)[number]
6
6
 
7
- const allowRuleSchema = z.string().min(1).refine(isValidAllowRule, {
8
- message:
9
- 'allow rule must be one of: *, guild:*, guild:<id>, guild:<id>/<channel>, team:*, team:<id>, team:<id>/<channel>, tg:*, tg:<chat_id>, channel:<id>, dm:*, dm:<id>, im:*, im:<id>, kakao:*, kakao:<chat>, kakao:dm/*, kakao:group/*, kakao:open/*',
10
- })
11
-
12
7
  const engagementTriggerSchema = z.enum(['mention', 'reply', 'dm'])
13
8
 
14
9
  const stickinessSchema = z.union([
@@ -92,8 +87,13 @@ const historySchema = z
92
87
  },
93
88
  })
94
89
 
90
+ // Deliberately non-strict: a stale on-disk file may still carry the
91
+ // legacy `allow` field (`migrateLegacyConfigShape` lifts it into
92
+ // `roles.member.match[]` on load, but a between-reload window can
93
+ // briefly contain both). Zod silently drops unknown keys here, which is
94
+ // exactly what we want — a hard `.strict()` reject would brick recovery
95
+ // for any user mid-migration.
95
96
  const adapterSchema = z.object({
96
- allow: z.array(allowRuleSchema).default([]),
97
97
  engagement: engagementSchema,
98
98
  history: historySchema,
99
99
  enabled: z.boolean().default(true),
@@ -118,156 +118,6 @@ export const channelsSchema = z
118
118
  })
119
119
  .default({})
120
120
 
121
- export type AllowRule = string
122
121
  export type EngagementConfig = z.infer<typeof engagementSchema>
123
122
  export type ChannelAdapterConfig = z.infer<typeof adapterSchema>
124
- export type KakaotalkAdapterConfig = ChannelAdapterConfig
125
123
  export type ChannelsConfig = z.infer<typeof channelsSchema>
126
-
127
- // Discord IDs are numeric snowflakes; Slack IDs start with a single uppercase
128
- // letter (T for teams, C/D/G for channels) followed by alphanumerics; Telegram
129
- // chat IDs are signed integers (negative for groups, `-100…` for supergroups
130
- // and channels); KakaoTalk chat IDs are LOCO-protocol decimal integers
131
- // (large enough to need BigInt at the protocol layer, but rendered as plain
132
- // decimal strings here). All shapes are accepted on every adapter so the
133
- // allow list stays declarative — the runtime ensures only the right adapter
134
- // ever sees its own IDs.
135
- const RULE_PATTERNS = [
136
- /^\*$/,
137
- // Discord
138
- /^guild:\*$/,
139
- /^guild:[0-9]+$/,
140
- /^guild:[0-9]+\/[0-9]+$/,
141
- /^dm:\*$/,
142
- /^dm:[0-9]+$/,
143
- // Slack
144
- /^team:\*$/,
145
- /^team:[A-Z0-9]+$/,
146
- /^team:[A-Z0-9]+\/[A-Z0-9]+$/,
147
- /^im:\*$/,
148
- /^im:[A-Z0-9]+$/,
149
- // Telegram (`tg:*` admits all chats; `tg:<chat_id>` scopes to one chat —
150
- // numeric, may be negative). There is no team/guild concept; every chat is
151
- // identified by its absolute id.
152
- /^tg:\*$/,
153
- /^tg:-?[0-9]+$/,
154
- // KakaoTalk: a single workspace per logged-in account, so the rules scope
155
- // by chat-type (1:1 / group / open) rather than by workspace. `kakao:*`
156
- // admits every chat the account can see; `kakao:dm/*`, `kakao:group/*`,
157
- // `kakao:open/*` admit one chat-type bucket; `kakao:<chat-id>` admits a
158
- // single chat. The runtime classifies each chat into a bucket based on
159
- // KakaoChat.type at chat-resolver time and surfaces the bucket via the
160
- // workspace coordinate.
161
- /^kakao:\*$/,
162
- /^kakao:dm\/\*$/,
163
- /^kakao:group\/\*$/,
164
- /^kakao:open\/\*$/,
165
- /^kakao:[0-9]+$/,
166
- // Shared (channel ids are unique on both platforms)
167
- /^channel:[A-Z0-9]+$/,
168
- /^channel:-?[0-9]+$/,
169
- ]
170
-
171
- function isValidAllowRule(rule: string): boolean {
172
- return RULE_PATTERNS.some((p) => p.test(rule))
173
- }
174
-
175
- export function isAllowed(rules: readonly AllowRule[], workspace: string, chat: string): boolean {
176
- for (const rule of rules) {
177
- if (matchRule(rule, workspace, chat)) return true
178
- }
179
- return false
180
- }
181
-
182
- // `*` → every workspace channel + every DM (catch-all)
183
- // `guild:*` → every Discord guild channel (no DMs)
184
- // `guild:G` → every channel in guild G
185
- // `guild:G/C` → channel C in guild G only
186
- // `team:*` → every Slack team channel (no DMs)
187
- // `team:T` → every channel in team T
188
- // `team:T/C` → channel C in team T only
189
- // `tg:*` → every Telegram chat (DMs, groups, supergroups, channels)
190
- // `tg:C` → Telegram chat C only (signed numeric chat id)
191
- // `channel:C` → channel C in any workspace (IDs are globally unique on
192
- // Discord/Slack and Telegram chat ids are also globally
193
- // unique numeric values)
194
- // `dm:*` → every Discord DM
195
- // `dm:C` → Discord DM channel C only
196
- // `im:*` → every Slack DM (im channel)
197
- // `im:D` → Slack DM channel D only
198
- // `kakao:*` → every KakaoTalk chat the account is in
199
- // `kakao:dm/*` → every KakaoTalk 1:1 chat
200
- // `kakao:group/*` → every KakaoTalk group chat
201
- // `kakao:open/*` → every KakaoTalk open chat
202
- // `kakao:<id>` → KakaoTalk chat with the given numeric chat_id
203
- //
204
- // `guild:`/`dm:`, `team:`/`im:`, `tg:`, and `kakao:` identify which adapter
205
- // the rule was written for, but the matcher applies any rule that the
206
- // (workspace, chat) pair satisfies. That keeps the adapter-side coupling at
207
- // the schema/UX layer (Slack users write `team:`, Discord users write
208
- // `guild:`, Telegram users write `tg:`, KakaoTalk users write `kakao:`)
209
- // without bloating the matching logic. Telegram has no workspace concept;
210
- // the adapter pins workspace to `'telegram'` so `tg:*` only ever admits
211
- // Telegram chats. KakaoTalk uses `@kakao-dm` / `@kakao-group` / `@kakao-open`
212
- // as workspace coordinates so the bucket-* rules are pure prefix matches
213
- // against `workspace`.
214
- function matchRule(rule: string, workspace: string, chat: string): boolean {
215
- // KakaoTalk workspaces accept the global `*` catch-all or any `kakao:`
216
- // rule. Adapter-specific non-kakao rules (`team:*`, `guild:*`, `dm:*`,
217
- // `im:*`, `tg:*`) never admit kakao workspaces — those are scoped to
218
- // their own adapter's coordinate space and would be meaningless here.
219
- // The init wizard still defaults kakaotalk to the narrower `kakao:dm/*`
220
- // (group chats with personal accounts are sensitive — every member sees
221
- // every reply), so opting into `*` is an explicit, per-adapter decision
222
- // made in `channels.kakaotalk.allow`.
223
- if (KAKAO_WORKSPACES.has(workspace)) {
224
- if (rule === '*') return true
225
- if (rule.startsWith('kakao:')) return matchKakaoRule(rule.slice(6), workspace, chat)
226
- return false
227
- }
228
-
229
- if (rule === '*') return true
230
- if (rule.startsWith('kakao:')) return false
231
-
232
- if (workspace === '@dm') {
233
- if (rule === 'dm:*' || rule === 'im:*') return true
234
- if (rule.startsWith('dm:')) return rule.slice(3) === chat
235
- if (rule.startsWith('im:')) return rule.slice(3) === chat
236
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
237
- return false
238
- }
239
-
240
- if (workspace === 'telegram') {
241
- if (rule === 'tg:*') return true
242
- if (rule.startsWith('tg:')) return rule.slice(3) === chat
243
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
244
- return false
245
- }
246
-
247
- if (rule === 'guild:*' || rule === 'team:*') return true
248
- if (rule.startsWith('channel:')) return rule.slice(8) === chat
249
- if (rule.startsWith('guild:')) {
250
- const body = rule.slice(6)
251
- const slash = body.indexOf('/')
252
- if (slash === -1) return body === workspace
253
- return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
254
- }
255
- if (rule.startsWith('team:')) {
256
- const body = rule.slice(5)
257
- const slash = body.indexOf('/')
258
- if (slash === -1) return body === workspace
259
- return body.slice(0, slash) === workspace && body.slice(slash + 1) === chat
260
- }
261
- return false
262
- }
263
-
264
- const KAKAO_WORKSPACES = new Set(['@kakao-dm', '@kakao-group', '@kakao-open'])
265
-
266
- function matchKakaoRule(body: string, workspace: string, chat: string): boolean {
267
- if (!KAKAO_WORKSPACES.has(workspace)) return false
268
- if (body === '*') return true
269
- if (body === 'dm/*') return workspace === '@kakao-dm'
270
- if (body === 'group/*') return workspace === '@kakao-group'
271
- if (body === 'open/*') return workspace === '@kakao-open'
272
- return body === chat
273
- }
@@ -14,6 +14,7 @@ import {
14
14
  type KakaotalkAuthResult,
15
15
  } from '@/init'
16
16
  import { runKakaotalkBootstrap } from '@/init/kakaotalk-auth'
17
+ import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
17
18
 
18
19
  import { c, done, errorLine } from './ui'
19
20
 
@@ -68,6 +69,44 @@ const addSub = defineCommand({
68
69
  },
69
70
  })
70
71
 
72
+ // Only adapters with an interactive credential flow appear here. Bot tokens
73
+ // (Discord/Slack/Telegram) are rotated by editing secrets.json or .env
74
+ // directly — they don't need a guided CLI flow because there's no
75
+ // passcode-on-phone equivalent. KakaoTalk is the only adapter that does, so
76
+ // it's the only adapter that needs `reauth`.
77
+ const REAUTHABLE_ADAPTERS = ['kakaotalk'] as const
78
+ type ReauthableAdapter = (typeof REAUTHABLE_ADAPTERS)[number]
79
+
80
+ const reauthSub = defineCommand({
81
+ meta: {
82
+ name: 'reauth',
83
+ description:
84
+ 're-authenticate a channel adapter (currently only `kakaotalk`). Use after a stale-token 401 or to rotate the saved password.',
85
+ },
86
+ args: {
87
+ adapter: {
88
+ type: 'positional',
89
+ description: `which adapter to re-authenticate (${REAUTHABLE_ADAPTERS.join(' | ')})`,
90
+ required: false,
91
+ },
92
+ },
93
+ async run({ args }) {
94
+ const cwd = findAgentDir(process.cwd()) ?? process.cwd()
95
+
96
+ if (!isInitialized(cwd)) {
97
+ console.error(errorLine('TypeClaw config file not found. Run `typeclaw init` first, or cd into an agent folder.'))
98
+ process.exit(1)
99
+ }
100
+
101
+ const configured = await readConfiguredChannels(cwd)
102
+ const adapter = await resolveReauthableAdapter(args.adapter, configured)
103
+
104
+ intro(`Re-authenticating channel: ${CHANNEL_LABELS[adapter]}`)
105
+
106
+ await runReauth(cwd, adapter)
107
+ },
108
+ })
109
+
71
110
  export const channelCommand = defineCommand({
72
111
  meta: {
73
112
  name: 'channel',
@@ -75,9 +114,162 @@ export const channelCommand = defineCommand({
75
114
  },
76
115
  subCommands: {
77
116
  add: addSub,
117
+ reauth: reauthSub,
78
118
  },
79
119
  })
80
120
 
121
+ async function resolveReauthableAdapter(
122
+ requested: string | undefined,
123
+ configured: Set<ChannelKind>,
124
+ ): Promise<ReauthableAdapter> {
125
+ if (requested !== undefined) {
126
+ if (!isReauthableAdapter(requested)) {
127
+ console.error(
128
+ errorLine(`Adapter "${requested}" does not support reauth. Supported: ${REAUTHABLE_ADAPTERS.join(', ')}.`),
129
+ )
130
+ process.exit(1)
131
+ }
132
+ if (!configured.has(requested)) {
133
+ console.error(
134
+ errorLine(
135
+ `${CHANNEL_LABELS[requested]} ("${requested}") is not configured in typeclaw.json. Run \`typeclaw channel add ${requested}\` first.`,
136
+ ),
137
+ )
138
+ process.exit(1)
139
+ }
140
+ return requested
141
+ }
142
+
143
+ const available = REAUTHABLE_ADAPTERS.filter((kind) => configured.has(kind))
144
+ if (available.length === 0) {
145
+ console.error(
146
+ errorLine(
147
+ `No reauth-capable channels are configured. Run \`typeclaw channel add ${REAUTHABLE_ADAPTERS[0]}\` first.`,
148
+ ),
149
+ )
150
+ process.exit(1)
151
+ }
152
+ if (available.length === 1) return available[0]!
153
+
154
+ const selected = await select<ReauthableAdapter>({
155
+ message: 'Pick a channel to re-authenticate',
156
+ options: available.map((kind) => ({ value: kind, label: CHANNEL_LABELS[kind] })),
157
+ initialValue: available[0],
158
+ })
159
+ if (isCancel(selected)) {
160
+ cancel('Aborted.')
161
+ process.exit(0)
162
+ }
163
+ return selected
164
+ }
165
+
166
+ function isReauthableAdapter(value: string): value is ReauthableAdapter {
167
+ return (REAUTHABLE_ADAPTERS as ReadonlyArray<string>).includes(value)
168
+ }
169
+
170
+ async function runReauth(cwd: string, adapter: ReauthableAdapter): Promise<void> {
171
+ switch (adapter) {
172
+ case 'kakaotalk':
173
+ await runKakaotalkReauth(cwd)
174
+ return
175
+ }
176
+ }
177
+
178
+ async function runKakaotalkReauth(cwd: string): Promise<void> {
179
+ const existingEmail = await readExistingKakaotalkEmail(cwd)
180
+ const creds = await promptKakaotalkCredentials({ defaultEmail: existingEmail })
181
+
182
+ const s = spinner()
183
+ s.start('Logging in to KakaoTalk...')
184
+ const result = await runKakaotalkBootstrap({
185
+ email: creds.email,
186
+ password: creds.password,
187
+ agentDir: cwd,
188
+ callbacks: {
189
+ onPasscode: (code) => log.info(`Confirm this passcode on your phone: ${code}`),
190
+ },
191
+ })
192
+ if (!result.ok) {
193
+ s.stop(`KakaoTalk login failed: ${result.reason}`)
194
+ process.exit(1)
195
+ }
196
+ s.stop('KakaoTalk credentials refreshed in secrets.json.')
197
+
198
+ await maybePromptReauthRefresh(cwd, 'kakaotalk')
199
+ }
200
+
201
+ async function readExistingKakaotalkEmail(cwd: string): Promise<string | undefined> {
202
+ try {
203
+ const store = new SecretsKakaoCredentialStore({ mode: 'host', secretsPath: `${cwd}/secrets.json` })
204
+ const account = await store.getAccountWithRenewalFields()
205
+ return account?.email ?? undefined
206
+ } catch {
207
+ // First-time reauth or a brand-new agent dir: no account yet, prompt from scratch.
208
+ return undefined
209
+ }
210
+ }
211
+
212
+ // The renewed tokens are already on disk via secrets.json. What still needs
213
+ // to happen depends on the running adapter's state:
214
+ // - Container NOT running → nothing to do; next `typeclaw start` picks them up.
215
+ // - Container running, adapter previously 401'd → `typeclaw reload` re-runs
216
+ // startAdapter, which loads the fresh tokens.
217
+ // - Container running, adapter currently live (e.g. proactive rotation) →
218
+ // reload will report `restart-required` because tokens are captured at
219
+ // start time; `typeclaw restart` is needed to actually pick them up.
220
+ // We can't reliably distinguish the last two cases from outside the container
221
+ // without calling reload first, so the next-step hints surface both paths.
222
+ async function maybePromptReauthRefresh(cwd: string, adapter: ReauthableAdapter): Promise<void> {
223
+ const label = CHANNEL_LABELS[adapter]
224
+ const current = await status({ cwd }).catch(() => null)
225
+ if (current === null || current.kind !== 'running') {
226
+ done({
227
+ title: c.green(`${label} re-authenticated.`),
228
+ hints: [
229
+ { label: 'Start the agent:', command: 'typeclaw start' },
230
+ { label: 'Then check status:', command: 'typeclaw status' },
231
+ ],
232
+ })
233
+ return
234
+ }
235
+
236
+ const restartNow = await confirm({
237
+ message:
238
+ 'The agent container is running. Restart it now so the adapter picks up the fresh credentials (recommended)?',
239
+ initialValue: true,
240
+ })
241
+ if (isCancel(restartNow) || !restartNow) {
242
+ done({
243
+ title: c.green(`${label} re-authenticated.`),
244
+ hints: [
245
+ { label: 'Try a live reload first:', command: 'typeclaw reload' },
246
+ { label: 'If reload reports restart-required:', command: 'typeclaw restart' },
247
+ ],
248
+ })
249
+ return
250
+ }
251
+
252
+ const stopped = await stop({ cwd })
253
+ if (!stopped.ok) {
254
+ console.error(errorLine(`Restart failed during stop: ${stopped.reason}`))
255
+ process.exit(1)
256
+ }
257
+ const started = await start({ cwd, preferredHostPort: config.port, cliEntry: process.argv[1] })
258
+ if (!started.ok) {
259
+ console.error(errorLine(`Restart failed during start: ${started.reason}`))
260
+ process.exit(1)
261
+ }
262
+ done({
263
+ title: c.green(
264
+ `${label} re-authenticated. Restarted ${started.plan.containerName} on host port ${started.hostPort}.`,
265
+ ),
266
+ hints: [
267
+ { label: 'Attach TUI:', command: 'typeclaw tui' },
268
+ { label: 'Follow logs:', command: 'typeclaw logs -f' },
269
+ ],
270
+ })
271
+ }
272
+
81
273
  function validateAdapterArg(adapter: string, configured: Set<ChannelKind>): ChannelKind {
82
274
  if (!isChannelKind(adapter)) {
83
275
  console.error(errorLine(`Unknown adapter "${adapter}". Expected one of: ${CHANNEL_KINDS.join(', ')}.`))
@@ -86,7 +278,7 @@ function validateAdapterArg(adapter: string, configured: Set<ChannelKind>): Chan
86
278
  if (configured.has(adapter)) {
87
279
  console.error(
88
280
  errorLine(
89
- `${CHANNEL_LABELS[adapter]} ("${adapter}") is already configured in typeclaw.json. Edit the file directly to change its allow list.`,
281
+ `${CHANNEL_LABELS[adapter]} ("${adapter}") is already configured in typeclaw.json. Edit the file directly to change its configuration.`,
90
282
  ),
91
283
  )
92
284
  process.exit(1)
@@ -282,20 +474,24 @@ async function promptTelegramToken(): Promise<string> {
282
474
  return token
283
475
  }
284
476
 
285
- async function promptKakaotalkCredentials(): Promise<{ email: string; password: string }> {
477
+ async function promptKakaotalkCredentials(
478
+ opts: { defaultEmail?: string } = {},
479
+ ): Promise<{ email: string; password: string }> {
286
480
  note(
287
481
  [
288
482
  'KakaoTalk authentication uses a personal account, registered as a',
289
483
  'tablet sub-device. Messages will be sent and received under this',
290
484
  'account. Use a non-primary account if possible.',
291
485
  '',
292
- 'After you submit the password, KakaoTalk may ask you to confirm a',
293
- 'passcode on your phone. Watch the screen for the code.',
486
+ 'On reauth, the existing device_uuid is preserved automatically, so',
487
+ 'subsequent logins for the same account typically skip the phone',
488
+ 'passcode confirmation.',
294
489
  ].join('\n'),
295
490
  'About to log in to KakaoTalk',
296
491
  )
297
492
  const email = await text({
298
493
  message: 'KakaoTalk email',
494
+ ...(opts.defaultEmail !== undefined ? { initialValue: opts.defaultEmail, placeholder: opts.defaultEmail } : {}),
299
495
  validate: (value) => (value && value.length > 0 ? undefined : 'Email is required'),
300
496
  })
301
497
  if (isCancel(email)) {
@@ -0,0 +1,182 @@
1
+ import { styleText } from 'node:util'
2
+
3
+ import type { ComposeUsageResult } from '@/compose'
4
+ import type { UsageReport, UsageTotals } from '@/usage'
5
+ import { formatCacheHitRate, formatCost, formatTokens } from '@/usage/format'
6
+
7
+ export type FormatComposeUsageOptions = {
8
+ useColor?: boolean
9
+ }
10
+
11
+ type ColorFn = (s: string) => string
12
+ type Palette = {
13
+ bold: ColorFn
14
+ dim: ColorFn
15
+ cyan: ColorFn
16
+ yellow: ColorFn
17
+ red: ColorFn
18
+ }
19
+
20
+ const identity: ColorFn = (s) => s
21
+ const NO_PALETTE: Palette = { bold: identity, dim: identity, cyan: identity, yellow: identity, red: identity }
22
+ const COLOR_PALETTE: Palette = {
23
+ bold: (s) => styleText('bold', s),
24
+ dim: (s) => styleText('dim', s),
25
+ cyan: (s) => styleText('cyan', s),
26
+ yellow: (s) => styleText('yellow', s),
27
+ red: (s) => styleText('red', s),
28
+ }
29
+
30
+ export function formatComposeUsage(result: ComposeUsageResult, opts: FormatComposeUsageOptions = {}): string {
31
+ const p: Palette = opts.useColor ? COLOR_PALETTE : NO_PALETTE
32
+
33
+ if (result.agents.length === 0) {
34
+ return p.dim(`No typeclaw agents in ${result.rootCwd}.`)
35
+ }
36
+
37
+ const sections: string[] = []
38
+ sections.push(`${p.bold('USAGE')} ${p.dim(`— ${result.rootCwd}`)}`)
39
+ sections.push(rangeLine(result.range, p))
40
+ sections.push('')
41
+ sections.push(renderTable(result, p))
42
+
43
+ const warnings = collectWarnings(result)
44
+ if (warnings.length > 0) {
45
+ sections.push('')
46
+ sections.push(p.yellow(`${warnings.length} warning(s):`))
47
+ for (const w of warnings) sections.push(` - ${w}`)
48
+ }
49
+
50
+ return sections.join('\n')
51
+ }
52
+
53
+ export function formatComposeUsageJson(result: ComposeUsageResult): string {
54
+ return JSON.stringify(result, null, 2)
55
+ }
56
+
57
+ function rangeLine(range: ComposeUsageResult['range'], p: Palette): string {
58
+ if (range.since === null && range.until === null) return p.dim('Range: all time')
59
+ const since = range.since !== null ? new Date(range.since).toISOString() : '—'
60
+ const until = range.until !== null ? new Date(range.until).toISOString() : '—'
61
+ return p.dim(`Range: ${since} → ${until}`)
62
+ }
63
+
64
+ type Row = {
65
+ label: string
66
+ ok: boolean
67
+ reason: string | null
68
+ totals: UsageTotals
69
+ }
70
+
71
+ function renderTable(result: ComposeUsageResult, p: Palette): string {
72
+ const rows: Row[] = result.results.map((r) => {
73
+ if (!r.ok) {
74
+ return { label: r.name, ok: false, reason: r.reason, totals: emptyTotals() }
75
+ }
76
+ return { label: r.name, ok: true, reason: null, totals: totalsFrom(r.data) }
77
+ })
78
+
79
+ const total = sumTotals(rows.filter((r) => r.ok).map((r) => r.totals))
80
+ const headers = ['Agent', 'Sessions', 'Msgs', 'In', 'Out', 'Cache %', 'Cost']
81
+
82
+ const dataCells = rows.map((r) => {
83
+ if (!r.ok) {
84
+ return [p.red(r.label), p.red(`error: ${r.reason ?? 'unknown'}`), '', '', '', '', '']
85
+ }
86
+ return [
87
+ p.bold(r.label),
88
+ String(sessionCountFor(r, result)),
89
+ String(r.totals.messageCount),
90
+ formatTokens(r.totals.input),
91
+ formatTokens(r.totals.output),
92
+ formatCacheHitRate(r.totals.input, r.totals.cacheRead),
93
+ formatCost(r.totals.cost),
94
+ ]
95
+ })
96
+
97
+ const totalSessions = result.results.reduce((acc, r) => acc + (r.ok ? r.data.aggregation.bySession.length : 0), 0)
98
+ const totalCells = [
99
+ 'Total',
100
+ String(totalSessions),
101
+ String(total.messageCount),
102
+ formatTokens(total.input),
103
+ formatTokens(total.output),
104
+ formatCacheHitRate(total.input, total.cacheRead),
105
+ formatCost(total.cost),
106
+ ]
107
+
108
+ return alignTable([headers, ...dataCells, totalCells], p, { totalRowIdx: dataCells.length + 1 })
109
+ }
110
+
111
+ function sessionCountFor(row: Row, result: ComposeUsageResult): number {
112
+ const match = result.results.find((r) => r.name === row.label)
113
+ if (match === undefined || !match.ok) return 0
114
+ return match.data.aggregation.bySession.length
115
+ }
116
+
117
+ function collectWarnings(result: ComposeUsageResult): string[] {
118
+ const out: string[] = []
119
+ for (const r of result.results) {
120
+ if (!r.ok) continue
121
+ for (const w of r.data.warnings) out.push(`[${r.name}] ${w}`)
122
+ }
123
+ return out
124
+ }
125
+
126
+ function totalsFrom(report: UsageReport): UsageTotals {
127
+ return report.aggregation.total
128
+ }
129
+
130
+ function emptyTotals(): UsageTotals {
131
+ return { messageCount: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 }
132
+ }
133
+
134
+ function sumTotals(parts: UsageTotals[]): UsageTotals {
135
+ const acc = emptyTotals()
136
+ for (const t of parts) {
137
+ acc.messageCount += t.messageCount
138
+ acc.input += t.input
139
+ acc.output += t.output
140
+ acc.cacheRead += t.cacheRead
141
+ acc.cacheWrite += t.cacheWrite
142
+ acc.totalTokens += t.totalTokens
143
+ acc.cost += t.cost
144
+ }
145
+ return acc
146
+ }
147
+
148
+ function alignTable(table: string[][], p: Palette, opts: { totalRowIdx: number }): string {
149
+ const widths = computeNaturalWidths(table)
150
+ return table
151
+ .map((row, idx) => {
152
+ const cells = row.map((cell, c) => {
153
+ const pad = widths[c]! - stripAnsi(cell).length
154
+ return c === 0 ? cell + ' '.repeat(pad) : ' '.repeat(pad) + cell
155
+ })
156
+ const line = cells.join(' ')
157
+ if (idx === 0) return p.cyan(line)
158
+ if (idx === opts.totalRowIdx) return p.yellow(p.bold(line))
159
+ return line
160
+ })
161
+ .join('\n')
162
+ }
163
+
164
+ function computeNaturalWidths(table: string[][]): number[] {
165
+ const cols = table[0]?.length ?? 0
166
+ const widths: number[] = []
167
+ for (let c = 0; c < cols; c++) {
168
+ let w = 0
169
+ for (const row of table) {
170
+ const cell = row[c] ?? ''
171
+ const visible = stripAnsi(cell).length
172
+ if (visible > w) w = visible
173
+ }
174
+ widths.push(w)
175
+ }
176
+ return widths
177
+ }
178
+
179
+ function stripAnsi(s: string): string {
180
+ // eslint-disable-next-line no-control-regex
181
+ return s.replace(/\u001b\[[0-9;]*m/g, '')
182
+ }
@@ -7,6 +7,7 @@ import {
7
7
  composeStart,
8
8
  composeStatus,
9
9
  composeStop,
10
+ composeUsage,
10
11
  type AgentResult,
11
12
  type ComposeDoctorReport,
12
13
  } from '@/compose'
@@ -14,7 +15,9 @@ import { config } from '@/config'
14
15
  import { formatJson, formatReport } from '@/doctor'
15
16
 
16
17
  import { formatComposeStatus } from './compose-status'
18
+ import { formatComposeUsage, formatComposeUsageJson } from './compose-usage'
17
19
  import { c, spinner } from './ui'
20
+ import { parseSince, parseUntil } from './usage-args'
18
21
 
19
22
  const startSub = defineCommand({
20
23
  meta: { name: 'start', description: 'start every agent in immediate subdirectories of cwd' },
@@ -166,6 +169,35 @@ const logsSub = defineCommand({
166
169
  },
167
170
  })
168
171
 
172
+ const usageSub = defineCommand({
173
+ meta: {
174
+ name: 'usage',
175
+ description: 'report LLM token usage and cost across every agent in immediate subdirectories of cwd',
176
+ },
177
+ args: {
178
+ json: { type: 'boolean', description: 'emit the usage report as JSON', default: false },
179
+ since: { type: 'string', description: "ISO date or relative duration ('today', '7d', '30d')" },
180
+ until: { type: 'string', description: 'ISO date upper bound (exclusive)' },
181
+ },
182
+ async run({ args }) {
183
+ const since = parseSince(args.since, 'typeclaw compose usage')
184
+ const until = parseUntil(args.until, 'typeclaw compose usage')
185
+ const result = await composeUsage({
186
+ rootCwd: process.cwd(),
187
+ ...(since !== undefined ? { since } : {}),
188
+ ...(until !== undefined ? { until } : {}),
189
+ })
190
+ if (args.json) {
191
+ process.stdout.write(`${formatComposeUsageJson(result)}\n`)
192
+ return
193
+ }
194
+ const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
195
+ process.stdout.write(`${formatComposeUsage(result, { useColor })}\n`)
196
+ const anyFailed = result.results.some((r) => !r.ok)
197
+ if (anyFailed) process.exit(1)
198
+ },
199
+ })
200
+
169
201
  const doctorSub = defineCommand({
170
202
  meta: { name: 'doctor', description: 'diagnose every agent in immediate subdirectories of cwd' },
171
203
  args: {
@@ -212,6 +244,7 @@ export const composeCommand = defineCommand({
212
244
  restart: restartSub,
213
245
  status: statusSub,
214
246
  logs: logsSub,
247
+ usage: usageSub,
215
248
  doctor: doctorSub,
216
249
  },
217
250
  })