typeclaw 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/auth.schema.json +41 -0
- package/cron.schema.json +8 -0
- package/package.json +1 -1
- package/secrets.schema.json +41 -0
- package/src/agent/auth.ts +45 -22
- package/src/agent/index.ts +189 -19
- package/src/agent/multimodal/index.ts +12 -0
- package/src/agent/multimodal/look-at.ts +185 -0
- package/src/agent/multimodal/looker.ts +145 -0
- package/src/agent/plugin-tools.ts +30 -1
- package/src/agent/session-origin.ts +194 -46
- package/src/agent/subagents.ts +57 -1
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-result-budget.ts +121 -0
- package/src/bundled-plugins/backup/index.ts +23 -8
- package/src/bundled-plugins/backup/runner.ts +22 -0
- package/src/bundled-plugins/memory/README.md +7 -4
- package/src/bundled-plugins/memory/append-tool.ts +87 -61
- package/src/bundled-plugins/memory/dreaming.ts +23 -9
- package/src/bundled-plugins/memory/find-entry-tool.ts +62 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +19 -44
- package/src/bundled-plugins/memory/index.ts +91 -8
- package/src/bundled-plugins/memory/load-memory.ts +74 -34
- package/src/bundled-plugins/memory/memory-logger.ts +72 -29
- package/src/bundled-plugins/memory/migration.ts +276 -0
- package/src/bundled-plugins/memory/stream-events.ts +55 -0
- package/src/bundled-plugins/memory/stream-io.ts +63 -0
- package/src/bundled-plugins/memory/watermark.ts +48 -8
- package/src/bundled-plugins/security/index.ts +103 -10
- package/src/bundled-plugins/security/permissions.ts +12 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +51 -18
- package/src/bundled-plugins/tool-result-cap/README.md +9 -4
- package/src/bundled-plugins/tool-result-cap/cap-jsonl.ts +115 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +25 -13
- package/src/bundled-plugins/tool-result-cap/index.ts +16 -2
- package/src/channels/adapters/discord-bot-classify.ts +2 -6
- package/src/channels/adapters/discord-bot.ts +4 -45
- package/src/channels/adapters/kakaotalk-classify.ts +3 -7
- package/src/channels/adapters/kakaotalk.ts +28 -47
- package/src/channels/adapters/slack-bot-classify.ts +2 -6
- package/src/channels/adapters/slack-bot.ts +4 -50
- package/src/channels/adapters/telegram-bot-classify.ts +8 -10
- package/src/channels/adapters/telegram-bot.ts +3 -16
- package/src/channels/index.ts +3 -2
- package/src/channels/manager.ts +15 -1
- package/src/channels/persistence.ts +44 -10
- package/src/channels/router.ts +228 -19
- package/src/channels/schema.ts +6 -156
- package/src/cli/channel.ts +200 -4
- package/src/cli/compose-usage.ts +182 -0
- package/src/cli/compose.ts +33 -0
- package/src/cli/hostd.ts +49 -1
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +799 -319
- package/src/cli/model.ts +244 -0
- package/src/cli/provider.ts +404 -0
- package/src/cli/reload.ts +6 -1
- package/src/cli/role.ts +156 -0
- package/src/cli/run.ts +3 -1
- package/src/cli/tui.ts +8 -1
- package/src/cli/usage-args.ts +47 -0
- package/src/cli/usage.ts +97 -0
- package/src/compose/index.ts +1 -0
- package/src/compose/usage.ts +65 -0
- package/src/config/config.ts +385 -12
- package/src/config/index.ts +7 -0
- package/src/config/models-mutation.ts +200 -0
- package/src/config/providers-mutation.ts +250 -0
- package/src/config/providers.ts +141 -2
- package/src/config/reloadable.ts +15 -4
- package/src/container/index.ts +5 -0
- package/src/container/require-running.ts +33 -0
- package/src/container/start.ts +39 -58
- package/src/cron/consumer.ts +22 -2
- package/src/cron/index.ts +45 -4
- package/src/cron/schema.ts +104 -0
- package/src/doctor/checks.ts +50 -33
- package/src/git/system-commit.ts +103 -0
- package/src/hostd/daemon.ts +16 -0
- package/src/hostd/kakao-renewal-manager.ts +223 -0
- package/src/hostd/paths.ts +7 -0
- package/src/init/dockerfile.ts +32 -6
- package/src/init/index.ts +183 -62
- package/src/init/kakaotalk-auth.ts +18 -1
- package/src/init/models-dev.ts +26 -1
- package/src/init/run-owner-claim.ts +77 -0
- package/src/permissions/builtins.ts +70 -0
- package/src/permissions/grant.ts +99 -0
- package/src/permissions/index.ts +29 -0
- package/src/permissions/match-rule.ts +305 -0
- package/src/permissions/permissions.ts +196 -0
- package/src/permissions/resolve.ts +80 -0
- package/src/permissions/schema.ts +79 -0
- package/src/plugin/context.ts +8 -4
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/manager.ts +41 -0
- package/src/plugin/registry.ts +9 -0
- package/src/plugin/types.ts +35 -1
- package/src/role-claim/client.ts +182 -0
- package/src/role-claim/code.ts +53 -0
- package/src/role-claim/controller.ts +194 -0
- package/src/role-claim/index.ts +19 -0
- package/src/role-claim/match-rule.ts +43 -0
- package/src/role-claim/pending.ts +100 -0
- package/src/run/channel-session-factory.ts +76 -5
- package/src/run/index.ts +55 -6
- package/src/secrets/encryption.ts +116 -0
- package/src/secrets/kakao-renewal.ts +248 -0
- package/src/secrets/kakao-store.ts +66 -7
- package/src/secrets/keys.ts +173 -0
- package/src/secrets/schema.ts +23 -0
- package/src/secrets/storage.ts +68 -0
- package/src/server/index.ts +122 -11
- package/src/shared/index.ts +4 -0
- package/src/shared/protocol.ts +27 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +3 -3
- package/src/skills/typeclaw-config/SKILL.md +38 -64
- package/src/skills/typeclaw-memory/SKILL.md +1 -1
- package/src/skills/typeclaw-permissions/SKILL.md +166 -0
- package/src/stream/types.ts +7 -1
- package/src/usage/aggregate.ts +117 -0
- package/src/usage/format.ts +30 -0
- package/src/usage/index.ts +68 -0
- package/src/usage/report.ts +354 -0
- package/src/usage/scan.ts +186 -0
- package/typeclaw.schema.json +57 -45
package/src/cli/channel.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
'
|
|
293
|
-
'
|
|
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
|
+
}
|
package/src/cli/compose.ts
CHANGED
|
@@ -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:
|
|
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
|
})
|