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,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 {
|
|
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
|
})
|