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,209 @@
1
+ import { z } from 'zod'
2
+
3
+ import { definePlugin, type Subagent } from '@/plugin'
4
+
5
+ import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
6
+ import {
7
+ cleanupMessageFile,
8
+ type CommitMessagePayload,
9
+ createCommitMessageSubagent,
10
+ createDiagnoseFailureSubagent,
11
+ type DiagnoseFailurePayload,
12
+ ensureMessageDir,
13
+ messageFilePath,
14
+ readMessageFile,
15
+ } from './subagents'
16
+
17
+ const DEFAULT_IDLE_MS = 30_000
18
+ const MIN_IDLE_MS = 1_000
19
+
20
+ const SUBAGENT_BACKUP_RUNNER = 'backup'
21
+ const SUBAGENT_COMMIT_MESSAGE = 'backup-message'
22
+ const SUBAGENT_DIAGNOSE = 'backup-diagnose'
23
+
24
+ const SELF_INDUCED_SUBAGENT_NAMES = new Set<string>([
25
+ SUBAGENT_BACKUP_RUNNER,
26
+ SUBAGENT_COMMIT_MESSAGE,
27
+ SUBAGENT_DIAGNOSE,
28
+ ])
29
+
30
+ const backupConfigSchema = z
31
+ .object({
32
+ enabled: z.boolean().default(true),
33
+ idleMs: z.number().int().min(MIN_IDLE_MS).default(DEFAULT_IDLE_MS),
34
+ pushToOrigin: z.boolean().default(true),
35
+ commitTimeoutMs: z.number().int().min(1).default(COMMIT_TIMEOUT_MS),
36
+ networkTimeoutMs: z.number().int().min(1).default(NETWORK_TIMEOUT_MS),
37
+ })
38
+ .default({
39
+ enabled: true,
40
+ idleMs: DEFAULT_IDLE_MS,
41
+ pushToOrigin: true,
42
+ commitTimeoutMs: COMMIT_TIMEOUT_MS,
43
+ networkTimeoutMs: NETWORK_TIMEOUT_MS,
44
+ })
45
+
46
+ const runnerPayloadSchema = z.object({
47
+ agentDir: z.string(),
48
+ pushToOrigin: z.boolean(),
49
+ })
50
+
51
+ type RunnerPayload = z.infer<typeof runnerPayloadSchema>
52
+
53
+ export default definePlugin({
54
+ configSchema: backupConfigSchema,
55
+ plugin: async (ctx) => {
56
+ const enabled = ctx.config.enabled
57
+ const idleMs = ctx.config.idleMs
58
+ const pushToOrigin = ctx.config.pushToOrigin
59
+
60
+ const activeTurns = new Set<string>()
61
+ let idleTimer: ReturnType<typeof setTimeout> | null = null
62
+ let pendingFire = false
63
+ let inFlight = false
64
+
65
+ const cancelTimer = (): void => {
66
+ if (idleTimer !== null) {
67
+ clearTimeout(idleTimer)
68
+ idleTimer = null
69
+ }
70
+ }
71
+
72
+ const fireIfQuiet = async (): Promise<void> => {
73
+ if (!enabled) return
74
+ if (inFlight) {
75
+ pendingFire = true
76
+ return
77
+ }
78
+ if (activeTurns.size > 0) return
79
+ inFlight = true
80
+ try {
81
+ await ctx.spawnSubagent(SUBAGENT_BACKUP_RUNNER, {
82
+ agentDir: ctx.agentDir,
83
+ pushToOrigin,
84
+ } satisfies RunnerPayload)
85
+ } catch (err) {
86
+ ctx.logger.error(`backup runner spawn failed: ${err instanceof Error ? err.message : String(err)}`)
87
+ } finally {
88
+ inFlight = false
89
+ if (pendingFire) {
90
+ pendingFire = false
91
+ if (activeTurns.size === 0) {
92
+ queueMicrotask(() => {
93
+ void fireIfQuiet()
94
+ })
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ const isSelfInducedTurn = (origin: { kind: string; subagent?: string } | undefined): boolean => {
101
+ if (origin?.kind !== 'subagent') return false
102
+ const sub = origin.subagent
103
+ return sub !== undefined && SELF_INDUCED_SUBAGENT_NAMES.has(sub)
104
+ }
105
+
106
+ const runnerSubagent: Subagent<RunnerPayload> = {
107
+ systemPrompt: '(backup runner — no LLM)',
108
+ payloadSchema: runnerPayloadSchema,
109
+ inFlightKey: (payload) => payload.agentDir,
110
+ handler: async (sctx) => {
111
+ const result = await runBackupOnce(sctx.payload, ctx)
112
+ const summary = describeResult(result)
113
+ ctx.logger.info(`[backup] ${summary}`)
114
+ },
115
+ }
116
+
117
+ return {
118
+ subagents: {
119
+ [SUBAGENT_BACKUP_RUNNER]: runnerSubagent,
120
+ [SUBAGENT_COMMIT_MESSAGE]: createCommitMessageSubagent(),
121
+ [SUBAGENT_DIAGNOSE]: createDiagnoseFailureSubagent(),
122
+ },
123
+ hooks: {
124
+ 'session.turn.start': (event) => {
125
+ if (isSelfInducedTurn(event.origin)) return
126
+ activeTurns.add(event.sessionId)
127
+ cancelTimer()
128
+ },
129
+ 'session.turn.end': (event) => {
130
+ if (isSelfInducedTurn(event.origin)) return
131
+ activeTurns.delete(event.sessionId)
132
+ },
133
+ 'session.end': (event) => {
134
+ activeTurns.delete(event.sessionId)
135
+ },
136
+ 'session.idle': () => {
137
+ if (!enabled) return
138
+ if (activeTurns.size > 0) return
139
+ cancelTimer()
140
+ idleTimer = setTimeout(() => {
141
+ idleTimer = null
142
+ void fireIfQuiet()
143
+ }, idleMs)
144
+ },
145
+ },
146
+ }
147
+ },
148
+ })
149
+
150
+ async function runBackupOnce(
151
+ payload: RunnerPayload,
152
+ ctx: {
153
+ agentDir: string
154
+ logger: { info: (m: string) => void; warn: (m: string) => void }
155
+ spawnSubagent: (name: string, payload?: unknown) => Promise<void>
156
+ },
157
+ ): Promise<BackupResult> {
158
+ const messagePath = messageFilePath(payload.agentDir)
159
+ await ensureMessageDir(messagePath)
160
+ await cleanupMessageFile(messagePath)
161
+
162
+ const result = await runBackup(
163
+ { cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
164
+ {
165
+ gitSpawn: makeDefaultGitSpawn(),
166
+ pickCommitMessage: async ({ status, diffstat }) => {
167
+ await cleanupMessageFile(messagePath)
168
+ const messagePayload: CommitMessagePayload = {
169
+ agentDir: payload.agentDir,
170
+ status,
171
+ diffstat,
172
+ outputPath: messagePath,
173
+ }
174
+ try {
175
+ await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload)
176
+ } catch (err) {
177
+ ctx.logger.warn(
178
+ `${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
179
+ )
180
+ }
181
+ const written = await readMessageFile(messagePath)
182
+ await cleanupMessageFile(messagePath)
183
+ return written ?? 'chore: backup'
184
+ },
185
+ diagnoseFailure: async (input) => {
186
+ const diagPayload: DiagnoseFailurePayload = {
187
+ agentDir: input.cwd,
188
+ stage: input.stage,
189
+ exitCode: input.exitCode,
190
+ stderr: input.stderr,
191
+ stdout: input.stdout,
192
+ }
193
+ try {
194
+ await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload)
195
+ } catch (err) {
196
+ ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
197
+ }
198
+ },
199
+ },
200
+ )
201
+
202
+ await cleanupMessageFile(messagePath)
203
+ return result
204
+ }
205
+
206
+ function describeResult(r: BackupResult): string {
207
+ if (r.ok) return r.kind
208
+ return `failed (${r.kind}): ${r.reason}`
209
+ }
@@ -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 { stat } from 'node:fs/promises'
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
  })