typeclaw 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/auth.schema.json +238 -7
- package/package.json +1 -1
- package/secrets.schema.json +238 -7
- package/src/agent/auth.ts +19 -38
- package/src/agent/doctor.ts +173 -0
- package/src/agent/subagents.ts +24 -2
- package/src/agent/tools/channel-fetch-attachment.ts +6 -0
- package/src/agent/tools/channel-history.ts +10 -1
- package/src/agent/tools/channel-log.ts +32 -0
- package/src/agent/tools/channel-reply.ts +18 -1
- package/src/agent/tools/channel-send.ts +13 -1
- package/src/bundled-plugins/backup/README.md +81 -0
- package/src/bundled-plugins/backup/index.ts +209 -0
- package/src/bundled-plugins/backup/runner.ts +231 -0
- package/src/bundled-plugins/backup/subagents.ts +200 -0
- package/src/bundled-plugins/memory/index.ts +42 -1
- package/src/bundled-plugins/tool-result-cap/README.md +67 -0
- package/src/bundled-plugins/tool-result-cap/cap-result.ts +56 -0
- package/src/bundled-plugins/tool-result-cap/index.ts +51 -0
- package/src/channels/adapters/kakaotalk.ts +25 -16
- package/src/channels/manager.ts +47 -38
- package/src/channels/router.ts +29 -0
- package/src/cli/channel.ts +3 -3
- package/src/cli/compose.ts +92 -1
- package/src/cli/doctor.ts +100 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/init.ts +2 -1
- package/src/cli/ui.ts +11 -0
- package/src/compose/doctor.ts +141 -0
- package/src/compose/index.ts +8 -0
- package/src/compose/logs.ts +32 -19
- package/src/config/config.ts +31 -0
- package/src/container/log-colors.ts +75 -0
- package/src/container/log-timestamps.ts +84 -0
- package/src/container/logs.ts +71 -5
- package/src/container/start.ts +113 -9
- package/src/cron/consumer.ts +29 -7
- package/src/doctor/checks.ts +426 -0
- package/src/doctor/commit.ts +71 -0
- package/src/doctor/index.ts +287 -0
- package/src/doctor/plugin-bridge.ts +147 -0
- package/src/doctor/report.ts +142 -0
- package/src/doctor/types.ts +87 -0
- package/src/hostd/daemon.ts +28 -3
- package/src/hostd/protocol.ts +7 -0
- package/src/init/auto-upgrade.ts +368 -0
- package/src/init/cli-version.ts +81 -0
- package/src/init/dockerfile.ts +234 -25
- package/src/init/index.ts +141 -87
- package/src/init/kakaotalk-auth.ts +9 -3
- package/src/init/run-bun-install.ts +34 -0
- package/src/plugin/hooks.ts +32 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +32 -3
- package/src/plugin/types.ts +65 -0
- package/src/run/bundled-plugins.ts +15 -0
- package/src/run/index.ts +19 -5
- package/src/secrets/defaults.ts +67 -0
- package/src/secrets/hydrate.ts +99 -0
- package/src/secrets/index.ts +6 -12
- package/src/secrets/kakao-store.ts +129 -0
- package/src/secrets/migrate-kakaotalk.ts +82 -0
- package/src/secrets/migrate.ts +5 -4
- package/src/secrets/resolve.ts +57 -0
- package/src/secrets/schema.ts +162 -42
- package/src/secrets/storage.ts +253 -47
- package/src/server/index.ts +103 -5
- package/src/shared/index.ts +3 -0
- package/src/shared/protocol.ts +22 -0
- package/src/skills/typeclaw-config/SKILL.md +48 -9
- package/typeclaw.schema.json +84 -0
- package/src/secrets/env.ts +0 -43
|
@@ -0,0 +1,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,
|
|
@@ -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({
|
|
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({
|
|
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).
|