typeclaw 0.1.1 → 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 (43) hide show
  1. package/README.md +12 -12
  2. package/package.json +1 -1
  3. package/src/agent/doctor.ts +173 -0
  4. package/src/agent/subagents.ts +24 -2
  5. package/src/bundled-plugins/backup/README.md +81 -0
  6. package/src/bundled-plugins/backup/index.ts +209 -0
  7. package/src/bundled-plugins/backup/runner.ts +231 -0
  8. package/src/bundled-plugins/backup/subagents.ts +200 -0
  9. package/src/bundled-plugins/memory/index.ts +42 -1
  10. package/src/channels/router.ts +29 -0
  11. package/src/cli/compose.ts +92 -1
  12. package/src/cli/doctor.ts +100 -0
  13. package/src/cli/index.ts +1 -0
  14. package/src/compose/doctor.ts +141 -0
  15. package/src/compose/index.ts +8 -0
  16. package/src/compose/logs.ts +32 -19
  17. package/src/config/config.ts +20 -0
  18. package/src/container/log-colors.ts +75 -0
  19. package/src/container/log-timestamps.ts +84 -0
  20. package/src/container/logs.ts +71 -5
  21. package/src/container/start.ts +23 -8
  22. package/src/cron/consumer.ts +29 -7
  23. package/src/doctor/checks.ts +426 -0
  24. package/src/doctor/commit.ts +71 -0
  25. package/src/doctor/index.ts +287 -0
  26. package/src/doctor/plugin-bridge.ts +147 -0
  27. package/src/doctor/report.ts +142 -0
  28. package/src/doctor/types.ts +87 -0
  29. package/src/init/cli-version.ts +81 -0
  30. package/src/init/dockerfile.ts +223 -25
  31. package/src/init/index.ts +18 -10
  32. package/src/plugin/hooks.ts +32 -0
  33. package/src/plugin/index.ts +7 -0
  34. package/src/plugin/manager.ts +2 -0
  35. package/src/plugin/registry.ts +32 -3
  36. package/src/plugin/types.ts +65 -0
  37. package/src/run/bundled-plugins.ts +8 -0
  38. package/src/run/index.ts +10 -5
  39. package/src/server/index.ts +103 -5
  40. package/src/shared/index.ts +3 -0
  41. package/src/shared/protocol.ts +22 -0
  42. package/src/skills/typeclaw-config/SKILL.md +1 -1
  43. package/typeclaw.schema.json +50 -0
@@ -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
- }
@@ -104,6 +104,23 @@ export const gitignoreSchema = z
104
104
 
105
105
  export type GitignoreConfig = z.infer<typeof gitignoreSchema>
106
106
 
107
+ // `blockInternal` is the kill-switch for the container-stage egress filter
108
+ // installed by Dockerfile entrypoint shim: when true, the container is granted
109
+ // CAP_NET_ADMIN at boot just long enough to install iptables OUTPUT rules
110
+ // that DROP traffic to RFC1918, link-local (incl. cloud metadata), CGNAT,
111
+ // multicast/reserved, IPv6 ULA/link-local/multicast. The capability is then
112
+ // dropped from the bounding set via setpriv before the agent process exec's,
113
+ // so no child (python, curl, bun-spawned anything) can mutate or recover it.
114
+ // Default is `false` so existing agent folders are unaffected by an upgrade;
115
+ // `typeclaw init` writes `true` for new agents (handled separately in init).
116
+ export const networkSchema = z
117
+ .object({
118
+ blockInternal: z.boolean().default(false),
119
+ })
120
+ .default({ blockInternal: false })
121
+
122
+ export type NetworkConfig = z.infer<typeof networkSchema>
123
+
107
124
  export const configSchema = z
108
125
  .object({
109
126
  $schema: z.string().optional(),
@@ -123,6 +140,7 @@ export const configSchema = z
123
140
  alias: z.array(z.string().trim().min(1)).default([]),
124
141
  channels: channelsSchema,
125
142
  portForward: portForwardSchema,
143
+ network: networkSchema,
126
144
  dockerfile: dockerfileSchema,
127
145
  gitignore: gitignoreSchema,
128
146
  })
@@ -213,6 +231,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
213
231
  alias: 'applied',
214
232
  channels: 'applied',
215
233
  portForward: 'restart-required',
234
+ network: 'restart-required',
216
235
  dockerfile: 'restart-required',
217
236
  gitignore: 'restart-required',
218
237
  }
@@ -276,6 +295,7 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
276
295
  'plugins',
277
296
  'channels',
278
297
  'portForward',
298
+ 'network',
279
299
  'dockerfile',
280
300
  'gitignore',
281
301
  ])
@@ -0,0 +1,75 @@
1
+ // Per-source line tinting for `typeclaw logs` and `typeclaw compose logs`.
2
+ // PR #146's wall-clock timestamp prefix made every line start identically,
3
+ // dropping readability when many sources interleave. We restore visual
4
+ // grouping by tinting each line based on its first `[tag]` (`[plugin:memory]`,
5
+ // `[memory-logger]`, etc.). Same trick `compose/logs.ts#colorFor` uses for
6
+ // agent names — stable hash → palette index → ANSI escape that wraps the
7
+ // whole line body.
8
+ //
9
+ // Lines without a `[tag]` get no tint (only the leading timestamp is dimmed),
10
+ // so untagged docker output, raw stack traces, and channel adapter logs
11
+ // stay readable without inheriting a misleading color.
12
+
13
+ import type { WritableStream as NodeWritable } from 'node:stream/web'
14
+
15
+ const ANSI_RESET = '\x1b[0m'
16
+ const ANSI_DIM = '\x1b[2m'
17
+ const ANSI_CYAN = '\x1b[36m'
18
+ const ANSI_YELLOW = '\x1b[33m'
19
+ const ANSI_GREEN = '\x1b[32m'
20
+ const ANSI_MAGENTA = '\x1b[35m'
21
+ const ANSI_BLUE = '\x1b[34m'
22
+ const ANSI_BRIGHT_CYAN = '\x1b[96m'
23
+ const ANSI_BRIGHT_YELLOW = '\x1b[93m'
24
+ const ANSI_BRIGHT_GREEN = '\x1b[92m'
25
+ const ANSI_BRIGHT_MAGENTA = '\x1b[95m'
26
+ const ANSI_BRIGHT_BLUE = '\x1b[94m'
27
+
28
+ const TAG_PALETTE = [
29
+ ANSI_CYAN,
30
+ ANSI_YELLOW,
31
+ ANSI_GREEN,
32
+ ANSI_MAGENTA,
33
+ ANSI_BLUE,
34
+ ANSI_BRIGHT_CYAN,
35
+ ANSI_BRIGHT_YELLOW,
36
+ ANSI_BRIGHT_GREEN,
37
+ ANSI_BRIGHT_MAGENTA,
38
+ ANSI_BRIGHT_BLUE,
39
+ ] as const
40
+
41
+ // Anchored to line start with a trailing space so a date that happens to
42
+ // appear mid-line doesn't get dimmed.
43
+ const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?= )/
44
+
45
+ const TAG_RE = /\[([^\]\n]+)\]/
46
+
47
+ // `useColor=false` returns the input verbatim; the contract that protects
48
+ // pipes, file redirects, NO_COLOR, and tests from ANSI leakage.
49
+ export function colorize(line: string, useColor: boolean): string {
50
+ if (!useColor || line.length === 0) return line
51
+
52
+ const ts = TIMESTAMP_RE.exec(line)
53
+ const timestampLen = ts ? ts[0].length : 0
54
+ const dimmedTimestamp = ts ? `${ANSI_DIM}${ts[0]}${ANSI_RESET}` : ''
55
+ const rest = line.slice(timestampLen)
56
+
57
+ const tag = TAG_RE.exec(rest)
58
+ if (!tag) return `${dimmedTimestamp}${rest}`
59
+
60
+ const tint = paletteColor(tag[1] ?? '')
61
+ return `${dimmedTimestamp}${tint}${rest}${ANSI_RESET}`
62
+ }
63
+
64
+ function paletteColor(seed: string): string {
65
+ let h = 0
66
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
67
+ return TAG_PALETTE[h % TAG_PALETTE.length] ?? TAG_PALETTE[0]
68
+ }
69
+
70
+ export function supportsColor(stream: NodeJS.WritableStream | NodeWritable): boolean {
71
+ const tty = (stream as unknown as { isTTY?: boolean }).isTTY === true
72
+ if (!tty) return false
73
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
74
+ return true
75
+ }
@@ -0,0 +1,84 @@
1
+ // Docker emits each `--timestamps` line as `<RFC3339Nano> <body>\n`, e.g.
2
+ // `2026-05-13T14:23:01.123456789Z hello world\n`. RFC3339Nano is precise but
3
+ // painful to read live; humans want a wall-clock prefix. This module parses
4
+ // the leading token, reformats it to `YYYY-MM-DD HH:MM:SS` in the host's
5
+ // local timezone, and passes everything after the first space through.
6
+ //
7
+ // Stateful chunker mirroring src/compose/logs.ts#makeLinePrefixer: only emits
8
+ // newline-terminated lines and flushes the un-terminated tail on EOF, so
9
+ // interleaved reads from `docker logs` can never shred a line mid-character.
10
+
11
+ import { colorize } from './log-colors'
12
+
13
+ // `2026-05-13T14:23:01.123456789Z` or `...+09:00` etc. We accept anything
14
+ // from Docker that Date can parse, but anchor on the ISO date+time prefix to
15
+ // avoid eating non-timestamped log content.
16
+ const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})) (.*)$/
17
+
18
+ export type TimestampReformatter = {
19
+ write: (chunk: string) => string
20
+ flush: () => string
21
+ }
22
+
23
+ export type ReformatterOptions = {
24
+ // Color is opt-in and applied per emitted line *after* timestamp rewriting.
25
+ // Defaults to `false` so tests stay deterministic and pipes/files stay
26
+ // ANSI-free unless callers explicitly opt in via supportsColor(out).
27
+ color?: boolean
28
+ }
29
+
30
+ export function makeLogTimestampReformatter(
31
+ now: () => Date = () => new Date(),
32
+ options: ReformatterOptions = {},
33
+ ): TimestampReformatter {
34
+ const useColor = options.color ?? false
35
+ let buffer = ''
36
+ return {
37
+ write(chunk: string): string {
38
+ buffer += chunk
39
+ const nl = buffer.lastIndexOf('\n')
40
+ if (nl < 0) return ''
41
+ const complete = buffer.slice(0, nl + 1)
42
+ buffer = buffer.slice(nl + 1)
43
+ return complete
44
+ .split('\n')
45
+ .slice(0, -1)
46
+ .map((line) => `${colorize(reformatLine(line, now), useColor)}\n`)
47
+ .join('')
48
+ },
49
+ flush(): string {
50
+ if (buffer.length === 0) return ''
51
+ const out = `${colorize(reformatLine(buffer, now), useColor)}\n`
52
+ buffer = ''
53
+ return out
54
+ },
55
+ }
56
+ }
57
+
58
+ // Exported for tests. Format: `YYYY-MM-DD HH:MM:SS <rest>`. Falls back to the
59
+ // raw line if it doesn't look like a Docker `--timestamps` line, and falls
60
+ // back to `now()` if Docker's timestamp doesn't parse (defensive — shouldn't
61
+ // happen, but losing one timestamp shouldn't hide the log body).
62
+ export function reformatLine(line: string, now: () => Date = () => new Date()): string {
63
+ const match = TIMESTAMP_RE.exec(line)
64
+ if (!match) return line
65
+ const [, raw, body] = match
66
+ if (raw === undefined || body === undefined) return line
67
+ const parsed = new Date(raw)
68
+ const stamp = formatLocal(Number.isNaN(parsed.getTime()) ? now() : parsed)
69
+ return `${stamp} ${body}`
70
+ }
71
+
72
+ function formatLocal(d: Date): string {
73
+ const year = d.getFullYear()
74
+ const month = pad2(d.getMonth() + 1)
75
+ const day = pad2(d.getDate())
76
+ const hour = pad2(d.getHours())
77
+ const minute = pad2(d.getMinutes())
78
+ const second = pad2(d.getSeconds())
79
+ return `${year}-${month}-${day} ${hour}:${minute}:${second}`
80
+ }
81
+
82
+ function pad2(n: number): string {
83
+ return n < 10 ? `0${n}` : String(n)
84
+ }
@@ -1,3 +1,5 @@
1
+ import { supportsColor } from './log-colors'
2
+ import { makeLogTimestampReformatter, type TimestampReformatter } from './log-timestamps'
1
3
  import { containerExists, containerNameFromCwd, getBun } from './shared'
2
4
 
3
5
  export type LogsPlan = {
@@ -7,7 +9,25 @@ export type LogsPlan = {
7
9
 
8
10
  export type LogsResult = { ok: true; containerName: string; exitCode: number } | { ok: false; reason: string }
9
11
 
10
- export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): Promise<LogsResult> {
12
+ export type LogsOptions = {
13
+ cwd: string
14
+ follow: boolean
15
+ out?: NodeJS.WritableStream
16
+ err?: NodeJS.WritableStream
17
+ signal?: AbortSignal
18
+ // When undefined, defaults to TTY+NO_COLOR detection on `out`/`err`.
19
+ // Tests pass `false` for deterministic plain output.
20
+ useColor?: boolean
21
+ }
22
+
23
+ export async function logs({
24
+ cwd,
25
+ follow,
26
+ out = process.stdout,
27
+ err = process.stderr,
28
+ signal,
29
+ useColor,
30
+ }: LogsOptions): Promise<LogsResult> {
11
31
  const bun = getBun()
12
32
  if (!bun) return { ok: false, reason: 'bun runtime not available' }
13
33
 
@@ -18,14 +38,30 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
18
38
  return { ok: false, reason: `Container ${containerName} not found. Run \`typeclaw start\` first.` }
19
39
  }
20
40
 
21
- const cmd = ['docker', 'logs']
41
+ const cmd = ['docker', 'logs', '--timestamps']
22
42
  if (follow) cmd.push('-f')
23
43
  cmd.push(containerName)
24
44
 
25
- // Inherit stdio so logs stream live and Ctrl+C reaches `docker logs`,
26
- // which exits cleanly on SIGINT in follow mode.
27
- const proc = bun.spawn({ cmd, cwd, stdout: 'inherit', stderr: 'inherit' })
45
+ const proc = bun.spawn({ cmd, cwd, stdout: 'pipe', stderr: 'pipe' })
46
+
47
+ const onAbort = (): void => {
48
+ try {
49
+ proc.kill('SIGTERM')
50
+ } catch {
51
+ // already exited
52
+ }
53
+ }
54
+ signal?.addEventListener('abort', onAbort, { once: true })
55
+
56
+ const colorOut = useColor ?? supportsColor(out)
57
+ const colorErr = useColor ?? supportsColor(err)
58
+ await Promise.all([
59
+ pumpWithTimestamps(proc.stdout, out, makeLogTimestampReformatter(undefined, { color: colorOut })),
60
+ pumpWithTimestamps(proc.stderr, err, makeLogTimestampReformatter(undefined, { color: colorErr })),
61
+ ])
28
62
  const exitCode = await proc.exited
63
+ signal?.removeEventListener('abort', onAbort)
64
+
29
65
  return { ok: true, containerName, exitCode }
30
66
  } catch (error) {
31
67
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -35,3 +71,33 @@ export async function logs({ cwd, follow }: { cwd: string; follow: boolean }): P
35
71
  export function planLogs(cwd: string, { follow }: { follow: boolean }): LogsPlan {
36
72
  return { containerName: containerNameFromCwd(cwd), follow }
37
73
  }
74
+
75
+ // Exported for `compose/logs.ts` so the multi-agent path reuses the same
76
+ // reformatter and stays consistent with single-agent output.
77
+ export async function pumpWithTimestamps(
78
+ stream: ReadableStream<Uint8Array>,
79
+ sink: NodeJS.WritableStream,
80
+ reformatter: TimestampReformatter = makeLogTimestampReformatter(),
81
+ ): Promise<void> {
82
+ const decoder = new TextDecoder()
83
+ const reader = stream.getReader()
84
+ try {
85
+ while (true) {
86
+ const { done, value } = await reader.read()
87
+ if (done) break
88
+ if (value && value.byteLength > 0) {
89
+ const out = reformatter.write(decoder.decode(value, { stream: true }))
90
+ if (out.length > 0) sink.write(out)
91
+ }
92
+ }
93
+ const tail = decoder.decode()
94
+ if (tail.length > 0) {
95
+ const out = reformatter.write(tail)
96
+ if (out.length > 0) sink.write(out)
97
+ }
98
+ const flushed = reformatter.flush()
99
+ if (flushed.length > 0) sink.write(flushed)
100
+ } finally {
101
+ reader.releaseLock()
102
+ }
103
+ }