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.
- package/README.md +16 -12
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- 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/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -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 +31 -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 +113 -9
- 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/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- 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 +15 -0
- package/src/run/index.ts +19 -5
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- 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-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- 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
|
|
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
|
+
}
|
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
|
-
}
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
+
}
|