typeclaw 0.1.0 → 0.1.2
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 +12 -12
- package/package.json +3 -2
- package/src/agent/auth.ts +10 -4
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/security/index.ts +5 -1
- package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
- package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
- package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
- package/src/channels/adapters/kakaotalk.ts +58 -3
- package/src/channels/router.ts +40 -2
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/ensure-deps.ts +2 -2
- package/src/init/index.ts +23 -13
- package/src/init/run-bun-install.ts +17 -1
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/secrets/env.ts +43 -0
- package/src/secrets/index.ts +2 -0
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/tsconfig.json +30 -0
- package/typeclaw.schema.json +50 -4
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type KakaoProfile,
|
|
9
9
|
type KakaoSendResult,
|
|
10
10
|
type KakaoTalkListenerEventMap,
|
|
11
|
+
type KakaoTalkPushEmoticonEvent,
|
|
11
12
|
type KakaoTalkPushMessageEvent,
|
|
12
13
|
} from 'agent-messenger/kakaotalk'
|
|
13
14
|
|
|
@@ -24,9 +25,11 @@ import type {
|
|
|
24
25
|
SendResult,
|
|
25
26
|
} from '@/channels/types'
|
|
26
27
|
|
|
28
|
+
import { emoticonEventToMessageEvent, formatHistoryText, formatInboundText } from './kakaotalk-attachment'
|
|
27
29
|
import { createKakaoAuthorResolver, type KakaoAuthorResolver } from './kakaotalk-author-resolver'
|
|
28
30
|
import { createKakaoChannelResolver, type KakaoChannelResolver } from './kakaotalk-channel-resolver'
|
|
29
31
|
import { classifyInbound, type InboundDropReason } from './kakaotalk-classify'
|
|
32
|
+
import { createFetchAttachmentCallback } from './kakaotalk-fetch-attachment'
|
|
30
33
|
|
|
31
34
|
// Inlined locally because agent-messenger/kakaotalk's index does not
|
|
32
35
|
// re-export KakaoMarkReadResult even though client.markRead returns it
|
|
@@ -237,7 +240,7 @@ export function createKakaoHistoryCallback(deps: {
|
|
|
237
240
|
externalMessageId: m.log_id,
|
|
238
241
|
authorId,
|
|
239
242
|
authorName,
|
|
240
|
-
text: m
|
|
243
|
+
text: formatHistoryText(m),
|
|
241
244
|
ts: m.sent_at,
|
|
242
245
|
isBot: selfId !== null && authorId === selfId,
|
|
243
246
|
replyToBotMessageId: null,
|
|
@@ -312,11 +315,46 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
312
315
|
formatChannelTag,
|
|
313
316
|
})
|
|
314
317
|
|
|
318
|
+
const fetchAttachmentCallback = createFetchAttachmentCallback({ logger })
|
|
319
|
+
|
|
315
320
|
const handleMessageEvent = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
|
|
321
|
+
// Synthesize the displayed text BEFORE classify so attachments
|
|
322
|
+
// (photo, file, video, ...) survive classifyInbound's empty_text
|
|
323
|
+
// drop and reach the agent with a `[KakaoTalk message with ...]`
|
|
324
|
+
// placeholder. For text-only messages this is a no-op —
|
|
325
|
+
// formatInboundText returns event.message unchanged. See
|
|
326
|
+
// kakaotalk-attachment.ts for the per-message-type rules.
|
|
327
|
+
await processInbound({ ...event, message: formatInboundText(event) })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const handleEmoticonEvent = async (event: KakaoTalkPushEmoticonEvent): Promise<void> => {
|
|
331
|
+
// Stickers arrive on a separate listener event in agent-messenger
|
|
332
|
+
// 2.15.0 and have no `message` field. We wrap them into the same
|
|
333
|
+
// MSG-shaped payload classifyInbound expects so the engagement /
|
|
334
|
+
// allow-list / self-author rules apply identically across plain
|
|
335
|
+
// messages and stickers — there is no second classifier to keep in
|
|
336
|
+
// sync.
|
|
337
|
+
await processInbound(emoticonEventToMessageEvent(event))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const processInbound = async (event: KakaoTalkPushMessageEvent): Promise<void> => {
|
|
316
341
|
inflightInbounds++
|
|
317
342
|
try {
|
|
318
343
|
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
319
344
|
await channelResolver.refresh()
|
|
345
|
+
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
346
|
+
// The push event itself proves the chat exists, even when
|
|
347
|
+
// getChats({all:true}) does not surface it (e.g. memo chats,
|
|
348
|
+
// certain open chats, recently-joined groups that haven't
|
|
349
|
+
// propagated). Register a provisional @kakao-group entry so the
|
|
350
|
+
// strictest allow rules still apply, but the message is no longer
|
|
351
|
+
// silently dropped as unknown_chat. The next real refresh
|
|
352
|
+
// upgrades the entry if the chat is actually a DM or open chat.
|
|
353
|
+
channelResolver.ingestProvisional(event.chat_id)
|
|
354
|
+
logger.warn(
|
|
355
|
+
`[kakaotalk] provisional chat=${event.chat_id} log_id=${event.log_id} bucket=@kakao-group reason=not_in_getchats`,
|
|
356
|
+
)
|
|
357
|
+
}
|
|
320
358
|
}
|
|
321
359
|
|
|
322
360
|
const inboundTag = await formatChannelTag(
|
|
@@ -324,7 +362,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
324
362
|
event.chat_id,
|
|
325
363
|
)
|
|
326
364
|
logger.info(
|
|
327
|
-
`[kakaotalk] inbound log_id=${event.log_id} author=${event.author_id} ${inboundTag} text_len=${event.message.length}`,
|
|
365
|
+
`[kakaotalk] inbound log_id=${event.log_id} author=${event.author_id} ${inboundTag} type=${event.message_type} text_len=${event.message.length}`,
|
|
328
366
|
)
|
|
329
367
|
|
|
330
368
|
// Ack the message BEFORE classify/route so the sender's unread "1"
|
|
@@ -500,6 +538,9 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
500
538
|
listener.on('message', (event) => {
|
|
501
539
|
void handleMessageEvent(event)
|
|
502
540
|
})
|
|
541
|
+
listener.on('emoticon', (event) => {
|
|
542
|
+
void handleEmoticonEvent(event)
|
|
543
|
+
})
|
|
503
544
|
listener.on('member_joined', () => {
|
|
504
545
|
void channelResolver.refresh()
|
|
505
546
|
})
|
|
@@ -510,6 +551,18 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
510
551
|
try {
|
|
511
552
|
await listener.start()
|
|
512
553
|
} catch (err) {
|
|
554
|
+
// Tear down defensively. Handlers (including the new 'emoticon'
|
|
555
|
+
// one) were already wired before start(), and a partial start can
|
|
556
|
+
// leave LOCO sockets half-open in the SDK. Without an explicit
|
|
557
|
+
// stop here, a later adapter.stop() short-circuits on
|
|
558
|
+
// !started and the listener leaks; with it, the SDK closes its
|
|
559
|
+
// resources and our handler closures become unreachable.
|
|
560
|
+
try {
|
|
561
|
+
listener.stop()
|
|
562
|
+
} catch {
|
|
563
|
+
// ignore — best-effort cleanup, the start failure is what we surface
|
|
564
|
+
}
|
|
565
|
+
listener = null
|
|
513
566
|
started = false
|
|
514
567
|
logger.error(`[kakaotalk] listener start failed: ${describe(err)}`)
|
|
515
568
|
throw err
|
|
@@ -523,6 +576,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
523
576
|
options.router.registerOutbound('kakaotalk', outboundCallback)
|
|
524
577
|
options.router.registerChannelNameResolver('kakaotalk', channelResolver.resolve)
|
|
525
578
|
options.router.registerHistory('kakaotalk', historyCallback)
|
|
579
|
+
options.router.registerFetchAttachment('kakaotalk', fetchAttachmentCallback)
|
|
526
580
|
},
|
|
527
581
|
|
|
528
582
|
async stop(): Promise<void> {
|
|
@@ -531,6 +585,7 @@ export function createKakaotalkAdapter(options: KakaotalkAdapterOptions): Kakaot
|
|
|
531
585
|
options.router.unregisterOutbound('kakaotalk', outboundCallback)
|
|
532
586
|
options.router.unregisterChannelNameResolver('kakaotalk', channelResolver.resolve)
|
|
533
587
|
options.router.unregisterHistory('kakaotalk', historyCallback)
|
|
588
|
+
options.router.unregisterFetchAttachment('kakaotalk', fetchAttachmentCallback)
|
|
534
589
|
if (inflightInbounds > 0) {
|
|
535
590
|
await new Promise<void>((resolve) => {
|
|
536
591
|
stopWaiters.push(resolve)
|
|
@@ -601,7 +656,7 @@ function dropHint(
|
|
|
601
656
|
case 'not_in_allow_list':
|
|
602
657
|
return ` (add ${suggestedAllowPattern(bucket, chatId)} to channels.kakaotalk.allow to admit this chat)`
|
|
603
658
|
case 'unknown_chat':
|
|
604
|
-
return ' (chat not in cache
|
|
659
|
+
return ' (chat not in cache after refresh and provisional registration; check earlier resolver-refresh-failed warnings)'
|
|
605
660
|
case 'empty_text':
|
|
606
661
|
case 'pre_connect':
|
|
607
662
|
case 'self_author':
|
package/src/channels/router.ts
CHANGED
|
@@ -776,6 +776,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
776
776
|
}
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
+
const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
|
|
780
|
+
if (!live.hooks) return
|
|
781
|
+
try {
|
|
782
|
+
await live.hooks.runSessionTurnStart({
|
|
783
|
+
sessionId: live.sessionId,
|
|
784
|
+
agentDir: options.agentDir,
|
|
785
|
+
origin: buildLiveOrigin(live),
|
|
786
|
+
})
|
|
787
|
+
} catch (err) {
|
|
788
|
+
logger.warn(`[channels] session.turn.start hook threw for ${live.keyId}: ${describe(err)}`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const fireSessionTurnEnd = async (live: LiveSession): Promise<void> => {
|
|
793
|
+
if (!live.hooks) return
|
|
794
|
+
try {
|
|
795
|
+
await live.hooks.runSessionTurnEnd({
|
|
796
|
+
sessionId: live.sessionId,
|
|
797
|
+
agentDir: options.agentDir,
|
|
798
|
+
origin: buildLiveOrigin(live),
|
|
799
|
+
})
|
|
800
|
+
} catch (err) {
|
|
801
|
+
logger.warn(`[channels] session.turn.end hook threw for ${live.keyId}: ${describe(err)}`)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
779
805
|
const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
|
|
780
806
|
const membership = readMembership(live.key)
|
|
781
807
|
return {
|
|
@@ -848,6 +874,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
848
874
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
849
875
|
const promptStart = now()
|
|
850
876
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
877
|
+
await fireSessionTurnStart(live)
|
|
851
878
|
try {
|
|
852
879
|
await live.session.prompt(text)
|
|
853
880
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -856,6 +883,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
856
883
|
} catch (err) {
|
|
857
884
|
logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
|
|
858
885
|
live.consecutiveSends.clear()
|
|
886
|
+
} finally {
|
|
887
|
+
await fireSessionTurnEnd(live)
|
|
859
888
|
}
|
|
860
889
|
await fireSessionIdle(live)
|
|
861
890
|
live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
|
|
@@ -1155,10 +1184,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1155
1184
|
): Promise<FetchAttachmentResult> => {
|
|
1156
1185
|
const callbacks = fetchAttachmentCallbacks.get(adapter)
|
|
1157
1186
|
if (!callbacks || callbacks.size === 0) {
|
|
1158
|
-
return { ok: false, error:
|
|
1187
|
+
return { ok: false, error: `no fetchAttachment callback registered for "${adapter}"` }
|
|
1159
1188
|
}
|
|
1160
1189
|
const snapshot = Array.from(callbacks)
|
|
1161
|
-
|
|
1190
|
+
// Initialized only so TypeScript can prove the variable is assigned
|
|
1191
|
+
// before return. The loop body always overwrites it on the failure
|
|
1192
|
+
// path (we just returned on the success path), so this string is
|
|
1193
|
+
// unreachable at runtime — kept as a clearly-tagged sentinel rather
|
|
1194
|
+
// than a non-null assertion so a future loop refactor that breaks
|
|
1195
|
+
// this invariant surfaces a recognizable error string.
|
|
1196
|
+
let lastError: FetchAttachmentResult & { ok: false } = {
|
|
1197
|
+
ok: false,
|
|
1198
|
+
error: `fetchAttachment for "${adapter}" returned no result (router bug)`,
|
|
1199
|
+
}
|
|
1162
1200
|
for (const cb of snapshot) {
|
|
1163
1201
|
const result = await cb(args)
|
|
1164
1202
|
if (result.ok) return result
|
package/src/cli/compose.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
composeDoctor,
|
|
5
|
+
composeLogs,
|
|
6
|
+
composeRestart,
|
|
7
|
+
composeStart,
|
|
8
|
+
composeStatus,
|
|
9
|
+
composeStop,
|
|
10
|
+
type AgentResult,
|
|
11
|
+
type ComposeDoctorReport,
|
|
12
|
+
} from '@/compose'
|
|
4
13
|
import { config } from '@/config'
|
|
14
|
+
import { formatJson, formatReport } from '@/doctor'
|
|
5
15
|
|
|
6
16
|
import { formatComposeStatus } from './compose-status'
|
|
7
17
|
import { c, spinner } from './ui'
|
|
@@ -156,6 +166,41 @@ const logsSub = defineCommand({
|
|
|
156
166
|
},
|
|
157
167
|
})
|
|
158
168
|
|
|
169
|
+
const doctorSub = defineCommand({
|
|
170
|
+
meta: { name: 'doctor', description: 'diagnose every agent in immediate subdirectories of cwd' },
|
|
171
|
+
args: {
|
|
172
|
+
verbose: { type: 'boolean', alias: 'v', default: false, description: 'show check details' },
|
|
173
|
+
json: { type: 'boolean', default: false, description: 'emit the report as JSON' },
|
|
174
|
+
fix: {
|
|
175
|
+
type: 'boolean',
|
|
176
|
+
default: false,
|
|
177
|
+
description: 'attempt auto-fixes per agent and commit changes in each agent folder',
|
|
178
|
+
},
|
|
179
|
+
only: { type: 'string', description: 'comma-separated category filter' },
|
|
180
|
+
shallow: {
|
|
181
|
+
type: 'boolean',
|
|
182
|
+
default: false,
|
|
183
|
+
description: 'run cross-agent checks only; skip per-agent doctor runs',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
async run({ args }) {
|
|
187
|
+
const only = args.only
|
|
188
|
+
? args.only
|
|
189
|
+
.split(',')
|
|
190
|
+
.map((s) => s.trim())
|
|
191
|
+
.filter((s) => s.length > 0)
|
|
192
|
+
: undefined
|
|
193
|
+
const report = await composeDoctor({
|
|
194
|
+
rootCwd: process.cwd(),
|
|
195
|
+
fix: args.fix,
|
|
196
|
+
shallow: args.shallow,
|
|
197
|
+
...(only !== undefined ? { only } : {}),
|
|
198
|
+
})
|
|
199
|
+
emitComposeDoctor(report, { verbose: args.verbose, json: args.json })
|
|
200
|
+
if (!report.ok) process.exit(1)
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
159
204
|
export const composeCommand = defineCommand({
|
|
160
205
|
meta: {
|
|
161
206
|
name: 'compose',
|
|
@@ -167,6 +212,7 @@ export const composeCommand = defineCommand({
|
|
|
167
212
|
restart: restartSub,
|
|
168
213
|
status: statusSub,
|
|
169
214
|
logs: logsSub,
|
|
215
|
+
doctor: doctorSub,
|
|
170
216
|
},
|
|
171
217
|
})
|
|
172
218
|
|
|
@@ -238,3 +284,48 @@ function formatRestartDone<T extends { start: { hostPort: number } }>(result: Ag
|
|
|
238
284
|
if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
|
|
239
285
|
return `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
|
|
240
286
|
}
|
|
287
|
+
|
|
288
|
+
function emitComposeDoctor(report: ComposeDoctorReport, opts: { verbose: boolean; json: boolean }): void {
|
|
289
|
+
if (opts.json) {
|
|
290
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
|
|
294
|
+
const sectionHead = useColor ? c.bold : (s: string) => s
|
|
295
|
+
|
|
296
|
+
process.stdout.write(`${sectionHead('compose doctor')} ${c.dim(report.rootCwd)}\n\n`)
|
|
297
|
+
|
|
298
|
+
process.stdout.write(`${sectionHead('Cross-agent checks')}\n`)
|
|
299
|
+
for (const check of report.crossChecks) {
|
|
300
|
+
const marker = checkMarker(check.status)
|
|
301
|
+
process.stdout.write(` ${marker} ${check.message} ${c.dim(`(${check.name})`)}\n`)
|
|
302
|
+
if (opts.verbose && check.details !== undefined) {
|
|
303
|
+
for (const d of check.details) process.stdout.write(` ${c.dim(`• ${d}`)}\n`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write('\n')
|
|
307
|
+
|
|
308
|
+
for (const agent of report.agents) {
|
|
309
|
+
process.stdout.write(`${sectionHead(`Agent: ${agent.entry.name}`)} ${c.dim(agent.entry.cwd)}\n`)
|
|
310
|
+
process.stdout.write(
|
|
311
|
+
`${opts.json ? formatJson(agent.result.final ?? agent.result.initial) : formatReport(agent.result.initial, { useColor, verbose: opts.verbose })}\n\n`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
process.stdout.write(
|
|
316
|
+
`${report.ok ? c.green('●') : c.red('●')} compose doctor ${report.ok ? 'passed' : 'found issues'}\n`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function checkMarker(status: 'ok' | 'warning' | 'error' | 'info'): string {
|
|
321
|
+
switch (status) {
|
|
322
|
+
case 'ok':
|
|
323
|
+
return c.green('✓')
|
|
324
|
+
case 'warning':
|
|
325
|
+
return c.yellow('!')
|
|
326
|
+
case 'error':
|
|
327
|
+
return c.red('✗')
|
|
328
|
+
case 'info':
|
|
329
|
+
return c.cyan('i')
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { formatJson, formatReport, runDoctor, type DoctorRunResult } from '@/doctor'
|
|
4
|
+
import { findAgentDir } from '@/init'
|
|
5
|
+
|
|
6
|
+
export const doctorCommand = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'doctor',
|
|
9
|
+
description: 'diagnose the host, agent folder, and plugins; surface remediation steps',
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
verbose: {
|
|
13
|
+
type: 'boolean',
|
|
14
|
+
alias: 'v',
|
|
15
|
+
description: 'show check details and per-entry hints',
|
|
16
|
+
default: false,
|
|
17
|
+
},
|
|
18
|
+
json: {
|
|
19
|
+
type: 'boolean',
|
|
20
|
+
description: 'emit the doctor report as JSON',
|
|
21
|
+
default: false,
|
|
22
|
+
},
|
|
23
|
+
fix: {
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
description: 'attempt to auto-fix issues and commit changes in the agent folder',
|
|
26
|
+
default: false,
|
|
27
|
+
},
|
|
28
|
+
only: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'comma-separated list of categories to include (e.g. docker,config)',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
35
|
+
const only = parseOnly(args.only)
|
|
36
|
+
const result = await runDoctor({
|
|
37
|
+
cwd,
|
|
38
|
+
fix: args.fix,
|
|
39
|
+
...(only !== undefined ? { only } : {}),
|
|
40
|
+
})
|
|
41
|
+
emit(result, { verbose: args.verbose, json: args.json })
|
|
42
|
+
process.exit(exitCodeFor(result))
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export function exitCodeFor(result: DoctorRunResult): number {
|
|
47
|
+
const last = result.final ?? result.initial
|
|
48
|
+
if (last.ok) return 0
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseOnly(value: string | undefined): string[] | undefined {
|
|
53
|
+
if (value === undefined) return undefined
|
|
54
|
+
const parts = value
|
|
55
|
+
.split(',')
|
|
56
|
+
.map((s) => s.trim())
|
|
57
|
+
.filter((s) => s.length > 0)
|
|
58
|
+
return parts.length > 0 ? parts : undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emit(result: DoctorRunResult, opts: { verbose: boolean; json: boolean }): void {
|
|
62
|
+
if (opts.json) {
|
|
63
|
+
process.stdout.write(`${formatJson(result.final ?? result.initial)}\n`)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
|
|
67
|
+
process.stdout.write(`${formatReport(result.initial, { useColor, verbose: opts.verbose })}\n`)
|
|
68
|
+
|
|
69
|
+
if (result.fixAttempts) {
|
|
70
|
+
process.stdout.write('\n')
|
|
71
|
+
process.stdout.write(`${formatFixAttempts(result, useColor)}\n`)
|
|
72
|
+
}
|
|
73
|
+
if (result.final) {
|
|
74
|
+
process.stdout.write('\n')
|
|
75
|
+
process.stdout.write(`${formatReport(result.final, { useColor, verbose: opts.verbose })}\n`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatFixAttempts(result: DoctorRunResult, useColor: boolean): string {
|
|
80
|
+
const lines: string[] = []
|
|
81
|
+
lines.push(useColor ? '\u001b[1m--fix\u001b[0m' : '--fix')
|
|
82
|
+
for (const attempt of result.fixAttempts ?? []) {
|
|
83
|
+
const tag = attempt.source === 'static' ? '[static]' : `[plugin]`
|
|
84
|
+
if (attempt.ok) {
|
|
85
|
+
lines.push(` ${tag} ${attempt.name}: ${attempt.summary}`)
|
|
86
|
+
} else {
|
|
87
|
+
lines.push(` ${tag} ${attempt.name}: failed: ${attempt.reason}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (result.commit) {
|
|
91
|
+
if (result.commit.kind === 'committed') {
|
|
92
|
+
lines.push(` commit: ${result.commit.commitSha.slice(0, 12)} (${result.commit.pathsStaged.length} path(s))`)
|
|
93
|
+
} else if (result.commit.kind === 'skipped') {
|
|
94
|
+
lines.push(` commit: skipped — ${result.commit.reason}`)
|
|
95
|
+
} else {
|
|
96
|
+
lines.push(` commit: failed — ${result.commit.reason}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.join('\n')
|
|
100
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ const main = defineCommand({
|
|
|
20
20
|
shell: () => import('./shell').then((m) => m.shellCommand),
|
|
21
21
|
compose: () => import('./compose').then((m) => m.composeCommand),
|
|
22
22
|
channel: () => import('./channel').then((m) => m.channelCommand),
|
|
23
|
+
doctor: () => import('./doctor').then((m) => m.doctorCommand),
|
|
23
24
|
_hostd: () => import('./hostd').then((m) => m.hostdCommand),
|
|
24
25
|
},
|
|
25
26
|
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { loadConfigSync } from '@/config'
|
|
2
|
+
import { runDoctor, type DoctorRunResult, type RunDoctorOptions } from '@/doctor'
|
|
3
|
+
|
|
4
|
+
import { discoverAgents, type AgentEntry } from './discover'
|
|
5
|
+
|
|
6
|
+
export type ComposeDoctorAgent = {
|
|
7
|
+
entry: AgentEntry
|
|
8
|
+
result: DoctorRunResult
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ComposeDoctorCrossCheck = {
|
|
12
|
+
name: string
|
|
13
|
+
status: 'ok' | 'warning' | 'error' | 'info'
|
|
14
|
+
message: string
|
|
15
|
+
details?: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ComposeDoctorReport = {
|
|
19
|
+
rootCwd: string
|
|
20
|
+
agents: ComposeDoctorAgent[]
|
|
21
|
+
crossChecks: ComposeDoctorCrossCheck[]
|
|
22
|
+
ok: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ComposeDoctorOptions = {
|
|
26
|
+
rootCwd: string
|
|
27
|
+
fix?: boolean
|
|
28
|
+
only?: string[]
|
|
29
|
+
shallow?: boolean
|
|
30
|
+
runDoctorFn?: (opts: RunDoctorOptions) => Promise<DoctorRunResult>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function composeDoctor(opts: ComposeDoctorOptions): Promise<ComposeDoctorReport> {
|
|
34
|
+
const runDoctorFn = opts.runDoctorFn ?? runDoctor
|
|
35
|
+
const agents = discoverAgents(opts.rootCwd)
|
|
36
|
+
|
|
37
|
+
const crossChecks: ComposeDoctorCrossCheck[] = []
|
|
38
|
+
if (agents.length === 0) {
|
|
39
|
+
crossChecks.push({
|
|
40
|
+
name: 'compose.root-has-agents',
|
|
41
|
+
status: 'info',
|
|
42
|
+
message: 'no typeclaw agents found in immediate subdirectories',
|
|
43
|
+
})
|
|
44
|
+
} else {
|
|
45
|
+
crossChecks.push(...runCrossChecks(agents))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let agentResults: ComposeDoctorAgent[] = []
|
|
49
|
+
if (!opts.shallow) {
|
|
50
|
+
agentResults = await Promise.all(
|
|
51
|
+
agents.map(async (entry) => ({
|
|
52
|
+
entry,
|
|
53
|
+
result: await runDoctorFn({
|
|
54
|
+
cwd: entry.cwd,
|
|
55
|
+
...(opts.fix === true ? { fix: true } : {}),
|
|
56
|
+
...(opts.only !== undefined ? { only: opts.only } : {}),
|
|
57
|
+
}),
|
|
58
|
+
})),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ok =
|
|
63
|
+
crossChecks.every((c) => c.status === 'ok' || c.status === 'info') &&
|
|
64
|
+
agentResults.every((a) => (a.result.final ?? a.result.initial).ok)
|
|
65
|
+
|
|
66
|
+
return { rootCwd: opts.rootCwd, agents: agentResults, crossChecks, ok }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function runCrossChecks(agents: AgentEntry[]): ComposeDoctorCrossCheck[] {
|
|
70
|
+
const checks: ComposeDoctorCrossCheck[] = []
|
|
71
|
+
|
|
72
|
+
const portConfigs = collectPreferredPorts(agents)
|
|
73
|
+
const portDuplicates = findDuplicates(portConfigs.map(({ port }) => port))
|
|
74
|
+
if (portDuplicates.size === 0) {
|
|
75
|
+
checks.push({
|
|
76
|
+
name: 'compose.no-port-collisions',
|
|
77
|
+
status: 'ok',
|
|
78
|
+
message: `${portConfigs.length} agent(s) declare unique preferred ports`,
|
|
79
|
+
})
|
|
80
|
+
} else {
|
|
81
|
+
const details = [...portDuplicates].map((port) => {
|
|
82
|
+
const names = portConfigs.filter((p) => p.port === port).map((p) => p.name)
|
|
83
|
+
return `port ${port}: ${names.join(', ')}`
|
|
84
|
+
})
|
|
85
|
+
checks.push({
|
|
86
|
+
name: 'compose.no-port-collisions',
|
|
87
|
+
status: 'warning',
|
|
88
|
+
message: `${portDuplicates.size} preferred port(s) shared across agents`,
|
|
89
|
+
details,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const nameDuplicates = findDuplicates(agents.map((a) => a.containerName))
|
|
94
|
+
if (nameDuplicates.size === 0) {
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'compose.no-container-name-collisions',
|
|
97
|
+
status: 'ok',
|
|
98
|
+
message: 'all agent folders map to unique Docker names',
|
|
99
|
+
})
|
|
100
|
+
} else {
|
|
101
|
+
const details = [...nameDuplicates].map((name) => {
|
|
102
|
+
const collisions = agents.filter((a) => a.containerName === name).map((a) => a.name)
|
|
103
|
+
return `${name}: ${collisions.join(', ')}`
|
|
104
|
+
})
|
|
105
|
+
checks.push({
|
|
106
|
+
name: 'compose.no-container-name-collisions',
|
|
107
|
+
status: 'error',
|
|
108
|
+
message: `${nameDuplicates.size} container name(s) shared across agents`,
|
|
109
|
+
details,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return checks
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectPreferredPorts(agents: AgentEntry[]): Array<{ name: string; port: number }> {
|
|
117
|
+
const out: Array<{ name: string; port: number }> = []
|
|
118
|
+
for (const agent of agents) {
|
|
119
|
+
const port = readPreferredPort(agent.cwd)
|
|
120
|
+
if (port !== null) out.push({ name: agent.name, port })
|
|
121
|
+
}
|
|
122
|
+
return out
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function readPreferredPort(cwd: string): number | null {
|
|
126
|
+
try {
|
|
127
|
+
return loadConfigSync(cwd).port
|
|
128
|
+
} catch {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findDuplicates<T>(items: T[]): Set<T> {
|
|
134
|
+
const seen = new Set<T>()
|
|
135
|
+
const dupes = new Set<T>()
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
if (seen.has(item)) dupes.add(item)
|
|
138
|
+
else seen.add(item)
|
|
139
|
+
}
|
|
140
|
+
return dupes
|
|
141
|
+
}
|
package/src/compose/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export { discoverAgents, type AgentEntry } from './discover'
|
|
2
|
+
export {
|
|
3
|
+
composeDoctor,
|
|
4
|
+
runCrossChecks,
|
|
5
|
+
type ComposeDoctorAgent,
|
|
6
|
+
type ComposeDoctorCrossCheck,
|
|
7
|
+
type ComposeDoctorOptions,
|
|
8
|
+
type ComposeDoctorReport,
|
|
9
|
+
} from './doctor'
|
|
2
10
|
export { colorFor, composeLogs, makeLinePrefixer, type ComposeLogsOptions, type ComposeLogsResult } from './logs'
|
|
3
11
|
export { composeStatus, type AgentRuntimeState, type AgentStatusEntry, type ComposeStatusResult } from './status'
|
|
4
12
|
export {
|
package/src/compose/logs.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { containerExists } from '@/container'
|
|
2
|
+
import { supportsColor } from '@/container/log-colors'
|
|
3
|
+
import { makeLogTimestampReformatter, type TimestampReformatter } from '@/container/log-timestamps'
|
|
2
4
|
import { getBun } from '@/container/shared'
|
|
3
5
|
|
|
4
6
|
import { discoverAgents, type AgentEntry } from './discover'
|
|
@@ -91,7 +93,9 @@ export async function composeLogs({
|
|
|
91
93
|
const useColor = supportsColor(out)
|
|
92
94
|
|
|
93
95
|
const procs = attached.map((agent) => {
|
|
94
|
-
const cmd = follow
|
|
96
|
+
const cmd = follow
|
|
97
|
+
? ['docker', 'logs', '--timestamps', '-f', agent.containerName]
|
|
98
|
+
: ['docker', 'logs', '--timestamps', agent.containerName]
|
|
95
99
|
const proc = bun.spawn({ cmd, stdout: 'pipe', stderr: 'pipe' })
|
|
96
100
|
return { agent, proc }
|
|
97
101
|
})
|
|
@@ -110,8 +114,18 @@ export async function composeLogs({
|
|
|
110
114
|
const pumps = procs.flatMap(({ agent, proc }) => {
|
|
111
115
|
const color = colorFor(agent.name)
|
|
112
116
|
return [
|
|
113
|
-
pumpStream(
|
|
114
|
-
|
|
117
|
+
pumpStream(
|
|
118
|
+
proc.stdout,
|
|
119
|
+
makeLogTimestampReformatter(undefined, { color: useColor }),
|
|
120
|
+
makeLinePrefixer(agent.name, width, color, useColor),
|
|
121
|
+
out,
|
|
122
|
+
),
|
|
123
|
+
pumpStream(
|
|
124
|
+
proc.stderr,
|
|
125
|
+
makeLogTimestampReformatter(undefined, { color: useColor }),
|
|
126
|
+
makeLinePrefixer(agent.name, width, color, useColor),
|
|
127
|
+
err,
|
|
128
|
+
),
|
|
115
129
|
]
|
|
116
130
|
})
|
|
117
131
|
|
|
@@ -128,35 +142,34 @@ export async function composeLogs({
|
|
|
128
142
|
|
|
129
143
|
async function pumpStream(
|
|
130
144
|
stream: ReadableStream<Uint8Array>,
|
|
145
|
+
reformatter: TimestampReformatter,
|
|
131
146
|
prefixer: { write: (s: string) => string; flush: () => string },
|
|
132
147
|
sink: NodeJS.WritableStream,
|
|
133
148
|
): Promise<void> {
|
|
134
149
|
const decoder = new TextDecoder()
|
|
135
150
|
const reader = stream.getReader()
|
|
151
|
+
const writeChunk = (chunk: string): void => {
|
|
152
|
+
const reformatted = reformatter.write(chunk)
|
|
153
|
+
if (reformatted.length === 0) return
|
|
154
|
+
const prefixed = prefixer.write(reformatted)
|
|
155
|
+
if (prefixed.length > 0) sink.write(prefixed)
|
|
156
|
+
}
|
|
136
157
|
try {
|
|
137
158
|
while (true) {
|
|
138
159
|
const { done, value } = await reader.read()
|
|
139
160
|
if (done) break
|
|
140
|
-
if (value && value.byteLength > 0) {
|
|
141
|
-
const out = prefixer.write(decoder.decode(value, { stream: true }))
|
|
142
|
-
if (out.length > 0) sink.write(out)
|
|
143
|
-
}
|
|
161
|
+
if (value && value.byteLength > 0) writeChunk(decoder.decode(value, { stream: true }))
|
|
144
162
|
}
|
|
145
163
|
const tail = decoder.decode()
|
|
146
|
-
if (tail.length > 0)
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
if (tail.length > 0) writeChunk(tail)
|
|
165
|
+
const flushedTs = reformatter.flush()
|
|
166
|
+
if (flushedTs.length > 0) {
|
|
167
|
+
const prefixed = prefixer.write(flushedTs)
|
|
168
|
+
if (prefixed.length > 0) sink.write(prefixed)
|
|
149
169
|
}
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
170
|
+
const flushedPrefix = prefixer.flush()
|
|
171
|
+
if (flushedPrefix.length > 0) sink.write(flushedPrefix)
|
|
152
172
|
} finally {
|
|
153
173
|
reader.releaseLock()
|
|
154
174
|
}
|
|
155
175
|
}
|
|
156
|
-
|
|
157
|
-
function supportsColor(stream: NodeJS.WritableStream): boolean {
|
|
158
|
-
const tty = (stream as unknown as { isTTY?: boolean }).isTTY === true
|
|
159
|
-
if (!tty) return false
|
|
160
|
-
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
161
|
-
return true
|
|
162
|
-
}
|