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.
Files changed (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. 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.message,
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; resolver refresh may be lagging)'
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':
@@ -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: 'fetch-attachment-not-supported' }
1187
+ return { ok: false, error: `no fetchAttachment callback registered for "${adapter}"` }
1159
1188
  }
1160
1189
  const snapshot = Array.from(callbacks)
1161
- let lastError: FetchAttachmentResult & { ok: false } = { ok: false, error: 'fetch-attachment-not-supported' }
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
@@ -1,7 +1,17 @@
1
1
  import { defineCommand } from 'citty'
2
2
 
3
- import { composeLogs, composeRestart, composeStart, composeStatus, composeStop, type AgentResult } from '@/compose'
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
+ }
@@ -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 {
@@ -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 ? ['docker', 'logs', '-f', agent.containerName] : ['docker', 'logs', agent.containerName]
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(proc.stdout, makeLinePrefixer(agent.name, width, color, useColor), out),
114
- pumpStream(proc.stderr, makeLinePrefixer(agent.name, width, color, useColor), err),
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
- const out = prefixer.write(tail)
148
- if (out.length > 0) sink.write(out)
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 flushed = prefixer.flush()
151
- if (flushed.length > 0) sink.write(flushed)
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
- }