typeclaw 0.1.0 → 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 +3 -2
- package/src/agent/auth.ts +10 -4
- 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/bundled-plugins/security/index.ts +5 -1
- package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
- package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
- package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
- package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
- package/src/channels/adapters/kakaotalk.ts +58 -3
- package/src/channels/router.ts +40 -2
- 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/ensure-deps.ts +2 -2
- package/src/init/index.ts +23 -13
- package/src/init/run-bun-install.ts +17 -1
- 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/secrets/env.ts +43 -0
- package/src/secrets/index.ts +2 -0
- 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-channel-kakaotalk/SKILL.md +26 -3
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/tsconfig.json +30 -0
- package/typeclaw.schema.json +50 -4
package/README.md
CHANGED
|
@@ -68,18 +68,18 @@ That's it. The agent is now alive, listening on a websocket, ready to receive pr
|
|
|
68
68
|
|
|
69
69
|
## CLI
|
|
70
70
|
|
|
71
|
-
| Command | Purpose
|
|
72
|
-
| ------------------ |
|
|
73
|
-
| `typeclaw init` | Scaffold a new agent folder
|
|
74
|
-
| `typeclaw start` | Build and run the container
|
|
75
|
-
| `typeclaw stop` | Stop the container
|
|
76
|
-
| `typeclaw restart` | `stop` then `start`
|
|
77
|
-
| `typeclaw status` | Show container + daemon registration state
|
|
78
|
-
| `typeclaw logs` |
|
|
79
|
-
| `typeclaw tui` | Attach a terminal UI over the agent's websocket
|
|
80
|
-
| `typeclaw shell` | Open a shell inside the running container
|
|
81
|
-
| `typeclaw reload` | Push a live config reload to the running agent
|
|
82
|
-
| `typeclaw compose` | Orchestrate multiple agents
|
|
71
|
+
| Command | Purpose |
|
|
72
|
+
| ------------------ | -------------------------------------------------------------------- |
|
|
73
|
+
| `typeclaw init` | Scaffold a new agent folder |
|
|
74
|
+
| `typeclaw start` | Build and run the container |
|
|
75
|
+
| `typeclaw stop` | Stop the container |
|
|
76
|
+
| `typeclaw restart` | `stop` then `start` |
|
|
77
|
+
| `typeclaw status` | Show container + daemon registration state |
|
|
78
|
+
| `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
|
|
79
|
+
| `typeclaw tui` | Attach a terminal UI over the agent's websocket |
|
|
80
|
+
| `typeclaw shell` | Open a shell inside the running container |
|
|
81
|
+
| `typeclaw reload` | Push a live config reload to the running agent |
|
|
82
|
+
| `typeclaw compose` | Orchestrate multiple agents |
|
|
83
83
|
|
|
84
84
|
## Configuration
|
|
85
85
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"homepage": "https://github.com/typeclaw/typeclaw#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/typeclaw/typeclaw/issues"
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
18
|
"scripts",
|
|
19
|
+
"tsconfig.json",
|
|
19
20
|
"typeclaw.schema.json",
|
|
20
21
|
"cron.schema.json",
|
|
21
22
|
"secrets.schema.json",
|
|
@@ -44,7 +45,7 @@
|
|
|
44
45
|
"@mariozechner/pi-coding-agent": "^0.67.3",
|
|
45
46
|
"@mariozechner/pi-tui": "^0.67.3",
|
|
46
47
|
"@mozilla/readability": "^0.6.0",
|
|
47
|
-
"agent-messenger": "2.
|
|
48
|
+
"agent-messenger": "2.15.0",
|
|
48
49
|
"cheerio": "^1.2.0",
|
|
49
50
|
"citty": "^0.2.2",
|
|
50
51
|
"cron-parser": "^5.5.0",
|
package/src/agent/auth.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
supportsOAuth,
|
|
11
11
|
type KnownProviderId,
|
|
12
12
|
} from '@/config/providers'
|
|
13
|
-
import { createSecretsStoreForAgent } from '@/secrets'
|
|
13
|
+
import { createSecretsStoreForAgent, stripEnvKey } from '@/secrets'
|
|
14
14
|
|
|
15
15
|
type Auth = {
|
|
16
16
|
authStorage: AuthStorage
|
|
@@ -70,9 +70,15 @@ export function getAuth(): Auth {
|
|
|
70
70
|
const envKey = process.env[provider.apiKeyEnv]
|
|
71
71
|
if (envKey) {
|
|
72
72
|
const existing = authStorage.get(provider.id)
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
75
|
-
|
|
73
|
+
const apiKeyOwned = existing === undefined || existing.type === 'api_key'
|
|
74
|
+
if (apiKeyOwned) {
|
|
75
|
+
if (existing === undefined || existing.key !== envKey) {
|
|
76
|
+
authStorage.set(provider.id, { type: 'api_key', key: envKey })
|
|
77
|
+
}
|
|
78
|
+
// secrets.json is now authoritative for this provider's api-key credential.
|
|
79
|
+
// Strip the value from `.env` so the next boot does not silently revive a
|
|
80
|
+
// stale or rotated-away key, and so users have a single place to edit.
|
|
81
|
+
stripEnvKey(join(process.cwd(), '.env'), provider.apiKeyEnv)
|
|
76
82
|
}
|
|
77
83
|
}
|
|
78
84
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { isAbsolute, normalize } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
PluginCheckResult,
|
|
5
|
+
PluginCheckStatus,
|
|
6
|
+
PluginDoctorContext,
|
|
7
|
+
PluginFixResult,
|
|
8
|
+
PluginRegistry,
|
|
9
|
+
RegisteredDoctorCheck,
|
|
10
|
+
} from '@/plugin'
|
|
11
|
+
|
|
12
|
+
export type PluginCheckRecord = {
|
|
13
|
+
id: string
|
|
14
|
+
pluginName: string
|
|
15
|
+
checkName: string
|
|
16
|
+
description: string
|
|
17
|
+
category: string
|
|
18
|
+
status: PluginCheckStatus
|
|
19
|
+
message: string
|
|
20
|
+
details?: string[]
|
|
21
|
+
fix?: { description: string; hasApply: boolean }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PluginFixOutcome = { ok: true; summary: string; changedPaths: string[] } | { ok: false; error: string }
|
|
25
|
+
|
|
26
|
+
export type RunPluginDoctorOptions = {
|
|
27
|
+
registry: PluginRegistry
|
|
28
|
+
agentDir: string
|
|
29
|
+
checkTimeoutMs?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RunPluginDoctorFixOptions = RunPluginDoctorOptions & {
|
|
33
|
+
checkId: string
|
|
34
|
+
fixTimeoutMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 5_000
|
|
38
|
+
const DEFAULT_FIX_TIMEOUT_MS = 30_000
|
|
39
|
+
|
|
40
|
+
export function checkId(pluginName: string, checkName: string): string {
|
|
41
|
+
return `${pluginName}.${checkName}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runPluginDoctorChecks(opts: RunPluginDoctorOptions): Promise<PluginCheckRecord[]> {
|
|
45
|
+
const timeoutMs = opts.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS
|
|
46
|
+
const records: PluginCheckRecord[] = []
|
|
47
|
+
for (const entry of opts.registry.doctorChecks) {
|
|
48
|
+
records.push(await runOneCheck(entry, opts.agentDir, timeoutMs))
|
|
49
|
+
}
|
|
50
|
+
return records
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runPluginDoctorFix(opts: RunPluginDoctorFixOptions): Promise<PluginFixOutcome> {
|
|
54
|
+
const entry = opts.registry.doctorChecks.find((c) => checkId(c.pluginName, c.checkName) === opts.checkId)
|
|
55
|
+
if (!entry) return { ok: false, error: `doctor check ${opts.checkId} is not registered` }
|
|
56
|
+
|
|
57
|
+
const ctx = buildPluginCtx(entry, opts.agentDir)
|
|
58
|
+
let result: PluginCheckResult
|
|
59
|
+
try {
|
|
60
|
+
result = await raceWithTimeout(entry.check.run(ctx), opts.checkTimeoutMs ?? DEFAULT_CHECK_TIMEOUT_MS, 'check')
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, error: messageOf(err) }
|
|
63
|
+
}
|
|
64
|
+
const apply = result.fix?.apply
|
|
65
|
+
if (!apply) return { ok: false, error: `${opts.checkId}: no auto-fix available` }
|
|
66
|
+
|
|
67
|
+
let fix: PluginFixResult
|
|
68
|
+
try {
|
|
69
|
+
fix = await raceWithTimeout(apply(ctx), opts.fixTimeoutMs ?? DEFAULT_FIX_TIMEOUT_MS, 'fix')
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { ok: false, error: messageOf(err) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sanitized = sanitizeChangedPaths(fix.changedPaths)
|
|
75
|
+
if (sanitized.rejected.length > 0) {
|
|
76
|
+
entry.logger.warn(
|
|
77
|
+
`${opts.checkId}: dropped ${sanitized.rejected.length} invalid changedPaths (${sanitized.rejected.join(', ')})`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, summary: fix.summary, changedPaths: sanitized.accepted }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function runOneCheck(
|
|
84
|
+
entry: RegisteredDoctorCheck,
|
|
85
|
+
agentDir: string,
|
|
86
|
+
timeoutMs: number,
|
|
87
|
+
): Promise<PluginCheckRecord> {
|
|
88
|
+
const id = checkId(entry.pluginName, entry.checkName)
|
|
89
|
+
const ctx = buildPluginCtx(entry, agentDir)
|
|
90
|
+
try {
|
|
91
|
+
const result = await raceWithTimeout(entry.check.run(ctx), timeoutMs, 'check')
|
|
92
|
+
return buildRecord(entry, id, result)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
pluginName: entry.pluginName,
|
|
97
|
+
checkName: entry.checkName,
|
|
98
|
+
description: entry.check.description,
|
|
99
|
+
category: entry.check.category ?? `plugin:${entry.pluginName}`,
|
|
100
|
+
status: 'error',
|
|
101
|
+
message: messageOf(err),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildRecord(entry: RegisteredDoctorCheck, id: string, result: PluginCheckResult): PluginCheckRecord {
|
|
107
|
+
const record: PluginCheckRecord = {
|
|
108
|
+
id,
|
|
109
|
+
pluginName: entry.pluginName,
|
|
110
|
+
checkName: entry.checkName,
|
|
111
|
+
description: entry.check.description,
|
|
112
|
+
category: entry.check.category ?? `plugin:${entry.pluginName}`,
|
|
113
|
+
status: result.status,
|
|
114
|
+
message: result.message,
|
|
115
|
+
}
|
|
116
|
+
if (result.details !== undefined && result.details.length > 0) record.details = result.details
|
|
117
|
+
if (result.fix !== undefined) {
|
|
118
|
+
record.fix = { description: result.fix.description, hasApply: result.fix.apply !== undefined }
|
|
119
|
+
}
|
|
120
|
+
return record
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildPluginCtx(entry: RegisteredDoctorCheck, agentDir: string): PluginDoctorContext {
|
|
124
|
+
return Object.freeze({
|
|
125
|
+
pluginName: entry.pluginName,
|
|
126
|
+
agentDir,
|
|
127
|
+
config: entry.pluginConfig,
|
|
128
|
+
logger: entry.logger,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: 'check' | 'fix'): Promise<T> {
|
|
133
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
134
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
135
|
+
timer = setTimeout(() => reject(new Error(`plugin doctor ${label} timed out after ${ms}ms`)), ms)
|
|
136
|
+
})
|
|
137
|
+
try {
|
|
138
|
+
return await Promise.race([work, timeout])
|
|
139
|
+
} finally {
|
|
140
|
+
if (timer !== null) clearTimeout(timer)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function messageOf(err: unknown): string {
|
|
145
|
+
return err instanceof Error ? err.message : String(err)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type PathSanitization = { accepted: string[]; rejected: string[] }
|
|
149
|
+
|
|
150
|
+
// Plugin fixes declare paths relative to agentDir; the host re-validates on
|
|
151
|
+
// receipt for defense in depth, but rejecting here first keeps the wire
|
|
152
|
+
// payload small and the failure attribution accurate.
|
|
153
|
+
export function sanitizeChangedPaths(paths: readonly string[]): PathSanitization {
|
|
154
|
+
const accepted: string[] = []
|
|
155
|
+
const rejected: string[] = []
|
|
156
|
+
for (const raw of paths) {
|
|
157
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
158
|
+
rejected.push(String(raw))
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (isAbsolute(raw) || raw.includes('\\')) {
|
|
162
|
+
rejected.push(raw)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const normalized = normalize(raw)
|
|
166
|
+
if (normalized.startsWith('..') || normalized.split('/').includes('..')) {
|
|
167
|
+
rejected.push(raw)
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
accepted.push(normalized)
|
|
171
|
+
}
|
|
172
|
+
return { accepted, rejected }
|
|
173
|
+
}
|
package/src/agent/subagents.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { HookBus } from '@/plugin'
|
|
|
5
5
|
import type { Stream, Unsubscribe } from '@/stream'
|
|
6
6
|
|
|
7
7
|
import { type AgentSession, createSession } from './index'
|
|
8
|
+
import type { SessionOrigin } from './session-origin'
|
|
8
9
|
|
|
9
10
|
type AgentSessionTools = NonNullable<Parameters<typeof createSession>[0]>['tools']
|
|
10
11
|
|
|
@@ -54,6 +55,8 @@ export type CreateSessionForSubagentResult = {
|
|
|
54
55
|
dispose?: () => Promise<void>
|
|
55
56
|
hooks?: HookBus
|
|
56
57
|
sessionId?: string
|
|
58
|
+
agentDir?: string
|
|
59
|
+
origin?: SessionOrigin
|
|
57
60
|
getTranscriptPath?: () => string | undefined
|
|
58
61
|
}
|
|
59
62
|
export type CreateSessionForSubagentOptions = {
|
|
@@ -82,6 +85,8 @@ type NormalizedSubagentSession = {
|
|
|
82
85
|
dispose: () => Promise<void>
|
|
83
86
|
hooks: HookBus | undefined
|
|
84
87
|
sessionId: string | undefined
|
|
88
|
+
agentDir: string | undefined
|
|
89
|
+
origin: SessionOrigin | undefined
|
|
85
90
|
getTranscriptPath: (() => string | undefined) | undefined
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -92,6 +97,8 @@ function normalizeSubagentSession(result: AgentSession | CreateSessionForSubagen
|
|
|
92
97
|
dispose: result.dispose ?? (async () => {}),
|
|
93
98
|
hooks: result.hooks,
|
|
94
99
|
sessionId: result.sessionId,
|
|
100
|
+
agentDir: result.agentDir,
|
|
101
|
+
origin: result.origin,
|
|
95
102
|
getTranscriptPath: result.getTranscriptPath,
|
|
96
103
|
}
|
|
97
104
|
}
|
|
@@ -100,6 +107,8 @@ function normalizeSubagentSession(result: AgentSession | CreateSessionForSubagen
|
|
|
100
107
|
dispose: async () => {},
|
|
101
108
|
hooks: undefined,
|
|
102
109
|
sessionId: undefined,
|
|
110
|
+
agentDir: undefined,
|
|
111
|
+
origin: undefined,
|
|
103
112
|
getTranscriptPath: undefined,
|
|
104
113
|
}
|
|
105
114
|
}
|
|
@@ -125,11 +134,24 @@ export async function invokeSubagent(name: string, options: InvokeSubagentOption
|
|
|
125
134
|
}
|
|
126
135
|
|
|
127
136
|
const runSession: RunSession = async (override) => {
|
|
128
|
-
const { session, dispose, hooks, sessionId, getTranscriptPath } = normalizeSubagentSession(
|
|
137
|
+
const { session, dispose, hooks, sessionId, agentDir, origin, getTranscriptPath } = normalizeSubagentSession(
|
|
129
138
|
await createSessionForSubagent(subagent, sessionOptions),
|
|
130
139
|
)
|
|
140
|
+
const turnEvent =
|
|
141
|
+
hooks && sessionId !== undefined && agentDir !== undefined
|
|
142
|
+
? { sessionId, agentDir, ...(origin !== undefined ? { origin } : {}) }
|
|
143
|
+
: undefined
|
|
131
144
|
try {
|
|
132
|
-
|
|
145
|
+
if (hooks && turnEvent !== undefined) {
|
|
146
|
+
await hooks.runSessionTurnStart(turnEvent)
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await session.prompt(override?.userPrompt ?? options.userPrompt)
|
|
150
|
+
} finally {
|
|
151
|
+
if (hooks && turnEvent !== undefined) {
|
|
152
|
+
await hooks.runSessionTurnEnd(turnEvent)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
133
155
|
if (hooks && sessionId !== undefined) {
|
|
134
156
|
await hooks.runSessionIdle({
|
|
135
157
|
sessionId,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# typeclaw-plugin-backup
|
|
2
|
+
|
|
3
|
+
The bundled backup plugin. Watches the agent folder for uncommitted work and commits + pushes it during quiet moments, with the LLM picking commit messages and diagnosing push/rebase failures. Replaces the previously documented-but-unimplemented "sessions/ via auto-backup" promise.
|
|
4
|
+
|
|
5
|
+
This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add and no opt-out short of `backup.enabled: false`. To configure it, add a `backup` block to `typeclaw.json`.
|
|
6
|
+
|
|
7
|
+
## Config
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"backup": {
|
|
12
|
+
"enabled": true,
|
|
13
|
+
"idleMs": 30000,
|
|
14
|
+
"pushToOrigin": true
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Field | Default | Effect |
|
|
20
|
+
| ------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| `backup.enabled` | `true` | Master switch. When `false`, all hooks no-op and the runner subagent is never spawned. |
|
|
22
|
+
| `backup.idleMs` | `30000` | Debounce window after the agent goes idle (no in-flight prompt turns) before the backup runner fires. Resets on every new prompt. Minimum `1000`. |
|
|
23
|
+
| `backup.pushToOrigin` | `true` | When `true`, after committing, the runner attempts `git push`. On non-fast-forward, it `git fetch && git rebase` then re-pushes. On rebase conflict, it aborts the rebase and asks the diagnose subagent to write a human-readable report. Set `false` to commit-only (useful for offline workflows or repos without a remote). |
|
|
24
|
+
| `backup.commitTimeoutMs` | `30000` | Per-command wall clock for local git operations (status/add/commit/diff). Mostly an escape hatch — defaults are generous. |
|
|
25
|
+
| `backup.networkTimeoutMs` | `60000` | Per-command wall clock for network git operations (push/fetch/rebase). Bounds the failure mode where a stuck remote would otherwise hang the runner indefinitely. `GIT_TERMINAL_PROMPT=0` is also set so auth failures fail fast instead of prompting. |
|
|
26
|
+
|
|
27
|
+
All fields are **restart-required** — the plugin reads them once at boot.
|
|
28
|
+
|
|
29
|
+
## How it triggers
|
|
30
|
+
|
|
31
|
+
The backup plugin uses **`session.idle` with debounce** as its trigger, not a fixed cron schedule. This means backups fire only after meaningful agent activity has settled — sporadic agents that never go idle (e.g. long polling loops in tools) will not be backed up by this plugin alone.
|
|
32
|
+
|
|
33
|
+
The fire path is gated by an **active-turn counter**: the plugin tracks `session.turn.start` / `session.turn.end` events from every prompt source (TUI, channel router, cron consumer, subagent invocations) and only fires when the count is zero. The plugin's own three subagents (`backup`, `backup-message`, `backup-diagnose`) are excluded from the count via `origin.kind === 'subagent' && origin.subagent` matching, so the backup never self-gates.
|
|
34
|
+
|
|
35
|
+
If a new prompt arrives while the runner is in flight, the runner finishes its current commit-and-push cycle; the plugin then re-evaluates the gate. There is no preemption mid-commit — the unit of atomicity is one full backup pass.
|
|
36
|
+
|
|
37
|
+
## What it commits
|
|
38
|
+
|
|
39
|
+
The runner stages two categories of dirty paths:
|
|
40
|
+
|
|
41
|
+
- **Tracked or untracked agent paths** (anything `git status --porcelain=v1 --untracked-files=all` reports), **except** paths under `memory/` — those are owned by the memory plugin's dreaming subagent.
|
|
42
|
+
- **Force-added `sessions/`** — gitignored, but force-added so transcripts survive across restarts.
|
|
43
|
+
|
|
44
|
+
Commit message comes from the `backup-message` subagent, which sees a truncated `git status` and `git diff --cached --stat` and writes a single conventional-ish commit message to a tmp file. On any failure the runner falls back to `chore: backup`.
|
|
45
|
+
|
|
46
|
+
## What it pushes
|
|
47
|
+
|
|
48
|
+
When `pushToOrigin: true` and the current branch has an upstream (`git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` succeeds), the runner runs `git push`. On non-fast-forward rejection, it runs `git fetch` then `git rebase <upstream>` then `git push` again.
|
|
49
|
+
|
|
50
|
+
If any network step fails (rebase conflict, auth failure, network timeout), the runner aborts cleanly and spawns the `backup-diagnose` subagent. That subagent has `bash`, `read`, and `write` tools and writes a short human-readable report to `<agentDir>/sessions/backup-diagnostics.log`. The diagnose subagent is explicitly forbidden from force-pushing or resolving merge conflicts itself.
|
|
51
|
+
|
|
52
|
+
## What it contributes
|
|
53
|
+
|
|
54
|
+
| Kind | Name | Notes |
|
|
55
|
+
| -------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
56
|
+
| Subagent | `backup` | Runner orchestrator. No LLM call — `handler` directly invokes the deterministic `runBackup`. Coalesced per `agentDir`. |
|
|
57
|
+
| Subagent | `backup-message` | Picks commit message from the diff. Has only the `write` tool. Coalesced per `agentDir`. |
|
|
58
|
+
| Subagent | `backup-diagnose` | Diagnoses push/rebase failures. Has `bash`, `read`, `write`. Coalesced per `agentDir`. |
|
|
59
|
+
| Hook | `session.turn.start` / `.end` | Maintains the active-turn counter. Excludes self-induced turns (the three subagents above) so the backup never gates against itself. |
|
|
60
|
+
| Hook | `session.idle` | Debouncer (idleMs). Resets the timer on every event. On fire, checks the active-turn counter and spawns `backup` if zero. |
|
|
61
|
+
| Hook | `session.end` | Removes the session from the active-turn set on session close. Defensive: if a session ends mid-turn (network drop), `session.turn.end` may not have fired. |
|
|
62
|
+
|
|
63
|
+
## Files on disk
|
|
64
|
+
|
|
65
|
+
- **`<agentDir>/.typeclaw/backup-message.tmp`** — ephemeral. Written by `backup-message` subagent, read and then deleted by the runner. The directory is created on demand. Not gitignored because it always cleans itself up before commit.
|
|
66
|
+
- **`<agentDir>/sessions/backup-diagnostics.log`** — append-only log written by `backup-diagnose` when push/rebase fails. Lives under `sessions/` so it gets force-added by the next successful backup. Read this file when investigating why the backup plugin stopped working.
|
|
67
|
+
|
|
68
|
+
## Why this design
|
|
69
|
+
|
|
70
|
+
This feature came up as: "periodically check for dirty files and commit; LLM picks the message and handles failures." A pre-implementation Oracle review pushed back hard on two assumptions:
|
|
71
|
+
|
|
72
|
+
1. **Don't make the core flow LLM-driven.** A subagent with `bash` orchestrating push/rebase/conflict recovery can hang on auth prompts, freestyle-mishandle conflicts, or burn an LLM call on every backup even when nothing went wrong. Instead, the deterministic runner owns the flow and only delegates two narrow tasks to LLMs: commit message synthesis (one short call, naturally bounded) and failure diagnosis (only fires on actual failures).
|
|
73
|
+
|
|
74
|
+
2. **`session.start` / `session.end` is the wrong gate.** Long-lived TUI and channel sessions stay open for hours; counting open sessions would mean the backup never fires. The new `session.turn.start` / `session.turn.end` hooks bracket each `session.prompt(...)` call across all four call sites (TUI server, cron consumer, subagent runner, channel router), so the counter reflects "active work in progress" rather than "any session connected".
|
|
75
|
+
|
|
76
|
+
`session.idle` (with debounce) was chosen over cron because it ties backup frequency to actual activity. There is no fixed `*/15 * * * *` schedule to misconfigure or re-explain. The tradeoff is the sporadic-agent case noted above.
|
|
77
|
+
|
|
78
|
+
## Tests
|
|
79
|
+
|
|
80
|
+
- `runner.test.ts` — deterministic runner unit tests (status parsing, force-add of `sessions/`, push-only-with-upstream, rebase-on-non-fast-forward, diagnose-on-rebase-conflict, advisory-throw isolation, sanitize-commit-message).
|
|
81
|
+
- `index.test.ts` — plugin composition tests (subagent/hook surface, config schema defaults and validation, debounce, active-turn gating, self-induced-turn exclusion, coalescing).
|
|
@@ -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
|
+
}
|