typeclaw 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +16 -12
  2. package/auth.schema.json +238 -7
  3. package/package.json +1 -1
  4. package/secrets.schema.json +238 -7
  5. package/src/agent/auth.ts +19 -38
  6. package/src/agent/doctor.ts +173 -0
  7. package/src/agent/subagents.ts +24 -2
  8. package/src/agent/tools/channel-fetch-attachment.ts +6 -0
  9. package/src/agent/tools/channel-history.ts +10 -1
  10. package/src/agent/tools/channel-log.ts +32 -0
  11. package/src/agent/tools/channel-reply.ts +18 -1
  12. package/src/agent/tools/channel-send.ts +13 -1
  13. package/src/bundled-plugins/backup/README.md +81 -0
  14. package/src/bundled-plugins/backup/index.ts +209 -0
  15. package/src/bundled-plugins/backup/runner.ts +231 -0
  16. package/src/bundled-plugins/backup/subagents.ts +200 -0
  17. package/src/bundled-plugins/memory/index.ts +42 -1
  18. package/src/bundled-plugins/tool-result-cap/README.md +67 -0
  19. package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
  20. package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
  21. package/src/channels/adapters/kakaotalk.ts +25 -16
  22. package/src/channels/manager.ts +47 -38
  23. package/src/channels/router.ts +29 -0
  24. package/src/cli/channel.ts +3 -3
  25. package/src/cli/compose.ts +92 -1
  26. package/src/cli/doctor.ts +100 -0
  27. package/src/cli/index.ts +4 -0
  28. package/src/cli/init.ts +2 -1
  29. package/src/cli/ui.ts +11 -0
  30. package/src/compose/doctor.ts +141 -0
  31. package/src/compose/index.ts +8 -0
  32. package/src/compose/logs.ts +32 -19
  33. package/src/config/config.ts +31 -0
  34. package/src/container/log-colors.ts +75 -0
  35. package/src/container/log-timestamps.ts +84 -0
  36. package/src/container/logs.ts +71 -5
  37. package/src/container/start.ts +113 -9
  38. package/src/cron/consumer.ts +29 -7
  39. package/src/doctor/checks.ts +426 -0
  40. package/src/doctor/commit.ts +71 -0
  41. package/src/doctor/index.ts +287 -0
  42. package/src/doctor/plugin-bridge.ts +147 -0
  43. package/src/doctor/report.ts +142 -0
  44. package/src/doctor/types.ts +87 -0
  45. package/src/hostd/daemon.ts +28 -3
  46. package/src/hostd/protocol.ts +7 -0
  47. package/src/init/auto-upgrade.ts +368 -0
  48. package/src/init/cli-version.ts +81 -0
  49. package/src/init/dockerfile.ts +234 -25
  50. package/src/init/index.ts +141 -87
  51. package/src/init/kakaotalk-auth.ts +9 -3
  52. package/src/init/run-bun-install.ts +34 -0
  53. package/src/plugin/hooks.ts +32 -0
  54. package/src/plugin/index.ts +7 -0
  55. package/src/plugin/manager.ts +2 -0
  56. package/src/plugin/registry.ts +32 -3
  57. package/src/plugin/types.ts +65 -0
  58. package/src/run/bundled-plugins.ts +15 -0
  59. package/src/run/index.ts +19 -5
  60. package/src/secrets/defaults.ts +67 -0
  61. package/src/secrets/hydrate.ts +99 -0
  62. package/src/secrets/index.ts +6 -12
  63. package/src/secrets/kakao-store.ts +129 -0
  64. package/src/secrets/migrate-kakaotalk.ts +82 -0
  65. package/src/secrets/migrate.ts +5 -4
  66. package/src/secrets/resolve.ts +57 -0
  67. package/src/secrets/schema.ts +162 -42
  68. package/src/secrets/storage.ts +253 -47
  69. package/src/server/index.ts +103 -5
  70. package/src/shared/index.ts +3 -0
  71. package/src/shared/protocol.ts +22 -0
  72. package/src/skills/typeclaw-config/SKILL.md +48 -9
  73. package/typeclaw.schema.json +84 -0
  74. package/src/secrets/env.ts +0 -43
@@ -0,0 +1,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,
@@ -7,6 +7,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
7
7
  import type { ChannelRouter } from '@/channels/router'
8
8
  import type { AdapterId } from '@/channels/schema'
9
9
 
10
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
11
+
10
12
  export type ChannelFetchAttachmentOrigin = {
11
13
  adapter: AdapterId
12
14
  }
@@ -15,6 +17,7 @@ export type CreateChannelFetchAttachmentToolOptions = {
15
17
  router: ChannelRouter
16
18
  origin: ChannelFetchAttachmentOrigin
17
19
  inboxDir?: string
20
+ logger?: ChannelToolLogger
18
21
  }
19
22
 
20
23
  export const DEFAULT_INBOX_DIR = '/agent/workspace/inbox'
@@ -23,6 +26,7 @@ export function createChannelFetchAttachmentTool({
23
26
  router,
24
27
  origin,
25
28
  inboxDir,
29
+ logger = consoleChannelLogger,
26
30
  }: CreateChannelFetchAttachmentToolOptions) {
27
31
  const baseDir = inboxDir ?? DEFAULT_INBOX_DIR
28
32
  const adapter = origin.adapter
@@ -60,6 +64,7 @@ export function createChannelFetchAttachmentTool({
60
64
  ...(params.filename !== undefined ? { filename: params.filename } : {}),
61
65
  })
62
66
  if (!result.ok) {
67
+ logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: ${result.error}`))
63
68
  const text = `channel_fetch_attachment error: ${result.error}`
64
69
  const details: Details = { ok: false, error: result.error }
65
70
  return { content: [{ type: 'text' as const, text }], details }
@@ -74,6 +79,7 @@ export function createChannelFetchAttachmentTool({
74
79
  await writeFile(targetPath, result.buffer)
75
80
  } catch (err) {
76
81
  const message = err instanceof Error ? err.message : String(err)
82
+ logger.warn(formatChannelToolFailure('channel_fetch_attachment', `${adapter}: write failed: ${message}`))
77
83
  const text = `channel_fetch_attachment error: write failed: ${message}`
78
84
  const details: Details = { ok: false, error: `write failed: ${message}` }
79
85
  return { content: [{ type: 'text' as const, text }], details }
@@ -5,6 +5,8 @@ import type { ChannelRouter } from '@/channels/router'
5
5
  import type { AdapterId } from '@/channels/schema'
6
6
  import type { ChannelHistoryMessage } from '@/channels/types'
7
7
 
8
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
9
+
8
10
  export type ChannelHistoryOrigin = {
9
11
  adapter: AdapterId
10
12
  workspace: string
@@ -15,6 +17,7 @@ export type ChannelHistoryOrigin = {
15
17
  export type CreateChannelHistoryToolOptions = {
16
18
  router: ChannelRouter
17
19
  origin: ChannelHistoryOrigin
20
+ logger?: ChannelToolLogger
18
21
  }
19
22
 
20
23
  // channel_history is a lazy "look back" capability for channel-routed
@@ -27,7 +30,11 @@ export type CreateChannelHistoryToolOptions = {
27
30
  // `scope` defaults to thread when the origin has one, channel otherwise.
28
31
  // Thread scope on a channel-root session is rejected rather than silently
29
32
  // downgraded so the agent doesn't conflate the two views.
30
- export function createChannelHistoryTool({ router, origin }: CreateChannelHistoryToolOptions) {
33
+ export function createChannelHistoryTool({
34
+ router,
35
+ origin,
36
+ logger = consoleChannelLogger,
37
+ }: CreateChannelHistoryToolOptions) {
31
38
  return defineTool({
32
39
  name: 'channel_history',
33
40
  label: 'Channel History',
@@ -64,6 +71,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
64
71
  type Details = { ok: boolean; error?: string; count?: number; nextCursor?: string }
65
72
 
66
73
  if (scope === 'thread' && origin.thread === null) {
74
+ logger.warn(formatChannelToolFailure('channel_history', 'thread-scope-requires-thread-session'))
67
75
  const text =
68
76
  'channel_history error: thread-scope-requires-thread-session — this session is not in a thread; pass `scope: "channel"` instead.'
69
77
  const details: Details = { ok: false, error: 'thread-scope-requires-thread-session' }
@@ -78,6 +86,7 @@ export function createChannelHistoryTool({ router, origin }: CreateChannelHistor
78
86
  })
79
87
 
80
88
  if (!result.ok) {
89
+ logger.warn(formatChannelToolFailure('channel_history', `${origin.adapter}:${origin.chat}: ${result.error}`))
81
90
  const details: Details = { ok: false, error: result.error }
82
91
  return {
83
92
  content: [{ type: 'text' as const, text: `channel_history error: ${result.error}` }],
@@ -0,0 +1,32 @@
1
+ // Shared logger surface for the channel_* agent tools.
2
+ //
3
+ // Until now, channel_send / channel_reply / channel_history /
4
+ // channel_fetch_attachment swallowed every failure into the model-visible
5
+ // tool result and emitted nothing to the container's stdout/stderr. That
6
+ // made operator-side debugging blind: a Slack send that 403'd, a
7
+ // `thread-scope-requires-thread-session` denial, or a Discord attachment
8
+ // fetch that timed out left no trace in `typeclaw logs`. The router layer
9
+ // logs some of these (e.g. `fetchHistory` warns on caught exceptions) but
10
+ // does NOT log `router.send` rejections, and pre-router validation errors
11
+ // inside the tools (missing text, NO_REPLY misuse, thread-scope mismatch,
12
+ // local write failures) never reached the router in the first place.
13
+ //
14
+ // One injectable logger per tool keeps the existing fake-router test
15
+ // pattern intact: tests pass an array-collecting logger to assert the log
16
+ // line, production code defaults to `consoleChannelLogger` which routes to
17
+ // `console.warn` so it lands in `typeclaw logs` alongside the existing
18
+ // `[channels]` lines from manager.ts / router.ts.
19
+ export type ChannelToolLogger = {
20
+ warn: (msg: string) => void
21
+ }
22
+
23
+ export const consoleChannelLogger: ChannelToolLogger = {
24
+ warn: (m) => console.warn(m),
25
+ }
26
+
27
+ // Format a failure log line. Keeps the `[channels]` prefix used by
28
+ // manager.ts and router.ts so operators can `grep '\[channels\]'` and see
29
+ // the full stack of channel-related warnings in one pass.
30
+ export function formatChannelToolFailure(tool: string, error: string): string {
31
+ return `[channels] ${tool} failed: ${error}`
32
+ }
@@ -4,6 +4,8 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
4
4
  import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
5
  import type { AdapterId } from '@/channels/schema'
6
6
 
7
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
8
+
7
9
  export type ChannelReplyOrigin = {
8
10
  adapter: AdapterId
9
11
  workspace: string
@@ -14,6 +16,7 @@ export type ChannelReplyOrigin = {
14
16
  export type CreateChannelReplyToolOptions = {
15
17
  router: ChannelRouter
16
18
  origin: ChannelReplyOrigin
19
+ logger?: ChannelToolLogger
17
20
  }
18
21
 
19
22
  // channel_reply is the happy-path companion to channel_send for channel-routed
@@ -25,7 +28,11 @@ export type CreateChannelReplyToolOptions = {
25
28
  // channel_reply takes only `text` and addresses the message from the origin.
26
29
  // channel_send remains for posting somewhere else (different chat, breaking
27
30
  // out of a thread, sending DMs from a channel session, etc.).
28
- export function createChannelReplyTool({ router, origin }: CreateChannelReplyToolOptions) {
31
+ export function createChannelReplyTool({
32
+ router,
33
+ origin,
34
+ logger = consoleChannelLogger,
35
+ }: CreateChannelReplyToolOptions) {
29
36
  return defineTool({
30
37
  name: 'channel_reply',
31
38
  label: 'Channel Reply',
@@ -64,6 +71,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
64
71
  const text = params.text
65
72
  const attachments = params.attachments
66
73
  if ((text === undefined || text === '') && (attachments === undefined || attachments.length === 0)) {
74
+ logger.warn(formatChannelToolFailure('channel_reply', 'missing text and attachments'))
67
75
  return {
68
76
  content: [
69
77
  { type: 'text' as const, text: 'channel_reply denied: must provide `text`, `attachments`, or both.' },
@@ -74,6 +82,7 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
74
82
 
75
83
  const noReplyError = noReplyMisuseError(text)
76
84
  if (noReplyError) {
85
+ logger.warn(formatChannelToolFailure('channel_reply', noReplyError))
77
86
  return {
78
87
  content: [{ type: 'text' as const, text: `channel_reply denied: ${noReplyError}` }],
79
88
  details: { ok: false, error: noReplyError },
@@ -89,6 +98,14 @@ export function createChannelReplyTool({ router, origin }: CreateChannelReplyToo
89
98
  ...(attachments !== undefined ? { attachments } : {}),
90
99
  })
91
100
 
101
+ if (!result.ok) {
102
+ logger.warn(
103
+ formatChannelToolFailure(
104
+ 'channel_reply',
105
+ `${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`,
106
+ ),
107
+ )
108
+ }
92
109
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
93
110
  // Echo the delivered text back to the model. The adapter classifier
94
111
  // drops self-authored messages on the inbound path (`self_author`),
@@ -4,6 +4,7 @@ import { defineTool } from '@mariozechner/pi-coding-agent'
4
4
  import { isNoReplySignal, type ChannelRouter } from '@/channels/router'
5
5
  import { ADAPTER_IDS, type AdapterId } from '@/channels/schema'
6
6
 
7
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
7
8
  import { renderOutboundEcho } from './channel-reply'
8
9
 
9
10
  export type ChannelSendOrigin = {
@@ -21,9 +22,10 @@ export type CreateChannelSendToolOptions = {
21
22
  // the model can self-correct on its next turn. Absent for sessions whose
22
23
  // origin isn't a channel (e.g. cron prompts that send to channels).
23
24
  origin?: ChannelSendOrigin
25
+ logger?: ChannelToolLogger
24
26
  }
25
27
 
26
- export function createChannelSendTool({ router, origin }: CreateChannelSendToolOptions) {
28
+ export function createChannelSendTool({ router, origin, logger = consoleChannelLogger }: CreateChannelSendToolOptions) {
27
29
  return defineTool({
28
30
  name: 'channel_send',
29
31
  label: 'Channel Send',
@@ -93,6 +95,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
93
95
  const bodyText = params.text
94
96
  const attachments = params.attachments
95
97
  if ((bodyText === undefined || bodyText === '') && (attachments === undefined || attachments.length === 0)) {
98
+ logger.warn(formatChannelToolFailure('channel_send', 'missing text and attachments'))
96
99
  return {
97
100
  content: [
98
101
  { type: 'text' as const, text: 'channel_send denied: must provide `text`, `attachments`, or both.' },
@@ -103,6 +106,7 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
103
106
 
104
107
  const noReplyError = noReplyMisuseError(bodyText)
105
108
  if (noReplyError) {
109
+ logger.warn(formatChannelToolFailure('channel_send', noReplyError))
106
110
  return {
107
111
  content: [{ type: 'text' as const, text: `channel_send denied: ${noReplyError}` }],
108
112
  details: { ok: false, error: noReplyError },
@@ -118,6 +122,14 @@ export function createChannelSendTool({ router, origin }: CreateChannelSendToolO
118
122
  ...(attachments !== undefined ? { attachments } : {}),
119
123
  })
120
124
 
125
+ if (!result.ok) {
126
+ logger.warn(
127
+ formatChannelToolFailure(
128
+ 'channel_send',
129
+ `${params.adapter}:${params.workspace}/${params.chat}: ${result.error}`,
130
+ ),
131
+ )
132
+ }
121
133
  const details: { ok: boolean; error?: string } = result.ok ? { ok: true } : { ok: false, error: result.error }
122
134
  const echo = renderOutboundEcho(bodyText, attachments)
123
135
  const baseText = result.ok
@@ -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).