typeclaw 0.5.1 → 0.7.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.
- package/README.md +34 -84
- package/package.json +1 -1
- package/src/agent/index.ts +80 -8
- package/src/agent/live-subagents.ts +215 -0
- package/src/agent/plugin-tools.ts +60 -20
- package/src/agent/session-origin.ts +15 -0
- package/src/agent/subagents.ts +140 -3
- package/src/agent/system-prompt.ts +42 -0
- package/src/agent/tools/channel-reply.ts +24 -1
- package/src/agent/tools/channel-send.ts +26 -1
- package/src/agent/tools/spawn-subagent.ts +283 -0
- package/src/agent/tools/subagent-cancel.ts +96 -0
- package/src/agent/tools/subagent-output.ts +192 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +26 -0
- package/src/bundled-plugins/explorer/explorer.ts +103 -0
- package/src/bundled-plugins/explorer/index.ts +11 -0
- package/src/bundled-plugins/guard/index.ts +12 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +139 -0
- package/src/bundled-plugins/guard/policy.ts +1 -0
- package/src/bundled-plugins/operator/index.ts +11 -0
- package/src/bundled-plugins/operator/operator.ts +76 -0
- package/src/bundled-plugins/scout/index.ts +11 -0
- package/src/bundled-plugins/scout/scout.ts +94 -0
- package/src/channels/router.ts +32 -0
- package/src/cli/init.ts +8 -1
- package/src/cli/oauth-callbacks.ts +64 -34
- package/src/cli/provider.ts +9 -4
- package/src/config/config.ts +73 -16
- package/src/config/index.ts +3 -0
- package/src/config/providers.ts +106 -0
- package/src/cron/index.ts +3 -0
- package/src/cron/schema.ts +20 -0
- package/src/init/dockerfile.ts +44 -5
- package/src/init/models-dev.ts +1 -0
- package/src/permissions/builtins.ts +23 -2
- package/src/plugin/define.ts +2 -0
- package/src/plugin/index.ts +2 -0
- package/src/plugin/types.ts +15 -22
- package/src/run/bundled-plugins.ts +6 -0
- package/src/run/channel-session-factory.ts +19 -0
- package/src/run/index.ts +56 -6
- package/src/server/index.ts +103 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
- package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
- package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
- package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
- package/src/skills/typeclaw-config/SKILL.md +29 -26
- package/typeclaw.schema.json +12 -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
|
+
}
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { definePlugin } from '@/plugin'
|
|
2
2
|
|
|
3
|
-
import {
|
|
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,
|