typeclaw 0.5.0 → 0.6.0

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 (48) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +80 -8
  4. package/src/agent/live-subagents.ts +215 -0
  5. package/src/agent/plugin-tools.ts +60 -20
  6. package/src/agent/session-origin.ts +15 -0
  7. package/src/agent/subagents.ts +140 -3
  8. package/src/agent/system-prompt.ts +40 -0
  9. package/src/agent/tools/channel-reply.ts +24 -1
  10. package/src/agent/tools/channel-send.ts +26 -1
  11. package/src/agent/tools/spawn-subagent.ts +283 -0
  12. package/src/agent/tools/subagent-cancel.ts +96 -0
  13. package/src/agent/tools/subagent-output.ts +192 -0
  14. package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
  15. package/src/bundled-plugins/explorer/explorer.ts +103 -0
  16. package/src/bundled-plugins/explorer/index.ts +11 -0
  17. package/src/bundled-plugins/guard/index.ts +12 -1
  18. package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
  19. package/src/bundled-plugins/guard/policy.ts +1 -0
  20. package/src/bundled-plugins/operator/index.ts +11 -0
  21. package/src/bundled-plugins/operator/operator.ts +76 -0
  22. package/src/bundled-plugins/scout/index.ts +11 -0
  23. package/src/bundled-plugins/scout/scout.ts +94 -0
  24. package/src/channels/router.ts +32 -0
  25. package/src/cli/channel.ts +2 -45
  26. package/src/cli/init.ts +2 -45
  27. package/src/cli/model.ts +2 -1
  28. package/src/cli/ui.ts +95 -0
  29. package/src/config/config.ts +45 -12
  30. package/src/config/index.ts +3 -0
  31. package/src/cron/index.ts +3 -0
  32. package/src/cron/schema.ts +20 -0
  33. package/src/init/dockerfile.ts +156 -5
  34. package/src/init/index.ts +33 -0
  35. package/src/permissions/builtins.ts +23 -2
  36. package/src/plugin/define.ts +2 -0
  37. package/src/plugin/index.ts +2 -0
  38. package/src/plugin/types.ts +15 -22
  39. package/src/run/bundled-plugins.ts +6 -0
  40. package/src/run/channel-session-factory.ts +19 -0
  41. package/src/run/index.ts +56 -6
  42. package/src/server/index.ts +103 -0
  43. package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
  44. package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
  45. package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
  46. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
  47. package/src/skills/typeclaw-config/SKILL.md +29 -26
  48. package/typeclaw.schema.json +6 -0
@@ -0,0 +1,96 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { PermissionService } from '@/permissions'
5
+
6
+ import type { LiveSubagentRegistry } from '../live-subagents'
7
+ import type { SessionOrigin } from '../session-origin'
8
+
9
+ export type SubagentCancelToolDetails =
10
+ | { ok: true; taskId: string; subagent: string; alreadyDone: boolean }
11
+ | { ok: false; error: string }
12
+
13
+ export type CreateSubagentCancelToolOptions = {
14
+ liveRegistry: LiveSubagentRegistry
15
+ getOrigin: () => SessionOrigin | undefined
16
+ permissions?: PermissionService
17
+ }
18
+
19
+ export function createSubagentCancelTool(options: CreateSubagentCancelToolOptions) {
20
+ const { liveRegistry, getOrigin, permissions } = options
21
+
22
+ return defineTool({
23
+ name: 'subagent_cancel',
24
+ label: 'Cancel Subagent',
25
+ description:
26
+ 'Cancel a running subagent you previously spawned. The subagent receives an abort signal and its current in-flight tool call is interrupted. ' +
27
+ 'Use this when the user changes their mind, the spawn is no longer needed, or a runaway subagent must be stopped. ' +
28
+ 'Cancelling an already-completed or failed subagent is a no-op (returns ok=true with alreadyDone=true).',
29
+ parameters: Type.Object({
30
+ task_id: Type.String({
31
+ description: 'The task_id returned by a previous spawn_subagent call.',
32
+ }),
33
+ }),
34
+
35
+ async execute(_toolCallId, params): Promise<ToolReturn> {
36
+ if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.cancel')) {
37
+ return errorResult('subagent.cancel denied: insufficient permissions')
38
+ }
39
+ const live = liveRegistry.get(params.task_id)
40
+ if (live === undefined) {
41
+ return errorResult(`Unknown task_id: ${params.task_id}.`)
42
+ }
43
+ if (live.status !== 'running') {
44
+ const details: SubagentCancelToolDetails = {
45
+ ok: true,
46
+ taskId: live.taskId,
47
+ subagent: live.subagentName,
48
+ alreadyDone: true,
49
+ }
50
+ return {
51
+ content: [
52
+ {
53
+ type: 'text' as const,
54
+ text: `${live.subagentName} (${live.taskId}) is already ${live.status}; nothing to cancel.`,
55
+ },
56
+ ],
57
+ details,
58
+ }
59
+ }
60
+ try {
61
+ await live.abort()
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err)
64
+ return errorResult(`abort failed: ${message}`)
65
+ }
66
+ const details: SubagentCancelToolDetails = {
67
+ ok: true,
68
+ taskId: live.taskId,
69
+ subagent: live.subagentName,
70
+ alreadyDone: false,
71
+ }
72
+ return {
73
+ content: [
74
+ {
75
+ type: 'text' as const,
76
+ text: `${live.subagentName} (${live.taskId}) cancellation requested. It will stop on the next abort checkpoint.`,
77
+ },
78
+ ],
79
+ details,
80
+ }
81
+ },
82
+ })
83
+ }
84
+
85
+ type ToolReturn = {
86
+ content: { type: 'text'; text: string }[]
87
+ details: SubagentCancelToolDetails
88
+ }
89
+
90
+ function errorResult(message: string): ToolReturn {
91
+ const details: SubagentCancelToolDetails = { ok: false, error: message }
92
+ return {
93
+ content: [{ type: 'text', text: message }],
94
+ details,
95
+ }
96
+ }
@@ -0,0 +1,192 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { PermissionService } from '@/permissions'
5
+
6
+ import type { LiveSubagentRegistry, StatusSnapshot, SubagentProgressEvent } from '../live-subagents'
7
+ import type { SessionOrigin } from '../session-origin'
8
+
9
+ const DEFAULT_TIMEOUT_MS = 60_000
10
+ const MAX_TIMEOUT_MS = 300_000
11
+
12
+ export type SubagentOutputToolDetails =
13
+ | {
14
+ ok: true
15
+ status: 'running'
16
+ taskId: string
17
+ subagent: string
18
+ startedAt: number
19
+ elapsedMs: number
20
+ eventsCount: number
21
+ eventsRecent: SubagentProgressEvent[]
22
+ lastActivity: SubagentProgressEvent | null
23
+ statusSummary: string
24
+ }
25
+ | {
26
+ ok: true
27
+ status: 'completed'
28
+ taskId: string
29
+ subagent: string
30
+ durationMs: number
31
+ finalMessage?: string
32
+ }
33
+ | {
34
+ ok: true
35
+ status: 'failed'
36
+ taskId: string
37
+ subagent: string
38
+ durationMs: number
39
+ error: string
40
+ }
41
+ | { ok: false; error: string }
42
+
43
+ export type CreateSubagentOutputToolOptions = {
44
+ liveRegistry: LiveSubagentRegistry
45
+ getOrigin: () => SessionOrigin | undefined
46
+ permissions?: PermissionService
47
+ now?: () => number
48
+ }
49
+
50
+ export function createSubagentOutputTool(options: CreateSubagentOutputToolOptions) {
51
+ const { liveRegistry, getOrigin, permissions, now = () => Date.now() } = options
52
+
53
+ return defineTool({
54
+ name: 'subagent_output',
55
+ label: 'Subagent Output',
56
+ description:
57
+ 'Fetch the current state of a subagent you previously spawned. Returns one of three statuses: ' +
58
+ "'running' (with a human-readable status_summary and a tail of recent progress events), " +
59
+ "'completed' (with the final message), or 'failed' (with the error). " +
60
+ 'Use this when the user asks how a long-running subagent is going, or when you need to retrieve the result of a backgrounded spawn. ' +
61
+ 'When block=true (default false), the tool waits up to timeout_ms for completion before returning. ' +
62
+ 'Prefer block=false and rely on the system-reminder for completion notification; reserve block=true for tight workflows.',
63
+ parameters: Type.Object({
64
+ task_id: Type.String({
65
+ description: 'The task_id returned by a previous spawn_subagent call.',
66
+ }),
67
+ block: Type.Optional(
68
+ Type.Boolean({
69
+ description:
70
+ 'If true, wait for the subagent to complete (or time out) before returning. Default false: return immediately with the current state.',
71
+ }),
72
+ ),
73
+ timeout_ms: Type.Optional(
74
+ Type.Integer({
75
+ description: `When block=true, max milliseconds to wait (default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}).`,
76
+ minimum: 1,
77
+ maximum: MAX_TIMEOUT_MS,
78
+ }),
79
+ ),
80
+ }),
81
+
82
+ async execute(_toolCallId, params) {
83
+ if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.output')) {
84
+ return errorResult('subagent.output denied: insufficient permissions')
85
+ }
86
+ const live = liveRegistry.get(params.task_id)
87
+ if (live === undefined) {
88
+ return errorResult(`Unknown task_id: ${params.task_id}.`)
89
+ }
90
+
91
+ const wantsBlock = params.block === true && live.status === 'running'
92
+ if (wantsBlock) {
93
+ const timeoutMs = clampTimeout(params.timeout_ms)
94
+ await raceWithTimeout(live.awaitCompletion(), timeoutMs)
95
+ }
96
+
97
+ const snap = liveRegistry.snapshot(params.task_id, now())
98
+ if (snap === undefined) {
99
+ return errorResult(`Unknown task_id: ${params.task_id}.`)
100
+ }
101
+ return renderSnapshot(snap)
102
+ },
103
+ })
104
+ }
105
+
106
+ function clampTimeout(value: number | undefined): number {
107
+ if (value === undefined) return DEFAULT_TIMEOUT_MS
108
+ return Math.min(Math.max(1, Math.floor(value)), MAX_TIMEOUT_MS)
109
+ }
110
+
111
+ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | undefined> {
112
+ return new Promise<T | undefined>((resolve) => {
113
+ const timer = setTimeout(() => resolve(undefined), timeoutMs)
114
+ promise.then(
115
+ (value) => {
116
+ clearTimeout(timer)
117
+ resolve(value)
118
+ },
119
+ () => {
120
+ clearTimeout(timer)
121
+ resolve(undefined)
122
+ },
123
+ )
124
+ })
125
+ }
126
+
127
+ type ToolReturn = {
128
+ content: { type: 'text'; text: string }[]
129
+ details: SubagentOutputToolDetails
130
+ }
131
+
132
+ function renderSnapshot(snap: StatusSnapshot): ToolReturn {
133
+ if (snap.status === 'running') {
134
+ const details: SubagentOutputToolDetails = {
135
+ ok: true,
136
+ status: 'running',
137
+ taskId: snap.taskId,
138
+ subagent: snap.subagentName,
139
+ startedAt: snap.startedAt,
140
+ elapsedMs: snap.elapsedMs,
141
+ eventsCount: snap.eventsCount,
142
+ eventsRecent: snap.eventsRecent,
143
+ lastActivity: snap.lastActivity,
144
+ statusSummary: snap.statusSummary,
145
+ }
146
+ return {
147
+ content: [{ type: 'text' as const, text: snap.statusSummary }],
148
+ details,
149
+ }
150
+ }
151
+ if (snap.status === 'completed') {
152
+ const finalMessage = snap.completion?.finalMessage
153
+ const details: SubagentOutputToolDetails = {
154
+ ok: true,
155
+ status: 'completed',
156
+ taskId: snap.taskId,
157
+ subagent: snap.subagentName,
158
+ durationMs: snap.completion?.durationMs ?? snap.elapsedMs,
159
+ ...(finalMessage !== undefined ? { finalMessage } : {}),
160
+ }
161
+ return {
162
+ content: [
163
+ {
164
+ type: 'text' as const,
165
+ text: finalMessage ?? `${snap.subagentName} completed in ${details.durationMs}ms with no final message.`,
166
+ },
167
+ ],
168
+ details,
169
+ }
170
+ }
171
+ const error = snap.completion?.error ?? 'unknown error'
172
+ const details: SubagentOutputToolDetails = {
173
+ ok: true,
174
+ status: 'failed',
175
+ taskId: snap.taskId,
176
+ subagent: snap.subagentName,
177
+ durationMs: snap.completion?.durationMs ?? snap.elapsedMs,
178
+ error,
179
+ }
180
+ return {
181
+ content: [{ type: 'text' as const, text: `${snap.subagentName} failed after ${details.durationMs}ms: ${error}` }],
182
+ details,
183
+ }
184
+ }
185
+
186
+ function errorResult(message: string): ToolReturn {
187
+ const details: SubagentOutputToolDetails = { ok: false, error: message }
188
+ return {
189
+ content: [{ type: 'text', text: message }],
190
+ details,
191
+ }
192
+ }
@@ -53,6 +53,32 @@ Tailscale and other remote networks. No special flag, tool, or config required.
53
53
  **Always share the proxy port URL — never `localhost:<raw-session-port>`** —
54
54
  those raw ports are inside the container and unreachable from the host.
55
55
 
56
+ ### Don't confuse the proxy port with the dashboard port
57
+
58
+ There are two ports in play. Both sockets live inside the container, but
59
+ they have very different audiences:
60
+
61
+ | File | Audience | What it's for |
62
+ | ------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `/tmp/typeclaw-agent-browser-proxy-port` | **Host browser** | The port the host browser opens (`http://localhost:<proxy-port>`). Host-forwarded via hostd; the compatibility proxy rewrites the dashboard's hardcoded loopback URLs so they work over Tailscale/LAN. **Default `4848`, falls back to `4849`–`4857` on collision.** |
64
+ | `/tmp/typeclaw-agent-browser-upstream-port` | **In-container clients** | The actual `agent-browser dashboard` server. **Default `4849`.** This is what other in-container processes (Cloudflare tunnels, in-container `curl`, in-container scripts) must talk to. Not host-forwarded — there's no point. |
65
+
66
+ **For Cloudflare tunnels and anything else that originates inside the
67
+ container, use the upstream-port file, NOT the proxy-port file.** Cloudflare's
68
+ `cloudflared` runs in the container's netns and connects to `127.0.0.1:<port>`
69
+ directly — it doesn't traverse the compatibility proxy and gains nothing from
70
+ it. Pointing a tunnel at the proxy port silently tunnels the proxy's listen
71
+ socket instead of the dashboard; the tunnel comes up, the URL "works" against
72
+ the proxy's pass-through paths, but anything dashboard-specific (sessions,
73
+ WebSocket activity feed, JSON API) breaks in non-obvious ways.
74
+
75
+ Common-failure shape: reading `proxy-port` mechanically because it's the
76
+ file you remembered, then passing that to `typeclaw tunnel add` or to a
77
+ `cloudflared --url http://127.0.0.1:<port>` invocation. **Read the
78
+ `upstream-port` file for tunnel upstreams.** When in doubt, run
79
+ `agent-browser dashboard status` (or check the `agent-browser dashboard
80
+ start` log line — it prints the upstream URL).
81
+
56
82
  ### When NOT to use the dashboard
57
83
 
58
84
  The dashboard is for **live observation and handoff**, not file delivery. If
@@ -0,0 +1,103 @@
1
+ import { z } from 'zod'
2
+
3
+ import { bashTool, findTool, grepTool, lsTool, readTool, type Subagent } from '@/plugin'
4
+
5
+ export const EXPLORER_SYSTEM_PROMPT = `You are a local-search specialist running inside TypeClaw. Your job: find things on the agent's local filesystem (code, transcripts, memory, config, git history, mounts) and return actionable results to the caller. For EXTERNAL web research, the caller should spawn \`scout\` instead — you have no network tools.
6
+
7
+ === READ-ONLY — NO FILE MODIFICATIONS ===
8
+ You are STRICTLY PROHIBITED from:
9
+ - Creating, modifying, or deleting files
10
+ - Using bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any write operation
11
+ - Starting long-running background processes
12
+ - Writing to MEMORY.md, sessions/, workspace/, or any other runtime-managed path
13
+ - Spawning further subagents — you are at the end of the delegation chain
14
+
15
+ Your role is EXCLUSIVELY to search and analyze existing local state.
16
+
17
+ ## Tools
18
+
19
+ The runtime exposes these tools to you by these EXACT names — call them by name, do not paraphrase:
20
+
21
+ - \`find\` — locate files by name pattern or extension across a directory tree
22
+ - \`grep\` — search file contents by text or regex
23
+ - \`read\` — read a specific file once you know its path
24
+ - \`ls\` — list a directory's immediate contents for structural discovery
25
+ - \`bash\` — ONLY for read-only commands. The two common shapes are read-only git (\`git log\`, \`git blame\`, \`git diff\`, \`git status\`, \`git grep\`, \`git show <commit>:<path>\`) and one-shot pipelines that don't mutate state (\`cat\`, \`head\`, \`tail\`, \`wc\`, \`sort\`, \`uniq\`, \`jq\`, \`awk\`)
26
+
27
+ Launch 3+ tools in parallel whenever you can. Cross-validate findings across multiple tools — a grep hit confirmed by reading the file is stronger than either alone.
28
+
29
+ ## Local searchable surfaces
30
+
31
+ The agent folder is mounted at \`/agent\` inside the container. Search the narrowest relevant surface before falling back to broad codebase greps.
32
+
33
+ 1. **Codebase** — \`/agent/\` root and subdirs (excluding the runtime-managed paths below). Source files, docs, identity files (\`IDENTITY.md\`, \`SOUL.md\`, \`USER.md\`, \`AGENTS.md\`).
34
+ 2. **Sessions** — \`/agent/sessions/*.jsonl\` — conversation transcripts. Each line is a JSON event (user message, tool call, tool result, assistant message). Filename pattern \`\${ISO_TIMESTAMP}_\${UUID}.jsonl\`. \`grep\` works directly on the JSONL.
35
+ 3. **Memory** — \`/agent/MEMORY.md\` (long-term consolidated memory) and \`/agent/memory/yyyy-MM-dd.jsonl\` (daily fragment streams written by the memory-logger subagent). \`memory/.dreaming-state.json\` tracks the dreaming watermark. Do NOT edit any of these — they are runtime-owned.
36
+ 4. **Muscle-memory skills** — \`/agent/memory/skills/<name>/SKILL.md\` — procedures the dreaming subagent distilled from repeated work.
37
+ 5. **User-installed skills** — \`/agent/.agents/skills/<name>/SKILL.md\` — hand-authored or downloaded skills.
38
+ 6. **Workspace** — \`/agent/workspace/\` — the agent's free-write zone. Drafts, scratch work, generated artifacts.
39
+ 7. **Cron** — \`/agent/cron.json\` — scheduled jobs. Plugin-contributed cron jobs are in-memory only and not visible from disk.
40
+ 8. **Config** — \`/agent/typeclaw.json\`, \`/agent/package.json\`, \`/agent/Dockerfile\`, \`/agent/.env\`, \`/agent/.gitignore\`, \`/agent/secrets.json\`. **\`.env\` and \`secrets.json\` contain credentials — never echo their values back to the caller verbatim; describe what's configured without printing tokens.**
41
+ 9. **Git history** — \`.git\` under \`/agent/\`. Search via read-only \`git log\`, \`git blame\`, \`git diff\`, \`git grep\`, \`git show <commit>:<path>\`.
42
+ 10. **Logs** — \`/agent/sessions/backup-diagnostics.log\` is the only persistent log inside the container (backup-plugin failures). Container stdout/stderr is ephemeral.
43
+ 11. **Mounts** — \`/agent/mounts/<name>/\` — host directories mapped into the container per \`typeclaw.json#mounts\`.
44
+ 12. **Channels persistence** — \`/agent/channels/sessions.json\` — active channel sessions, participants, last inbound timestamps.
45
+ 13. **Packages** — \`/agent/packages/\` — user-authored plugins or libraries the agent built.
46
+ 14. **Container-only state** — \`/agent/node_modules/\` (auto-generated, large — prefer targeted greps) and \`/tmp/\` (ephemeral).
47
+
48
+ ## Process
49
+
50
+ Before searching, analyze intent in an <analysis> block:
51
+
52
+ <analysis>
53
+ **Literal Request**: [what they literally asked]
54
+ **Actual Need**: [what they're really trying to accomplish]
55
+ **Success Looks Like**: [what result lets them proceed immediately]
56
+ </analysis>
57
+
58
+ End every response with this exact structure:
59
+
60
+ <results>
61
+ <files>
62
+ - /absolute/path/to/file.ts — [why this file is relevant]
63
+ </files>
64
+ <answer>
65
+ [Direct answer to the actual need, not just a file list. If they asked "where is auth?", explain the auth flow you found.]
66
+ </answer>
67
+ <next_steps>
68
+ [What the caller should do next, or "Ready to proceed."]
69
+ </next_steps>
70
+ </results>
71
+
72
+ ## Rules
73
+
74
+ - Every path MUST be absolute (start with /).
75
+ - Find ALL relevant matches, not just the first. Completeness over speed.
76
+ - Do NOT diagnose, plan, or make architectural decisions — that's the caller's job. You find and report.
77
+ - If the question requires EXTERNAL/web information (docs, library reference, web search, fetching a URL), say so explicitly and tell the caller to spawn \`scout\` instead. Do not try to answer external questions from memory.
78
+ - If you cannot find what was asked, say so explicitly with what you DID find and what surfaces you searched.`
79
+
80
+ export const explorerPayloadSchema = z
81
+ .object({
82
+ requestId: z.string().optional(),
83
+ prompt: z.string().optional(),
84
+ description: z.string().optional(),
85
+ })
86
+ .passthrough()
87
+
88
+ export type ExplorerPayload = z.infer<typeof explorerPayloadSchema>
89
+
90
+ export function createExplorerSubagent(): Subagent<ExplorerPayload> {
91
+ return {
92
+ systemPrompt: EXPLORER_SYSTEM_PROMPT,
93
+ profile: 'fast',
94
+ tools: [readTool, grepTool, findTool, lsTool, bashTool],
95
+ payloadSchema: explorerPayloadSchema,
96
+ visibility: 'public',
97
+ inFlightKey: (payload) => payload?.requestId ?? `anon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
98
+ toolResultBudget: {
99
+ maxTotalBytes: 256_000,
100
+ toolNames: ['read', 'grep', 'find', 'ls', 'bash'],
101
+ },
102
+ }
103
+ }
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { createExplorerSubagent } from './explorer'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ subagents: {
8
+ explorer: createExplorerSubagent(),
9
+ },
10
+ }),
11
+ })
@@ -1,11 +1,22 @@
1
1
  import { definePlugin } from '@/plugin'
2
2
 
3
- import { checkNonWorkspaceWriteGuard, checkSkillAuthoringGuard, checkUncommittedChangesAdvice } from './policy'
3
+ import {
4
+ checkManagedConfigGuard,
5
+ checkNonWorkspaceWriteGuard,
6
+ checkSkillAuthoringGuard,
7
+ checkUncommittedChangesAdvice,
8
+ } from './policy'
4
9
 
5
10
  export default definePlugin({
6
11
  plugin: async () => ({
7
12
  hooks: {
8
13
  'tool.before': async (event, ctx) => {
14
+ const managedConfigResult = await checkManagedConfigGuard({
15
+ tool: event.tool,
16
+ args: event.args,
17
+ agentDir: ctx.agentDir,
18
+ })
19
+ if (managedConfigResult) return managedConfigResult
9
20
  const skillResult = await checkSkillAuthoringGuard({
10
21
  tool: event.tool,
11
22
  args: event.args,
@@ -0,0 +1,139 @@
1
+ import { readFile, realpath } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { parseConfigJson } from '@/config'
5
+ import { parseCronJson } from '@/cron'
6
+
7
+ import type { GuardBlock } from '../policy'
8
+
9
+ export const GUARD_MANAGED_CONFIG = 'managedConfig'
10
+
11
+ type ManagedFile = 'typeclaw.json' | 'cron.json'
12
+
13
+ const MANAGED_FILES = new Set<ManagedFile>(['typeclaw.json', 'cron.json'])
14
+
15
+ export async function checkManagedConfigGuard(options: {
16
+ tool: string
17
+ args: Record<string, unknown>
18
+ agentDir: string
19
+ }): Promise<GuardBlock | undefined> {
20
+ const { tool, args, agentDir } = options
21
+ if (tool !== 'write' && tool !== 'edit') return undefined
22
+
23
+ const rawPath = args.path
24
+ if (typeof rawPath !== 'string') return undefined
25
+
26
+ const targetPath = path.resolve(agentDir, rawPath)
27
+ const managed = await resolveManagedTarget(agentDir, targetPath)
28
+ if (!managed) return undefined
29
+
30
+ const contentResult = await intendedContent(tool, args, targetPath)
31
+ if ('block' in contentResult) return contentResult
32
+
33
+ const validation = validateManagedContent(managed.file, contentResult.content)
34
+ if (validation.ok) return undefined
35
+
36
+ return {
37
+ block: true,
38
+ reason: `Guard \`${GUARD_MANAGED_CONFIG}\` blocked ${tool} for ${targetPath}: ${validation.reason}.`,
39
+ }
40
+ }
41
+
42
+ async function resolveManagedTarget(agentDir: string, targetPath: string): Promise<{ file: ManagedFile } | undefined> {
43
+ const resolvedAgentDir = path.resolve(agentDir)
44
+ const realAgentDir = await resolveRealIntendedPath(resolvedAgentDir)
45
+ const realTargetPath = await resolveRealIntendedPath(targetPath)
46
+
47
+ if (path.dirname(realTargetPath) !== realAgentDir) return undefined
48
+
49
+ const basename = path.basename(realTargetPath)
50
+ return isManagedFile(basename) ? { file: basename } : undefined
51
+ }
52
+
53
+ function isManagedFile(basename: string): basename is ManagedFile {
54
+ return MANAGED_FILES.has(basename as ManagedFile)
55
+ }
56
+
57
+ function validateManagedContent(file: ManagedFile, content: string): { ok: true } | { ok: false; reason: string } {
58
+ if (file === 'typeclaw.json') {
59
+ const result = parseConfigJson(content, { migrate: false })
60
+ return result.ok ? { ok: true } : { ok: false, reason: result.reason }
61
+ }
62
+ const result = parseCronJson(content, { migrate: false })
63
+ return result.ok ? { ok: true } : { ok: false, reason: result.reason }
64
+ }
65
+
66
+ async function intendedContent(
67
+ tool: string,
68
+ args: Record<string, unknown>,
69
+ targetPath: string,
70
+ ): Promise<{ content: string } | GuardBlock> {
71
+ if (tool === 'write') {
72
+ const content = args.content
73
+ if (typeof content !== 'string') {
74
+ return blockReason(tool, targetPath, 'write content must be a string')
75
+ }
76
+ return { content }
77
+ }
78
+
79
+ const edits = args.edits
80
+ if (!Array.isArray(edits)) {
81
+ return blockReason(tool, targetPath, 'edit calls must include an edits array')
82
+ }
83
+
84
+ let content: string
85
+ try {
86
+ content = await readFile(targetPath, 'utf8')
87
+ } catch (err) {
88
+ const message = err instanceof Error ? err.message : String(err)
89
+ return blockReason(tool, targetPath, `could not read existing file before edit: ${message}`)
90
+ }
91
+
92
+ for (const edit of edits) {
93
+ if (!edit || typeof edit !== 'object') {
94
+ return blockReason(tool, targetPath, 'each edit must be an object')
95
+ }
96
+ const { oldText, newText } = edit as Record<string, unknown>
97
+ if (typeof oldText !== 'string' || typeof newText !== 'string') {
98
+ return blockReason(tool, targetPath, 'each edit must include string oldText and newText')
99
+ }
100
+ if (oldText.length === 0) {
101
+ return blockReason(tool, targetPath, 'edit oldText must not be empty')
102
+ }
103
+ if (!content.includes(oldText)) {
104
+ return blockReason(tool, targetPath, 'edit oldText was not found in existing file')
105
+ }
106
+ content = content.replace(oldText, newText)
107
+ }
108
+ return { content }
109
+ }
110
+
111
+ function blockReason(tool: string, targetPath: string, reason: string): GuardBlock {
112
+ return {
113
+ block: true,
114
+ reason: `Guard \`${GUARD_MANAGED_CONFIG}\` blocked ${tool} for ${targetPath}: ${reason}.`,
115
+ }
116
+ }
117
+
118
+ async function resolveRealIntendedPath(absolutePath: string): Promise<string> {
119
+ const pending: string[] = []
120
+ let current = absolutePath
121
+
122
+ while (true) {
123
+ try {
124
+ const realCurrent = await realpath(current)
125
+ return path.join(realCurrent, ...pending.reverse())
126
+ } catch (err) {
127
+ if (!isNotFoundError(err)) throw err
128
+ }
129
+
130
+ const parent = path.dirname(current)
131
+ if (parent === current) throw new Error(`could not resolve existing parent for ${absolutePath}`)
132
+ pending.push(path.basename(current))
133
+ current = parent
134
+ }
135
+ }
136
+
137
+ function isNotFoundError(err: unknown): boolean {
138
+ return err instanceof Error && 'code' in err && err.code === 'ENOENT'
139
+ }
@@ -8,6 +8,7 @@ export function isGuardAcknowledged(args: Record<string, unknown>, guard: string
8
8
  return (acknowledgements as Record<string, unknown>)[guard] === true
9
9
  }
10
10
 
11
+ export { GUARD_MANAGED_CONFIG, checkManagedConfigGuard } from './policies/managed-config'
11
12
  export { GUARD_NON_WORKSPACE_WRITE, checkNonWorkspaceWriteGuard } from './policies/non-workspace-write'
12
13
  export {
13
14
  GUARD_SKILL_AUTHORING,
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { createOperatorSubagent } from './operator'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ subagents: {
8
+ operator: createOperatorSubagent(),
9
+ },
10
+ }),
11
+ })