typeclaw 0.1.1 → 0.1.3

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 (74) hide show
  1. package/README.md +16 -12
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -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
@@ -2,9 +2,12 @@
2
2
 
3
3
  import { defineCommand, runMain } from 'citty'
4
4
 
5
+ import { CLI_VERSION } from '../init/cli-version'
6
+
5
7
  const main = defineCommand({
6
8
  meta: {
7
9
  name: 'typeclaw',
10
+ version: CLI_VERSION,
8
11
  description: 'TypeClaw agent runtime',
9
12
  },
10
13
  subCommands: {
@@ -20,6 +23,7 @@ const main = defineCommand({
20
23
  shell: () => import('./shell').then((m) => m.shellCommand),
21
24
  compose: () => import('./compose').then((m) => m.composeCommand),
22
25
  channel: () => import('./channel').then((m) => m.channelCommand),
26
+ doctor: () => import('./doctor').then((m) => m.doctorCommand),
23
27
  _hostd: () => import('./hostd').then((m) => m.hostdCommand),
24
28
  },
25
29
  })
package/src/cli/init.ts CHANGED
@@ -275,6 +275,7 @@ export const init = defineCommand({
275
275
  cwd,
276
276
  llmAuth,
277
277
  model: selectedModel.ref,
278
+ cliEntry: process.argv[1],
278
279
  ...(discordBotToken !== undefined ? { discordBotToken } : {}),
279
280
  ...(slackBotToken !== undefined ? { slackBotToken, slackAppToken } : {}),
280
281
  ...(telegramBotToken !== undefined ? { telegramBotToken } : {}),
@@ -414,7 +415,7 @@ function preflightFailureGuidance(result: Extract<DockerAvailability, { ok: fals
414
415
  }
415
416
 
416
417
  function reportKakaotalkAuth(result: KakaotalkAuthResult): string {
417
- if (result.ok) return 'KakaoTalk credentials saved to workspace/.agent-messenger/.'
418
+ if (result.ok) return 'KakaoTalk credentials saved to secrets.json.'
418
419
  return `KakaoTalk login failed: ${result.reason}`
419
420
  }
420
421
 
package/src/cli/ui.ts CHANGED
@@ -2,6 +2,8 @@ import { styleText } from 'node:util'
2
2
 
3
3
  import { cancel, intro, isCancel, log, note, outro, spinner as clackSpinner } from '@clack/prompts'
4
4
 
5
+ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrade'
6
+
5
7
  export { cancel, intro, isCancel, log, note, outro }
6
8
 
7
9
  function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
@@ -62,6 +64,7 @@ export type StartLikeResult = {
62
64
  hostPort: number
63
65
  containerId: string
64
66
  hostd: { state: 'registered' } | { state: 'unavailable'; reason: string } | { state: 'disabled' }
67
+ autoUpgrade?: AutoUpgradeOutcome
65
68
  }
66
69
 
67
70
  export function renderStartSuccess(result: StartLikeResult): string {
@@ -69,6 +72,14 @@ export function renderStartSuccess(result: StartLikeResult): string {
69
72
  const name = c.cyan(result.plan.containerName)
70
73
  const port = c.green(String(result.hostPort))
71
74
 
75
+ if (result.autoUpgrade) {
76
+ const message = describeAutoUpgrade(result.autoUpgrade)
77
+ if (message.length > 0) {
78
+ const tint = result.autoUpgrade.kind === 'exact-pin-respected' ? c.yellow : c.cyan
79
+ lines.push(tint(message))
80
+ }
81
+ }
82
+
72
83
  if (result.alreadyRunning) {
73
84
  lines.push(`${c.green('●')} ${name} is already running on host port ${port}.`)
74
85
  } else {
@@ -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,34 @@ 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
+ //
115
+ // Default is `true`: the threat model that motivated this feature — prompt
116
+ // injection asking the agent to fetch RFC1918 hosts (e.g. a LAN router admin
117
+ // page) or the cloud-IMDS endpoint — applies to every agent equally, so the
118
+ // safe default is "on" and
119
+ // the explicit opt-out is for users who need their agent to reach LAN hosts
120
+ // (NAS, internal services, sibling dev machines). PR #145 shipped this with
121
+ // default `false` to preserve existing-folder behavior on upgrade; this
122
+ // follow-up (the one PR #145 promised in its description) makes the default
123
+ // match the intent. `typeclaw init` also writes `true` explicitly so the
124
+ // field is discoverable in fresh `typeclaw.json` files. Loopback traffic
125
+ // (`-o lo`) is always allowed by the shim, so `bun run dev` and local APIs
126
+ // on `localhost` / `127.0.0.1` are unaffected.
127
+ export const networkSchema = z
128
+ .object({
129
+ blockInternal: z.boolean().default(true),
130
+ })
131
+ .default({ blockInternal: true })
132
+
133
+ export type NetworkConfig = z.infer<typeof networkSchema>
134
+
107
135
  export const configSchema = z
108
136
  .object({
109
137
  $schema: z.string().optional(),
@@ -123,6 +151,7 @@ export const configSchema = z
123
151
  alias: z.array(z.string().trim().min(1)).default([]),
124
152
  channels: channelsSchema,
125
153
  portForward: portForwardSchema,
154
+ network: networkSchema,
126
155
  dockerfile: dockerfileSchema,
127
156
  gitignore: gitignoreSchema,
128
157
  })
@@ -213,6 +242,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
213
242
  alias: 'applied',
214
243
  channels: 'applied',
215
244
  portForward: 'restart-required',
245
+ network: 'restart-required',
216
246
  dockerfile: 'restart-required',
217
247
  gitignore: 'restart-required',
218
248
  }
@@ -276,6 +306,7 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
276
306
  'plugins',
277
307
  'channels',
278
308
  'portForward',
309
+ 'network',
279
310
  'dockerfile',
280
311
  'gitignore',
281
312
  ])
@@ -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
+ }