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.
Files changed (57) hide show
  1. package/README.md +12 -12
  2. package/package.json +3 -2
  3. package/src/agent/auth.ts +10 -4
  4. package/src/agent/doctor.ts +173 -0
  5. package/src/agent/subagents.ts +24 -2
  6. package/src/bundled-plugins/backup/README.md +81 -0
  7. package/src/bundled-plugins/backup/index.ts +209 -0
  8. package/src/bundled-plugins/backup/runner.ts +231 -0
  9. package/src/bundled-plugins/backup/subagents.ts +200 -0
  10. package/src/bundled-plugins/memory/index.ts +42 -1
  11. package/src/bundled-plugins/security/index.ts +5 -1
  12. package/src/bundled-plugins/security/policies/git-exfil.ts +184 -4
  13. package/src/bundled-plugins/security/policies/remote-taint-state.ts +59 -0
  14. package/src/channels/adapters/kakaotalk-attachment.ts +224 -0
  15. package/src/channels/adapters/kakaotalk-channel-resolver.ts +20 -1
  16. package/src/channels/adapters/kakaotalk-fetch-attachment.ts +91 -0
  17. package/src/channels/adapters/kakaotalk.ts +58 -3
  18. package/src/channels/router.ts +40 -2
  19. package/src/cli/compose.ts +92 -1
  20. package/src/cli/doctor.ts +100 -0
  21. package/src/cli/index.ts +1 -0
  22. package/src/compose/doctor.ts +141 -0
  23. package/src/compose/index.ts +8 -0
  24. package/src/compose/logs.ts +32 -19
  25. package/src/config/config.ts +20 -0
  26. package/src/container/log-colors.ts +75 -0
  27. package/src/container/log-timestamps.ts +84 -0
  28. package/src/container/logs.ts +71 -5
  29. package/src/container/start.ts +23 -8
  30. package/src/cron/consumer.ts +29 -7
  31. package/src/doctor/checks.ts +426 -0
  32. package/src/doctor/commit.ts +71 -0
  33. package/src/doctor/index.ts +287 -0
  34. package/src/doctor/plugin-bridge.ts +147 -0
  35. package/src/doctor/report.ts +142 -0
  36. package/src/doctor/types.ts +87 -0
  37. package/src/init/cli-version.ts +81 -0
  38. package/src/init/dockerfile.ts +223 -25
  39. package/src/init/ensure-deps.ts +2 -2
  40. package/src/init/index.ts +23 -13
  41. package/src/init/run-bun-install.ts +17 -1
  42. package/src/plugin/hooks.ts +32 -0
  43. package/src/plugin/index.ts +7 -0
  44. package/src/plugin/manager.ts +2 -0
  45. package/src/plugin/registry.ts +32 -3
  46. package/src/plugin/types.ts +65 -0
  47. package/src/run/bundled-plugins.ts +8 -0
  48. package/src/run/index.ts +10 -5
  49. package/src/secrets/env.ts +43 -0
  50. package/src/secrets/index.ts +2 -0
  51. package/src/server/index.ts +103 -5
  52. package/src/shared/index.ts +3 -0
  53. package/src/shared/protocol.ts +22 -0
  54. package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +26 -3
  55. package/src/skills/typeclaw-config/SKILL.md +1 -1
  56. package/tsconfig.json +30 -0
  57. 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` | `docker logs` passthrough, `-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 |
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.0",
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.14.1",
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 needsWrite = existing === undefined || (existing.type === 'api_key' && existing.key !== envKey)
74
- if (needsWrite) {
75
- authStorage.set(provider.id, { type: 'api_key', key: envKey })
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
+ }
@@ -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
- await session.prompt(override?.userPrompt ?? options.userPrompt)
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
+ }