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.
- package/README.md +12 -12
- package/package.json +1 -1
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/channels/router.ts +29 -0
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +1 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +20 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +23 -8
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +223 -25
- package/src/init/index.ts +18 -10
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +8 -0
- package/src/run/index.ts +10 -5
- package/src/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 +1 -1
- package/typeclaw.schema.json +50 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const COMMIT_TIMEOUT_MS = 30_000
|
|
5
|
+
export const NETWORK_TIMEOUT_MS = 60_000
|
|
6
|
+
|
|
7
|
+
const RUNTIME_OWNED_PREFIXES = ['memory/'] as const
|
|
8
|
+
const FORCE_ADD_PREFIXES = ['sessions/'] as const
|
|
9
|
+
|
|
10
|
+
const NONINTERACTIVE_ENV = {
|
|
11
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
12
|
+
GIT_PAGER: 'cat',
|
|
13
|
+
PAGER: 'cat',
|
|
14
|
+
GCM_INTERACTIVE: 'never',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
export type GitSpawnResult = {
|
|
18
|
+
exitCode: number
|
|
19
|
+
stdout: string
|
|
20
|
+
stderr: string
|
|
21
|
+
timedOut: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type GitSpawn = (args: readonly string[], opts: { cwd: string; timeoutMs: number }) => Promise<GitSpawnResult>
|
|
25
|
+
|
|
26
|
+
export type BackupRunnerDeps = {
|
|
27
|
+
gitSpawn: GitSpawn
|
|
28
|
+
pickCommitMessage: (input: { status: string; diffstat: string }) => Promise<string>
|
|
29
|
+
diagnoseFailure?: (input: BackupFailureInput) => Promise<void>
|
|
30
|
+
now?: () => number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BackupRunnerOptions = {
|
|
34
|
+
cwd: string
|
|
35
|
+
pushToOrigin: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BackupFailureInput = {
|
|
39
|
+
cwd: string
|
|
40
|
+
stage: 'push' | 'rebase'
|
|
41
|
+
exitCode: number
|
|
42
|
+
stderr: string
|
|
43
|
+
stdout: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type BackupResult =
|
|
47
|
+
| { ok: true; kind: 'no-repo' | 'clean' | 'committed' | 'pushed' | 'rebased-and-pushed' }
|
|
48
|
+
| { ok: false; kind: 'commit-failed' | 'push-failed' | 'rebase-failed' | 'aborted'; reason: string }
|
|
49
|
+
|
|
50
|
+
export async function runBackup(options: BackupRunnerOptions, deps: BackupRunnerDeps): Promise<BackupResult> {
|
|
51
|
+
const { cwd, pushToOrigin } = options
|
|
52
|
+
|
|
53
|
+
if (!existsSync(join(cwd, '.git'))) return { ok: true, kind: 'no-repo' }
|
|
54
|
+
|
|
55
|
+
const status = await deps.gitSpawn(['status', '--porcelain=v1', '--untracked-files=all'], {
|
|
56
|
+
cwd,
|
|
57
|
+
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
58
|
+
})
|
|
59
|
+
if (status.exitCode !== 0) return { ok: false, kind: 'aborted', reason: `git status failed: ${shortErr(status)}` }
|
|
60
|
+
const dirty = filterAgentOwned(parsePorcelain(status.stdout))
|
|
61
|
+
const force = filterForceAdd(parsePorcelain(status.stdout))
|
|
62
|
+
if (dirty.length === 0 && force.length === 0) return { ok: true, kind: 'clean' }
|
|
63
|
+
|
|
64
|
+
if (dirty.length > 0) {
|
|
65
|
+
const add = await deps.gitSpawn(['add', '--', ...dirty], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
66
|
+
if (add.exitCode !== 0) return { ok: false, kind: 'commit-failed', reason: `git add failed: ${shortErr(add)}` }
|
|
67
|
+
}
|
|
68
|
+
if (force.length > 0) {
|
|
69
|
+
const present = force.filter((p) => existsSync(join(cwd, p)))
|
|
70
|
+
if (present.length > 0) {
|
|
71
|
+
const addF = await deps.gitSpawn(['add', '-f', '--', ...present], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
72
|
+
if (addF.exitCode !== 0) {
|
|
73
|
+
return { ok: false, kind: 'commit-failed', reason: `git add -f failed: ${shortErr(addF)}` }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stagedCheck = await deps.gitSpawn(['diff', '--cached', '--quiet'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
79
|
+
if (stagedCheck.exitCode === 0) return { ok: true, kind: 'clean' }
|
|
80
|
+
|
|
81
|
+
const diffstat = await deps.gitSpawn(['diff', '--cached', '--stat'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
82
|
+
const message = await deps.pickCommitMessage({
|
|
83
|
+
status: status.stdout.slice(0, 4096),
|
|
84
|
+
diffstat: diffstat.stdout.slice(0, 4096),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const safeMessage = sanitizeCommitMessage(message)
|
|
88
|
+
const commit = await deps.gitSpawn(['commit', '-m', safeMessage], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
89
|
+
if (commit.exitCode !== 0)
|
|
90
|
+
return { ok: false, kind: 'commit-failed', reason: `git commit failed: ${shortErr(commit)}` }
|
|
91
|
+
|
|
92
|
+
if (!pushToOrigin) return { ok: true, kind: 'committed' }
|
|
93
|
+
|
|
94
|
+
const upstream = await deps.gitSpawn(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], {
|
|
95
|
+
cwd,
|
|
96
|
+
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
97
|
+
})
|
|
98
|
+
if (upstream.exitCode !== 0) return { ok: true, kind: 'committed' }
|
|
99
|
+
|
|
100
|
+
const upstreamRef = upstream.stdout.trim()
|
|
101
|
+
if (upstreamRef.length === 0) return { ok: true, kind: 'committed' }
|
|
102
|
+
|
|
103
|
+
const push = await deps.gitSpawn(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
104
|
+
if (push.exitCode === 0) return { ok: true, kind: 'pushed' }
|
|
105
|
+
|
|
106
|
+
if (!isNonFastForward(push)) {
|
|
107
|
+
await maybeDiagnose(deps, { cwd, stage: 'push', exitCode: push.exitCode, stderr: push.stderr, stdout: push.stdout })
|
|
108
|
+
return { ok: false, kind: 'push-failed', reason: shortErr(push) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const fetch = await deps.gitSpawn(['fetch'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
112
|
+
if (fetch.exitCode !== 0) {
|
|
113
|
+
await maybeDiagnose(deps, {
|
|
114
|
+
cwd,
|
|
115
|
+
stage: 'push',
|
|
116
|
+
exitCode: fetch.exitCode,
|
|
117
|
+
stderr: fetch.stderr,
|
|
118
|
+
stdout: fetch.stdout,
|
|
119
|
+
})
|
|
120
|
+
return { ok: false, kind: 'push-failed', reason: `git fetch failed: ${shortErr(fetch)}` }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rebase = await deps.gitSpawn(['rebase', upstreamRef], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
124
|
+
if (rebase.exitCode !== 0) {
|
|
125
|
+
await deps.gitSpawn(['rebase', '--abort'], { cwd, timeoutMs: COMMIT_TIMEOUT_MS })
|
|
126
|
+
await maybeDiagnose(deps, {
|
|
127
|
+
cwd,
|
|
128
|
+
stage: 'rebase',
|
|
129
|
+
exitCode: rebase.exitCode,
|
|
130
|
+
stderr: rebase.stderr,
|
|
131
|
+
stdout: rebase.stdout,
|
|
132
|
+
})
|
|
133
|
+
return { ok: false, kind: 'rebase-failed', reason: `git rebase failed: ${shortErr(rebase)}` }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const push2 = await deps.gitSpawn(['push'], { cwd, timeoutMs: NETWORK_TIMEOUT_MS })
|
|
137
|
+
if (push2.exitCode !== 0) {
|
|
138
|
+
await maybeDiagnose(deps, {
|
|
139
|
+
cwd,
|
|
140
|
+
stage: 'push',
|
|
141
|
+
exitCode: push2.exitCode,
|
|
142
|
+
stderr: push2.stderr,
|
|
143
|
+
stdout: push2.stdout,
|
|
144
|
+
})
|
|
145
|
+
return { ok: false, kind: 'push-failed', reason: `git push (post-rebase) failed: ${shortErr(push2)}` }
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, kind: 'rebased-and-pushed' }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function maybeDiagnose(deps: BackupRunnerDeps, input: BackupFailureInput): Promise<void> {
|
|
151
|
+
if (!deps.diagnoseFailure) return
|
|
152
|
+
try {
|
|
153
|
+
await deps.diagnoseFailure(input)
|
|
154
|
+
} catch {
|
|
155
|
+
// Diagnosis is advisory; never let it mask the original failure.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function shortErr(r: GitSpawnResult): string {
|
|
160
|
+
if (r.timedOut) return `timed out (exit ${r.exitCode})`
|
|
161
|
+
const text = r.stderr.trim() || r.stdout.trim() || `exit ${r.exitCode}`
|
|
162
|
+
return text.length > 400 ? `${text.slice(0, 400)}…` : text
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function isNonFastForward(r: GitSpawnResult): boolean {
|
|
166
|
+
const blob = `${r.stderr}\n${r.stdout}`.toLowerCase()
|
|
167
|
+
return blob.includes('non-fast-forward') || blob.includes('updates were rejected')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function parsePorcelain(stdout: string): string[] {
|
|
171
|
+
const out: string[] = []
|
|
172
|
+
for (const raw of stdout.split('\n')) {
|
|
173
|
+
if (raw.length < 4) continue
|
|
174
|
+
const rest = raw.slice(3)
|
|
175
|
+
const arrowIdx = rest.indexOf(' -> ')
|
|
176
|
+
out.push(arrowIdx === -1 ? rest : rest.slice(arrowIdx + 4))
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function filterAgentOwned(paths: readonly string[]): string[] {
|
|
182
|
+
return paths.filter((p) => !RUNTIME_OWNED_PREFIXES.some((pre) => p.startsWith(pre)))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function filterForceAdd(paths: readonly string[]): string[] {
|
|
186
|
+
return paths.filter((p) => FORCE_ADD_PREFIXES.some((pre) => p.startsWith(pre)))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sanitizeCommitMessage(raw: string): string {
|
|
190
|
+
const trimmed = raw.trim()
|
|
191
|
+
if (trimmed.length === 0) return 'Backup'
|
|
192
|
+
const subject = trimmed.split('\n')[0]?.slice(0, 200) ?? 'Backup'
|
|
193
|
+
const rest = trimmed.split('\n').slice(1).join('\n').trim()
|
|
194
|
+
return rest.length > 0 ? `${subject}\n\n${rest}` : subject
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function makeDefaultGitSpawn(): GitSpawn {
|
|
198
|
+
return async (args, { cwd, timeoutMs }) => {
|
|
199
|
+
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
200
|
+
if (!bun) {
|
|
201
|
+
return { exitCode: 127, stdout: '', stderr: 'Bun runtime not available', timedOut: false }
|
|
202
|
+
}
|
|
203
|
+
const controller = new AbortController()
|
|
204
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
205
|
+
try {
|
|
206
|
+
const proc = bun.spawn({
|
|
207
|
+
cmd: ['git', ...args],
|
|
208
|
+
cwd,
|
|
209
|
+
stdout: 'pipe',
|
|
210
|
+
stderr: 'pipe',
|
|
211
|
+
env: { ...process.env, ...NONINTERACTIVE_ENV },
|
|
212
|
+
signal: controller.signal,
|
|
213
|
+
})
|
|
214
|
+
const exitCode = await proc.exited
|
|
215
|
+
const stdout = await new Response(proc.stdout).text()
|
|
216
|
+
const stderr = await new Response(proc.stderr).text()
|
|
217
|
+
const timedOut = controller.signal.aborted
|
|
218
|
+
return { exitCode, stdout, stderr, timedOut }
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
221
|
+
return {
|
|
222
|
+
exitCode: 1,
|
|
223
|
+
stdout: '',
|
|
224
|
+
stderr: message,
|
|
225
|
+
timedOut: controller.signal.aborted,
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
import { bashTool, readTool, type Subagent, writeTool } from '@/plugin'
|
|
7
|
+
|
|
8
|
+
const messagePayloadSchema = z.object({
|
|
9
|
+
agentDir: z.string(),
|
|
10
|
+
status: z.string(),
|
|
11
|
+
diffstat: z.string(),
|
|
12
|
+
outputPath: z.string(),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export type CommitMessagePayload = z.infer<typeof messagePayloadSchema>
|
|
16
|
+
|
|
17
|
+
const diagnosePayloadSchema = z.object({
|
|
18
|
+
agentDir: z.string(),
|
|
19
|
+
stage: z.enum(['push', 'rebase']),
|
|
20
|
+
exitCode: z.number(),
|
|
21
|
+
stderr: z.string(),
|
|
22
|
+
stdout: z.string(),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export type DiagnoseFailurePayload = z.infer<typeof diagnosePayloadSchema>
|
|
26
|
+
|
|
27
|
+
export const COMMIT_MESSAGE_SYSTEM_PROMPT = `You are typeclaw's backup commit-message subagent.
|
|
28
|
+
|
|
29
|
+
A periodic backup is about to commit dirty files in the agent folder. Your only job is to produce a clear, conventional commit message describing those changes.
|
|
30
|
+
|
|
31
|
+
# Input
|
|
32
|
+
|
|
33
|
+
The user message gives you:
|
|
34
|
+
- The output of \`git status --porcelain=v1 --untracked-files=all\` (truncated)
|
|
35
|
+
- The output of \`git diff --cached --stat\` (truncated)
|
|
36
|
+
- An absolute file path you must write the commit message to
|
|
37
|
+
|
|
38
|
+
# What to write
|
|
39
|
+
|
|
40
|
+
A single commit message in conventional-commit-ish style:
|
|
41
|
+
- Subject line under 72 characters, imperative mood, lowercase first word, no trailing period.
|
|
42
|
+
- Pick a sensible prefix when one fits: \`docs:\`, \`test:\`, \`refactor:\`, \`chore:\`, \`fix:\`, \`feat:\`. Default to \`chore: backup\` if nothing fits.
|
|
43
|
+
- Optionally a blank line and a short body (1-3 lines) summarizing what changed and why if the diff makes the why obvious.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
- \`docs: update setup instructions\`
|
|
47
|
+
- \`feat: add user search endpoint\`
|
|
48
|
+
- \`chore: backup workspace state\`
|
|
49
|
+
|
|
50
|
+
# Hard rules
|
|
51
|
+
|
|
52
|
+
1. **Write exactly one file.** Use the \`write\` tool with the absolute path the user gave you. Do not write anywhere else. Do not read other files. Do not run commands.
|
|
53
|
+
2. **Output is the file contents only.** No prose to the user, no explanation, no apologies. The runner ignores everything except the file you wrote.
|
|
54
|
+
3. **Be honest about uncertainty.** If the diff looks like a mix of unrelated changes, write \`chore: backup\` rather than guessing a misleading subject.
|
|
55
|
+
4. **Never include secrets, API keys, or paths that would identify the user's machine.** Stick to repo-relative descriptions.
|
|
56
|
+
5. **Stop when the file is written.** Do not continue after the \`write\` succeeds.`
|
|
57
|
+
|
|
58
|
+
export const DIAGNOSE_FAILURE_SYSTEM_PROMPT = `You are typeclaw's backup failure-diagnosis subagent.
|
|
59
|
+
|
|
60
|
+
The deterministic backup runner just hit a git failure (push or rebase). The runner has already aborted any half-done state. Your job is to look at the git repo, figure out what went wrong, and either FIX IT or write a clear human-readable explanation.
|
|
61
|
+
|
|
62
|
+
# Input
|
|
63
|
+
|
|
64
|
+
The user message gives you:
|
|
65
|
+
- The agent folder absolute path
|
|
66
|
+
- The stage that failed (\`push\` or \`rebase\`)
|
|
67
|
+
- The git exit code, stderr, and stdout
|
|
68
|
+
|
|
69
|
+
# Tools
|
|
70
|
+
|
|
71
|
+
You have \`bash\`, \`read\`, and \`write\`. Use \`bash\` to inspect git state (\`git status\`, \`git remote -v\`, \`git log -5 --oneline\`, \`git config --get remote.origin.url\`).
|
|
72
|
+
|
|
73
|
+
# Allowed actions
|
|
74
|
+
|
|
75
|
+
You MAY:
|
|
76
|
+
- Inspect git state (read-only commands).
|
|
77
|
+
- Set up a missing upstream branch via \`git push -u origin <branch>\` if it's clear that's the only issue.
|
|
78
|
+
- Retry \`git push\` once after fixing a clear, narrow issue.
|
|
79
|
+
|
|
80
|
+
You MUST NOT:
|
|
81
|
+
- Force-push (\`--force\`, \`--force-with-lease\`).
|
|
82
|
+
- Resolve merge conflicts by editing files. If a rebase had conflicts, the runner already aborted it. Leave the repo as-is and explain.
|
|
83
|
+
- Mutate \`.git/config\` for credentials, signing keys, or remote URLs.
|
|
84
|
+
- Touch any file outside \`.git/\` housekeeping.
|
|
85
|
+
|
|
86
|
+
# Output
|
|
87
|
+
|
|
88
|
+
Write a brief diagnosis (3-8 lines) describing:
|
|
89
|
+
1. What the actual cause was (e.g. "no upstream tracking branch", "remote is ahead and rebase conflicted", "auth failed").
|
|
90
|
+
2. What you did about it (or why you didn't).
|
|
91
|
+
3. What the user should do next, if anything.
|
|
92
|
+
|
|
93
|
+
Append your diagnosis to \`<agentDir>/sessions/backup-diagnostics.log\` with a timestamp prefix. Keep it short — this log is for the human, not the model.
|
|
94
|
+
|
|
95
|
+
# When in doubt
|
|
96
|
+
|
|
97
|
+
Do nothing destructive. Write the diagnosis and stop. The user can recover manually.`
|
|
98
|
+
|
|
99
|
+
export type CreateCommitMessageSubagentOptions = {
|
|
100
|
+
fallbackMessage?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createCommitMessageSubagent(
|
|
104
|
+
options: CreateCommitMessageSubagentOptions = {},
|
|
105
|
+
): Subagent<CommitMessagePayload> {
|
|
106
|
+
const fallback = options.fallbackMessage ?? 'chore: backup'
|
|
107
|
+
return {
|
|
108
|
+
systemPrompt: COMMIT_MESSAGE_SYSTEM_PROMPT,
|
|
109
|
+
tools: [writeTool],
|
|
110
|
+
payloadSchema: messagePayloadSchema,
|
|
111
|
+
inFlightKey: (payload) => payload.agentDir,
|
|
112
|
+
handler: async (ctx, runSession) => {
|
|
113
|
+
const userPrompt = buildCommitMessagePrompt(ctx.payload)
|
|
114
|
+
try {
|
|
115
|
+
await runSession({ userPrompt })
|
|
116
|
+
} catch {
|
|
117
|
+
await writeFile(ctx.payload.outputPath, fallback, 'utf8').catch(() => undefined)
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function createDiagnoseFailureSubagent(): Subagent<DiagnoseFailurePayload> {
|
|
124
|
+
return {
|
|
125
|
+
systemPrompt: DIAGNOSE_FAILURE_SYSTEM_PROMPT,
|
|
126
|
+
tools: [bashTool, readTool, writeTool],
|
|
127
|
+
payloadSchema: diagnosePayloadSchema,
|
|
128
|
+
inFlightKey: (payload) => payload.agentDir,
|
|
129
|
+
handler: async (ctx, runSession) => {
|
|
130
|
+
const userPrompt = buildDiagnosePrompt(ctx.payload)
|
|
131
|
+
try {
|
|
132
|
+
await runSession({ userPrompt })
|
|
133
|
+
} catch {
|
|
134
|
+
// Diagnosis is advisory; failures here must not propagate.
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildCommitMessagePrompt(p: CommitMessagePayload): string {
|
|
141
|
+
return [
|
|
142
|
+
`Agent folder: ${p.agentDir}`,
|
|
143
|
+
`Write the commit message to: ${p.outputPath}`,
|
|
144
|
+
'',
|
|
145
|
+
'## git status --porcelain=v1 --untracked-files=all',
|
|
146
|
+
'```',
|
|
147
|
+
p.status.trim() || '(empty)',
|
|
148
|
+
'```',
|
|
149
|
+
'',
|
|
150
|
+
'## git diff --cached --stat',
|
|
151
|
+
'```',
|
|
152
|
+
p.diffstat.trim() || '(empty)',
|
|
153
|
+
'```',
|
|
154
|
+
'',
|
|
155
|
+
'Write the commit message and stop.',
|
|
156
|
+
].join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildDiagnosePrompt(p: DiagnoseFailurePayload): string {
|
|
160
|
+
return [
|
|
161
|
+
`Agent folder: ${p.agentDir}`,
|
|
162
|
+
`Failed stage: ${p.stage}`,
|
|
163
|
+
`Exit code: ${p.exitCode}`,
|
|
164
|
+
'',
|
|
165
|
+
'## stderr',
|
|
166
|
+
'```',
|
|
167
|
+
p.stderr.trim() || '(empty)',
|
|
168
|
+
'```',
|
|
169
|
+
'',
|
|
170
|
+
'## stdout',
|
|
171
|
+
'```',
|
|
172
|
+
p.stdout.trim() || '(empty)',
|
|
173
|
+
'```',
|
|
174
|
+
'',
|
|
175
|
+
'Inspect the repo, do the smallest safe action if any, and write your diagnosis to the log file.',
|
|
176
|
+
].join('\n')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function readMessageFile(path: string): Promise<string | null> {
|
|
180
|
+
try {
|
|
181
|
+
const raw = await readFile(path, 'utf8')
|
|
182
|
+
return raw.trim().length > 0 ? raw : null
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function ensureMessageDir(outputPath: string): Promise<void> {
|
|
189
|
+
const dir = outputPath.slice(0, outputPath.lastIndexOf('/'))
|
|
190
|
+
if (dir.length === 0) return
|
|
191
|
+
await mkdir(dir, { recursive: true }).catch(() => undefined)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function cleanupMessageFile(path: string): Promise<void> {
|
|
195
|
+
await rm(path, { force: true }).catch(() => undefined)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function messageFilePath(agentDir: string): string {
|
|
199
|
+
return join(agentDir, '.typeclaw', 'backup-message.tmp')
|
|
200
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { access, constants as fsConstants, mkdir, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
2
4
|
|
|
3
5
|
import { CronExpressionParser } from 'cron-parser'
|
|
4
6
|
import { z } from 'zod'
|
|
@@ -212,6 +214,45 @@ export default definePlugin({
|
|
|
212
214
|
bytesAtLastRun.delete(event.sessionId)
|
|
213
215
|
},
|
|
214
216
|
},
|
|
217
|
+
doctorChecks: {
|
|
218
|
+
'dir-writable': {
|
|
219
|
+
description: 'memory/ exists and is writable',
|
|
220
|
+
run: async (dctx) => {
|
|
221
|
+
const dir = join(dctx.agentDir, 'memory')
|
|
222
|
+
try {
|
|
223
|
+
await access(dir, fsConstants.W_OK)
|
|
224
|
+
return { status: 'ok', message: `${dir} writable` }
|
|
225
|
+
} catch {
|
|
226
|
+
return {
|
|
227
|
+
status: 'error',
|
|
228
|
+
message: `${dir} is missing or not writable`,
|
|
229
|
+
fix: { description: 'Create memory/ in the agent folder or fix its permissions on the host.' },
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
'daily-stream-current': {
|
|
235
|
+
description: "today's daily stream file exists",
|
|
236
|
+
run: async (dctx) => {
|
|
237
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
238
|
+
const rel = `memory/${today}.md`
|
|
239
|
+
const abs = join(dctx.agentDir, rel)
|
|
240
|
+
if (existsSync(abs)) return { status: 'ok', message: `${rel} present` }
|
|
241
|
+
return {
|
|
242
|
+
status: 'warning',
|
|
243
|
+
message: `${rel} missing`,
|
|
244
|
+
fix: {
|
|
245
|
+
description: `Create empty ${rel} so memory-logger has a target.`,
|
|
246
|
+
apply: async () => {
|
|
247
|
+
await mkdir(dirname(abs), { recursive: true })
|
|
248
|
+
await writeFile(abs, '', 'utf8')
|
|
249
|
+
return { summary: `created ${rel}`, changedPaths: [rel] }
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
215
256
|
}
|
|
216
257
|
},
|
|
217
258
|
})
|
package/src/channels/router.ts
CHANGED
|
@@ -776,6 +776,32 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
776
776
|
}
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
+
const fireSessionTurnStart = async (live: LiveSession): Promise<void> => {
|
|
780
|
+
if (!live.hooks) return
|
|
781
|
+
try {
|
|
782
|
+
await live.hooks.runSessionTurnStart({
|
|
783
|
+
sessionId: live.sessionId,
|
|
784
|
+
agentDir: options.agentDir,
|
|
785
|
+
origin: buildLiveOrigin(live),
|
|
786
|
+
})
|
|
787
|
+
} catch (err) {
|
|
788
|
+
logger.warn(`[channels] session.turn.start hook threw for ${live.keyId}: ${describe(err)}`)
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const fireSessionTurnEnd = async (live: LiveSession): Promise<void> => {
|
|
793
|
+
if (!live.hooks) return
|
|
794
|
+
try {
|
|
795
|
+
await live.hooks.runSessionTurnEnd({
|
|
796
|
+
sessionId: live.sessionId,
|
|
797
|
+
agentDir: options.agentDir,
|
|
798
|
+
origin: buildLiveOrigin(live),
|
|
799
|
+
})
|
|
800
|
+
} catch (err) {
|
|
801
|
+
logger.warn(`[channels] session.turn.end hook threw for ${live.keyId}: ${describe(err)}`)
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
779
805
|
const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
|
|
780
806
|
const membership = readMembership(live.key)
|
|
781
807
|
return {
|
|
@@ -848,6 +874,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
848
874
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
849
875
|
const promptStart = now()
|
|
850
876
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
877
|
+
await fireSessionTurnStart(live)
|
|
851
878
|
try {
|
|
852
879
|
await live.session.prompt(text)
|
|
853
880
|
await validateChannelTurn(live, successfulSendsBeforePrompt)
|
|
@@ -856,6 +883,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
856
883
|
} catch (err) {
|
|
857
884
|
logger.warn(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
|
|
858
885
|
live.consecutiveSends.clear()
|
|
886
|
+
} finally {
|
|
887
|
+
await fireSessionTurnEnd(live)
|
|
859
888
|
}
|
|
860
889
|
await fireSessionIdle(live)
|
|
861
890
|
live.lastTurnAuthorIds = new Set(live.currentTurnAuthorIds)
|
package/src/cli/compose.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
composeDoctor,
|
|
5
|
+
composeLogs,
|
|
6
|
+
composeRestart,
|
|
7
|
+
composeStart,
|
|
8
|
+
composeStatus,
|
|
9
|
+
composeStop,
|
|
10
|
+
type AgentResult,
|
|
11
|
+
type ComposeDoctorReport,
|
|
12
|
+
} from '@/compose'
|
|
4
13
|
import { config } from '@/config'
|
|
14
|
+
import { formatJson, formatReport } from '@/doctor'
|
|
5
15
|
|
|
6
16
|
import { formatComposeStatus } from './compose-status'
|
|
7
17
|
import { c, spinner } from './ui'
|
|
@@ -156,6 +166,41 @@ const logsSub = defineCommand({
|
|
|
156
166
|
},
|
|
157
167
|
})
|
|
158
168
|
|
|
169
|
+
const doctorSub = defineCommand({
|
|
170
|
+
meta: { name: 'doctor', description: 'diagnose every agent in immediate subdirectories of cwd' },
|
|
171
|
+
args: {
|
|
172
|
+
verbose: { type: 'boolean', alias: 'v', default: false, description: 'show check details' },
|
|
173
|
+
json: { type: 'boolean', default: false, description: 'emit the report as JSON' },
|
|
174
|
+
fix: {
|
|
175
|
+
type: 'boolean',
|
|
176
|
+
default: false,
|
|
177
|
+
description: 'attempt auto-fixes per agent and commit changes in each agent folder',
|
|
178
|
+
},
|
|
179
|
+
only: { type: 'string', description: 'comma-separated category filter' },
|
|
180
|
+
shallow: {
|
|
181
|
+
type: 'boolean',
|
|
182
|
+
default: false,
|
|
183
|
+
description: 'run cross-agent checks only; skip per-agent doctor runs',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
async run({ args }) {
|
|
187
|
+
const only = args.only
|
|
188
|
+
? args.only
|
|
189
|
+
.split(',')
|
|
190
|
+
.map((s) => s.trim())
|
|
191
|
+
.filter((s) => s.length > 0)
|
|
192
|
+
: undefined
|
|
193
|
+
const report = await composeDoctor({
|
|
194
|
+
rootCwd: process.cwd(),
|
|
195
|
+
fix: args.fix,
|
|
196
|
+
shallow: args.shallow,
|
|
197
|
+
...(only !== undefined ? { only } : {}),
|
|
198
|
+
})
|
|
199
|
+
emitComposeDoctor(report, { verbose: args.verbose, json: args.json })
|
|
200
|
+
if (!report.ok) process.exit(1)
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
|
|
159
204
|
export const composeCommand = defineCommand({
|
|
160
205
|
meta: {
|
|
161
206
|
name: 'compose',
|
|
@@ -167,6 +212,7 @@ export const composeCommand = defineCommand({
|
|
|
167
212
|
restart: restartSub,
|
|
168
213
|
status: statusSub,
|
|
169
214
|
logs: logsSub,
|
|
215
|
+
doctor: doctorSub,
|
|
170
216
|
},
|
|
171
217
|
})
|
|
172
218
|
|
|
@@ -238,3 +284,48 @@ function formatRestartDone<T extends { start: { hostPort: number } }>(result: Ag
|
|
|
238
284
|
if (!result.ok) return `${c.red('✖')} ${c.red('failed:')} ${result.reason}`
|
|
239
285
|
return `${c.green('✔')} restarted on host port ${c.cyan(String(result.data.start.hostPort))}`
|
|
240
286
|
}
|
|
287
|
+
|
|
288
|
+
function emitComposeDoctor(report: ComposeDoctorReport, opts: { verbose: boolean; json: boolean }): void {
|
|
289
|
+
if (opts.json) {
|
|
290
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
const useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === undefined
|
|
294
|
+
const sectionHead = useColor ? c.bold : (s: string) => s
|
|
295
|
+
|
|
296
|
+
process.stdout.write(`${sectionHead('compose doctor')} ${c.dim(report.rootCwd)}\n\n`)
|
|
297
|
+
|
|
298
|
+
process.stdout.write(`${sectionHead('Cross-agent checks')}\n`)
|
|
299
|
+
for (const check of report.crossChecks) {
|
|
300
|
+
const marker = checkMarker(check.status)
|
|
301
|
+
process.stdout.write(` ${marker} ${check.message} ${c.dim(`(${check.name})`)}\n`)
|
|
302
|
+
if (opts.verbose && check.details !== undefined) {
|
|
303
|
+
for (const d of check.details) process.stdout.write(` ${c.dim(`• ${d}`)}\n`)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
process.stdout.write('\n')
|
|
307
|
+
|
|
308
|
+
for (const agent of report.agents) {
|
|
309
|
+
process.stdout.write(`${sectionHead(`Agent: ${agent.entry.name}`)} ${c.dim(agent.entry.cwd)}\n`)
|
|
310
|
+
process.stdout.write(
|
|
311
|
+
`${opts.json ? formatJson(agent.result.final ?? agent.result.initial) : formatReport(agent.result.initial, { useColor, verbose: opts.verbose })}\n\n`,
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
process.stdout.write(
|
|
316
|
+
`${report.ok ? c.green('●') : c.red('●')} compose doctor ${report.ok ? 'passed' : 'found issues'}\n`,
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function checkMarker(status: 'ok' | 'warning' | 'error' | 'info'): string {
|
|
321
|
+
switch (status) {
|
|
322
|
+
case 'ok':
|
|
323
|
+
return c.green('✓')
|
|
324
|
+
case 'warning':
|
|
325
|
+
return c.yellow('!')
|
|
326
|
+
case 'error':
|
|
327
|
+
return c.red('✗')
|
|
328
|
+
case 'info':
|
|
329
|
+
return c.cyan('i')
|
|
330
|
+
}
|
|
331
|
+
}
|