typeclaw 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +14 -12
  2. package/auth.schema.json +41 -0
  3. package/cron.schema.json +8 -0
  4. package/package.json +1 -1
  5. package/secrets.schema.json +41 -0
  6. package/src/agent/auth.ts +45 -22
  7. package/src/agent/index.ts +189 -19
  8. package/src/agent/multimodal/index.ts +12 -0
  9. package/src/agent/multimodal/look-at.ts +185 -0
  10. package/src/agent/multimodal/looker.ts +145 -0
  11. package/src/agent/plugin-tools.ts +30 -1
  12. package/src/agent/session-origin.ts +194 -46
  13. package/src/agent/subagents.ts +57 -1
  14. package/src/agent/system-prompt.ts +1 -1
  15. package/src/agent/tool-result-budget.ts +121 -0
  16. package/src/bundled-plugins/backup/index.ts +23 -8
  17. package/src/bundled-plugins/backup/runner.ts +22 -0
  18. package/src/bundled-plugins/memory/README.md +7 -4
  19. package/src/bundled-plugins/memory/append-tool.ts +87 -61
  20. package/src/bundled-plugins/memory/dreaming.ts +23 -9
  21. package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
  22. package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
  23. package/src/bundled-plugins/memory/index.ts +91 -8
  24. package/src/bundled-plugins/memory/load-memory.ts +74 -34
  25. package/src/bundled-plugins/memory/memory-logger.ts +72 -29
  26. package/src/bundled-plugins/memory/migration.ts +276 -0
  27. package/src/bundled-plugins/memory/stream-events.ts +55 -0
  28. package/src/bundled-plugins/memory/stream-io.ts +63 -0
  29. package/src/bundled-plugins/memory/watermark.ts +48 -8
  30. package/src/bundled-plugins/security/index.ts +103 -10
  31. package/src/bundled-plugins/security/permissions.ts +12 -0
  32. package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
  33. package/src/bundled-plugins/tool-result-cap/README.md +9 -4
  34. package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
  35. package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
  36. package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
  37. package/src/channels/adapters/discord-bot-classify.ts +2 -6
  38. package/src/channels/adapters/discord-bot.ts +4 -45
  39. package/src/channels/adapters/kakaotalk-classify.ts +3 -7
  40. package/src/channels/adapters/kakaotalk.ts +28 -47
  41. package/src/channels/adapters/slack-bot-classify.ts +2 -6
  42. package/src/channels/adapters/slack-bot.ts +4 -50
  43. package/src/channels/adapters/telegram-bot-classify.ts +8 -10
  44. package/src/channels/adapters/telegram-bot.ts +3 -16
  45. package/src/channels/index.ts +3 -2
  46. package/src/channels/manager.ts +15 -1
  47. package/src/channels/persistence.ts +44 -10
  48. package/src/channels/router.ts +228 -19
  49. package/src/channels/schema.ts +6 -156
  50. package/src/cli/channel.ts +200 -4
  51. package/src/cli/compose-usage.ts +182 -0
  52. package/src/cli/compose.ts +33 -0
  53. package/src/cli/hostd.ts +49 -1
  54. package/src/cli/index.ts +4 -0
  55. package/src/cli/init.ts +799 -319
  56. package/src/cli/model.ts +244 -0
  57. package/src/cli/provider.ts +404 -0
  58. package/src/cli/reload.ts +6 -1
  59. package/src/cli/role.ts +156 -0
  60. package/src/cli/run.ts +3 -1
  61. package/src/cli/tui.ts +8 -1
  62. package/src/cli/usage-args.ts +47 -0
  63. package/src/cli/usage.ts +97 -0
  64. package/src/compose/index.ts +1 -0
  65. package/src/compose/usage.ts +65 -0
  66. package/src/config/config.ts +385 -12
  67. package/src/config/index.ts +7 -0
  68. package/src/config/models-mutation.ts +200 -0
  69. package/src/config/providers-mutation.ts +250 -0
  70. package/src/config/providers.ts +141 -2
  71. package/src/config/reloadable.ts +15 -4
  72. package/src/container/index.ts +5 -0
  73. package/src/container/require-running.ts +33 -0
  74. package/src/container/start.ts +39 -58
  75. package/src/cron/consumer.ts +22 -2
  76. package/src/cron/index.ts +45 -4
  77. package/src/cron/schema.ts +104 -0
  78. package/src/doctor/checks.ts +50 -33
  79. package/src/git/system-commit.ts +103 -0
  80. package/src/hostd/daemon.ts +16 -0
  81. package/src/hostd/kakao-renewal-manager.ts +223 -0
  82. package/src/hostd/paths.ts +7 -0
  83. package/src/init/dockerfile.ts +32 -6
  84. package/src/init/index.ts +183 -62
  85. package/src/init/kakaotalk-auth.ts +18 -1
  86. package/src/init/models-dev.ts +26 -1
  87. package/src/init/run-owner-claim.ts +77 -0
  88. package/src/permissions/builtins.ts +70 -0
  89. package/src/permissions/grant.ts +99 -0
  90. package/src/permissions/index.ts +29 -0
  91. package/src/permissions/match-rule.ts +305 -0
  92. package/src/permissions/permissions.ts +196 -0
  93. package/src/permissions/resolve.ts +80 -0
  94. package/src/permissions/schema.ts +79 -0
  95. package/src/plugin/context.ts +8 -4
  96. package/src/plugin/define.ts +2 -0
  97. package/src/plugin/index.ts +2 -0
  98. package/src/plugin/manager.ts +41 -0
  99. package/src/plugin/registry.ts +9 -0
  100. package/src/plugin/types.ts +35 -1
  101. package/src/role-claim/client.ts +182 -0
  102. package/src/role-claim/code.ts +53 -0
  103. package/src/role-claim/controller.ts +194 -0
  104. package/src/role-claim/index.ts +19 -0
  105. package/src/role-claim/match-rule.ts +43 -0
  106. package/src/role-claim/pending.ts +100 -0
  107. package/src/run/channel-session-factory.ts +76 -5
  108. package/src/run/index.ts +55 -6
  109. package/src/secrets/encryption.ts +116 -0
  110. package/src/secrets/kakao-renewal.ts +248 -0
  111. package/src/secrets/kakao-store.ts +66 -7
  112. package/src/secrets/keys.ts +173 -0
  113. package/src/secrets/schema.ts +23 -0
  114. package/src/secrets/storage.ts +68 -0
  115. package/src/server/index.ts +122 -11
  116. package/src/shared/index.ts +4 -0
  117. package/src/shared/protocol.ts +27 -0
  118. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
  119. package/src/skills/typeclaw-config/SKILL.md +38 -64
  120. package/src/skills/typeclaw-memory/SKILL.md +1 -1
  121. package/src/skills/typeclaw-permissions/SKILL.md +166 -0
  122. package/src/stream/types.ts +7 -1
  123. package/src/usage/aggregate.ts +117 -0
  124. package/src/usage/format.ts +30 -0
  125. package/src/usage/index.ts +68 -0
  126. package/src/usage/report.ts +354 -0
  127. package/src/usage/scan.ts +186 -0
  128. package/typeclaw.schema.json +57 -45
@@ -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
  })
package/src/cli/hostd.ts CHANGED
@@ -3,6 +3,7 @@ import { defineCommand } from 'citty'
3
3
  import { loadConfigSync, validateConfig, type Config, type ValidateConfigResult } from '@/config'
4
4
  import { start, stop, type StartOptions, type StartResult, type StopResult } from '@/container'
5
5
  import { startDaemon, type DaemonLogEvent, type RestartPreflight } from '@/hostd/daemon'
6
+ import { createKakaoRenewalManager } from '@/hostd/kakao-renewal-manager'
6
7
  import { createPortbrokerManager } from '@/hostd/portbroker-manager'
7
8
  import type { SupervisorLogEvent, SupervisorRestart } from '@/hostd/supervisor'
8
9
  import { computeSourceVersion, resolveSrcRoot, UNVERSIONED_SENTINEL } from '@/hostd/version'
@@ -22,19 +23,35 @@ export const hostdCommand = defineCommand({
22
23
  onLog: (msg) => writeLogLine(msg),
23
24
  })
24
25
 
26
+ const hostdRestart = buildHostdRestart(cliEntry, defaultRestartDeps, version)
27
+ const kakaoRenewal = createKakaoRenewalManager({
28
+ onLog: (event) => writeLogLine(formatLog(event)),
29
+ onRenewalOk: async ({ containerName, cwd }) => {
30
+ // Restart the container so the in-memory KakaoTalk LOCO client picks
31
+ // up the renewed tokens from secrets.json. Without this, the cron
32
+ // would write fresh tokens but the running adapter would keep using
33
+ // the old token in its closure and still 401 at the ~7-day wall.
34
+ const result = await hostdRestart({ containerName, cwd })
35
+ if (!result.ok) throw new Error(result.reason)
36
+ },
37
+ shouldRenew: ({ cwd }) => kakaoChannelConfigured(cwd),
38
+ })
39
+
25
40
  const daemon = await startDaemon({
26
41
  onLog: (e) => writeLogLine(formatLog(e)),
27
42
  version,
28
43
  onShutdown: () => process.exit(0),
29
44
  portbroker,
45
+ kakaoRenewal,
30
46
  restartPreflight: buildHostdRestartPreflight(cliEntry, version),
31
- restart: buildHostdRestart(cliEntry, defaultRestartDeps, version),
47
+ restart: hostdRestart,
32
48
  })
33
49
 
34
50
  const shutdown = (): void => {
35
51
  void daemon
36
52
  .stop()
37
53
  .then(() => portbroker.drain())
54
+ .then(() => kakaoRenewal.drain())
38
55
  .then(() => process.exit(0))
39
56
  }
40
57
  process.on('SIGTERM', shutdown)
@@ -135,6 +152,37 @@ function formatLog(event: DaemonLogEvent | SupervisorLogEvent): string {
135
152
  return formatPortForwardEvent(event.event)
136
153
  case 'tailscale-serve-event':
137
154
  return formatTailscaleServeEvent(event.event)
155
+ case 'kakao-renewal-tick-start':
156
+ return `[hostd] kakao renewal tick started for ${event.containerName}`
157
+ case 'kakao-renewal-tick-skipped':
158
+ return `[hostd] kakao renewal skipped for ${event.containerName}: ${event.reason}${event.ageMs !== undefined ? ` (age=${Math.round(event.ageMs / 1000 / 60 / 60)}h)` : ''}`
159
+ case 'kakao-renewal-tick-ok':
160
+ return `[hostd] kakao renewal OK for ${event.containerName} account=${event.accountId} (was last updated ${event.previousUpdatedAt})`
161
+ case 'kakao-renewal-tick-reauth-required':
162
+ return `[hostd] kakao renewal REAUTH REQUIRED for ${event.containerName} account=${event.accountId} reason=${event.reason} — ${event.message}`
163
+ case 'kakao-renewal-tick-transient-failure':
164
+ return `[hostd] kakao renewal transient failure for ${event.containerName} account=${event.accountId}: ${event.reason}`
165
+ case 'kakao-renewal-tick-error':
166
+ return `[hostd] kakao renewal ERROR for ${event.containerName}: ${event.error}`
167
+ case 'kakao-renewal-restart-scheduled':
168
+ return `[hostd] kakao renewal scheduled container restart for ${event.containerName} account=${event.accountId}`
169
+ case 'kakao-renewal-restart-failed':
170
+ return `[hostd] kakao renewal container restart FAILED for ${event.containerName} account=${event.accountId}: ${event.reason}`
171
+ }
172
+ }
173
+
174
+ // Reads the agent's typeclaw.json to decide whether the kakao renewal cron
175
+ // should run for this container. Without this, every typeclaw agent on the
176
+ // host gets a daily `no_account` skip event from the renewal manager — log
177
+ // spam for non-kakao agents. Returns false on read/parse errors so the
178
+ // renewal cron stays silent for agents we can't classify; the kakao adapter
179
+ // itself would surface the real config issue on its next start.
180
+ function kakaoChannelConfigured(cwd: string): boolean {
181
+ try {
182
+ const cfg = loadConfigSync(cwd)
183
+ return cfg.channels?.kakaotalk !== undefined
184
+ } catch {
185
+ return false
138
186
  }
139
187
  }
140
188
 
package/src/cli/index.ts CHANGED
@@ -23,7 +23,11 @@ const main = defineCommand({
23
23
  shell: () => import('./shell').then((m) => m.shellCommand),
24
24
  compose: () => import('./compose').then((m) => m.composeCommand),
25
25
  channel: () => import('./channel').then((m) => m.channelCommand),
26
+ role: () => import('./role').then((m) => m.roleCommand),
27
+ provider: () => import('./provider').then((m) => m.providerCommand),
28
+ model: () => import('./model').then((m) => m.modelCommand),
26
29
  doctor: () => import('./doctor').then((m) => m.doctorCommand),
30
+ usage: () => import('./usage').then((m) => m.usageCommand),
27
31
  _hostd: () => import('./hostd').then((m) => m.hostdCommand),
28
32
  },
29
33
  })