typeclaw 0.5.1 → 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 (43) 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/config/config.ts +45 -12
  26. package/src/config/index.ts +3 -0
  27. package/src/cron/index.ts +3 -0
  28. package/src/cron/schema.ts +20 -0
  29. package/src/init/dockerfile.ts +44 -5
  30. package/src/permissions/builtins.ts +23 -2
  31. package/src/plugin/define.ts +2 -0
  32. package/src/plugin/index.ts +2 -0
  33. package/src/plugin/types.ts +15 -22
  34. package/src/run/bundled-plugins.ts +6 -0
  35. package/src/run/channel-session-factory.ts +19 -0
  36. package/src/run/index.ts +56 -6
  37. package/src/server/index.ts +103 -0
  38. package/src/skills/typeclaw-claude-code/SKILL.md +273 -0
  39. package/src/skills/typeclaw-claude-code/references/auth-flow.md +135 -0
  40. package/src/skills/typeclaw-claude-code/references/stop-hook.md +99 -0
  41. package/src/skills/typeclaw-claude-code/references/tmux-driving.md +157 -0
  42. package/src/skills/typeclaw-config/SKILL.md +29 -26
  43. package/typeclaw.schema.json +6 -0
package/README.md CHANGED
@@ -140,6 +140,10 @@ bun run format
140
140
 
141
141
  See [AGENTS.md](./AGENTS.md) for the long-form architecture notes — stages, hostd internals, message stream, plugin contracts, and the testing philosophy.
142
142
 
143
+ ## Website
144
+
145
+ The landing page and documentation site at [typeclaw.dev](https://typeclaw.dev) lives in [`docs/`](./docs/). It's a Next.js + Fumadocs app — see [`docs/README.md`](./docs/README.md) for layout and the contributor workflow.
146
+
143
147
  ## License
144
148
 
145
149
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -25,12 +25,14 @@ import type { Stream } from '@/stream'
25
25
  import { getAuthFor } from './auth'
26
26
  import { createCompactionSettingsManager } from './compaction'
27
27
  import { renderGitNudge } from './git-nudge'
28
+ import type { LiveSubagentRegistry } from './live-subagents'
28
29
  import { lookAtTool } from './multimodal'
29
30
  import { resolveBuiltinToolRefs, wrapPluginTool, wrapSystemAgentTool, wrapSystemTool } from './plugin-tools'
30
31
  import { createReloadTool } from './reload-tool'
31
32
  import { loadSelf } from './self'
32
33
  import { SESSION_META_CUSTOM_TYPE, sessionMetaPayload } from './session-meta'
33
34
  import { renderSessionOrigin, type SessionOrigin, type SessionRoleContext } from './session-origin'
35
+ import type { CreateSessionForSubagent, SubagentRegistry } from './subagents'
34
36
  import { DEFAULT_SYSTEM_PROMPT, renderRuntimeBlock, SLIM_SYSTEM_PROMPT } from './system-prompt'
35
37
  import {
36
38
  createBudgetState,
@@ -43,7 +45,10 @@ import { createChannelHistoryTool } from './tools/channel-history'
43
45
  import { createChannelReplyTool } from './tools/channel-reply'
44
46
  import { createChannelSendTool } from './tools/channel-send'
45
47
  import { createRestartTool } from './tools/restart'
48
+ import { createSpawnSubagentTool } from './tools/spawn-subagent'
46
49
  import { createStreamSnapshotTool } from './tools/stream-snapshot'
50
+ import { createSubagentCancelTool } from './tools/subagent-cancel'
51
+ import { createSubagentOutputTool } from './tools/subagent-output'
47
52
  import { webfetchTool } from './tools/webfetch'
48
53
  import { websearchTool } from './tools/websearch'
49
54
 
@@ -153,6 +158,16 @@ export type CreateSessionOptions = {
153
158
  // already seen") provide their own here. See `ToolResultBudget` for the
154
159
  // shared shape.
155
160
  toolResultBudgetMessage?: ToolResultBudget['exhaustedMessage']
161
+ // Orchestration wiring. When all three of `liveSubagentRegistry`,
162
+ // `subagentRegistry`, and `createSessionForSubagent` are present (AND
163
+ // `pluginSubagent` is unset), the session exposes the spawn_subagent,
164
+ // subagent_output, and subagent_cancel tools. Subagent-origin sessions
165
+ // get an empty tool set via the `pluginSubagent` branch; the gate here
166
+ // (omitting these for subagent sessions) is what prevents recursive
167
+ // spawning.
168
+ liveSubagentRegistry?: LiveSubagentRegistry
169
+ subagentRegistry?: SubagentRegistry
170
+ createSessionForSubagent?: CreateSessionForSubagent
156
171
  }
157
172
 
158
173
  export type CreateSessionResult = {
@@ -205,9 +220,16 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
205
220
  const getOrigin: () => SessionOrigin | undefined =
206
221
  options.originRef !== undefined ? () => options.originRef!.current : () => options.origin
207
222
 
208
- const subagentBuiltinTools = options.pluginSubagent?.toolRefs
223
+ // Subagent built-in tool refs are dual-routed (see BUILTIN_TOOL_DEFINITION
224
+ // dual-map in plugin-tools.ts): pi-side coding tools go to `tools:` so they
225
+ // become the strict base set, typeclaw-side web tools go to `customTools:`.
226
+ // The two `tools:` fields below (effective `options.tools` and the resolved
227
+ // subagent pi-side builtins) are mutually exclusive — `options.tools` is only
228
+ // passed by non-subagent callers like multimodal look-at; subagent sessions
229
+ // never set both.
230
+ const resolvedSubagentBuiltins = options.pluginSubagent?.toolRefs
209
231
  ? resolveBuiltinToolRefs(options.pluginSubagent.toolRefs)
210
- : undefined
232
+ : { agentTools: [], toolDefinitions: [] }
211
233
  const pluginCustomTools = options.pluginSubagent
212
234
  ? wrapSubagentCustomTools(options.pluginSubagent, options.plugins, getOrigin)
213
235
  : wrapRegistryTools(options.plugins, getOrigin)
@@ -224,11 +246,9 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
224
246
  : undefined
225
247
  const sessionBudgetState = sessionBudget ? createBudgetState() : undefined
226
248
 
227
- const hookWrappedTools = wrapSystemAgentTools(
228
- options.tools ?? (subagentBuiltinTools as AgentSessionTools | undefined),
229
- options.plugins,
230
- getOrigin,
231
- )
249
+ const effectiveTools =
250
+ options.tools ?? (options.pluginSubagent ? (resolvedSubagentBuiltins.agentTools as AgentSessionTools) : undefined)
251
+ const hookWrappedTools = wrapSystemAgentTools(effectiveTools, options.plugins, getOrigin)
232
252
  const tools =
233
253
  sessionBudget && sessionBudgetState && hookWrappedTools
234
254
  ? (hookWrappedTools.map((t) =>
@@ -265,7 +285,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
265
285
  options.customTools !== undefined
266
286
  ? options.customTools
267
287
  : options.pluginSubagent
268
- ? []
288
+ ? resolvedSubagentBuiltins.toolDefinitions
269
289
  : [
270
290
  websearchTool,
271
291
  webfetchTool,
@@ -282,6 +302,16 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
282
302
  }),
283
303
  ]
284
304
  : []),
305
+ ...buildSubagentOrchestrationTools({
306
+ liveRegistry: options.liveSubagentRegistry,
307
+ registry: options.subagentRegistry,
308
+ createSessionForSubagent: options.createSessionForSubagent,
309
+ agentDir: options.plugins?.agentDir,
310
+ parentSessionId: sessionManager.getSessionId(),
311
+ getOrigin,
312
+ permissions: options.permissions,
313
+ stream: options.stream,
314
+ }),
285
315
  ]
286
316
  const customToolsPreBudget = [...wrapSystemTools(customSystemTools, options.plugins, getOrigin), ...pluginCustomTools]
287
317
  const customTools =
@@ -430,6 +460,48 @@ export function buildChannelTools(
430
460
  return tools
431
461
  }
432
462
 
463
+ export function buildSubagentOrchestrationTools(opts: {
464
+ liveRegistry: LiveSubagentRegistry | undefined
465
+ registry: SubagentRegistry | undefined
466
+ createSessionForSubagent: CreateSessionForSubagent | undefined
467
+ agentDir: string | undefined
468
+ parentSessionId: string
469
+ getOrigin: () => SessionOrigin | undefined
470
+ permissions: PermissionService | undefined
471
+ stream: Stream | undefined
472
+ }): ToolDefinition[] {
473
+ if (
474
+ opts.liveRegistry === undefined ||
475
+ opts.registry === undefined ||
476
+ opts.createSessionForSubagent === undefined ||
477
+ opts.agentDir === undefined
478
+ ) {
479
+ return []
480
+ }
481
+ return [
482
+ createSpawnSubagentTool({
483
+ registry: opts.registry,
484
+ liveRegistry: opts.liveRegistry,
485
+ createSessionForSubagent: opts.createSessionForSubagent,
486
+ agentDir: opts.agentDir,
487
+ parentSessionId: opts.parentSessionId,
488
+ getOrigin: opts.getOrigin,
489
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
490
+ ...(opts.stream ? { stream: opts.stream } : {}),
491
+ }),
492
+ createSubagentOutputTool({
493
+ liveRegistry: opts.liveRegistry,
494
+ getOrigin: opts.getOrigin,
495
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
496
+ }),
497
+ createSubagentCancelTool({
498
+ liveRegistry: opts.liveRegistry,
499
+ getOrigin: opts.getOrigin,
500
+ ...(opts.permissions ? { permissions: opts.permissions } : {}),
501
+ }),
502
+ ]
503
+ }
504
+
433
505
  function wrapRegistryTools(
434
506
  plugins: PluginSessionWiring | undefined,
435
507
  getOrigin: () => SessionOrigin | undefined,
@@ -0,0 +1,215 @@
1
+ import type { AgentSession } from './index'
2
+
3
+ export type SubagentProgressEvent =
4
+ | { kind: 'started'; ts: number }
5
+ | { kind: 'tool'; name: string; ok: boolean; ts: number }
6
+ | { kind: 'message'; preview: string; ts: number }
7
+
8
+ export type SubagentStatus = 'running' | 'completed' | 'failed'
9
+
10
+ export type SubagentCompletion = {
11
+ ok: boolean
12
+ finalMessage?: string
13
+ error?: string
14
+ durationMs: number
15
+ }
16
+
17
+ export type LiveSubagent = {
18
+ taskId: string
19
+ sessionId: string
20
+ subagentName: string
21
+ parentSessionId?: string
22
+ startedAt: number
23
+ status: SubagentStatus
24
+ completion?: SubagentCompletion
25
+ abort: () => Promise<void>
26
+ awaitCompletion: () => Promise<SubagentCompletion>
27
+ }
28
+
29
+ export const MAX_EVENTS_PER_SUBAGENT = 100
30
+ export const MESSAGE_PREVIEW_CHARS = 200
31
+
32
+ type AgentSessionEvent =
33
+ | { type: 'message_update'; assistantMessageEvent: { type: string; delta?: string } }
34
+ | { type: 'message_end'; message: unknown }
35
+ | { type: 'tool_execution_start'; toolCallId: string; toolName: string; args: unknown }
36
+ | { type: 'tool_execution_end'; toolCallId: string; toolName: string; result: unknown; isError: boolean }
37
+ | { type: string }
38
+
39
+ export function coarsen(event: AgentSessionEvent, now: number): SubagentProgressEvent | null {
40
+ if (event.type === 'tool_execution_end') {
41
+ const ev = event as Extract<AgentSessionEvent, { type: 'tool_execution_end' }>
42
+ return { kind: 'tool', name: ev.toolName, ok: !ev.isError, ts: now }
43
+ }
44
+ if (event.type === 'message_end') {
45
+ const ev = event as Extract<AgentSessionEvent, { type: 'message_end' }>
46
+ const preview = extractMessagePreview(ev.message)
47
+ if (preview === null) return null
48
+ return { kind: 'message', preview, ts: now }
49
+ }
50
+ return null
51
+ }
52
+
53
+ function extractMessagePreview(message: unknown): string | null {
54
+ if (message === null || typeof message !== 'object') return null
55
+ const content = (message as { content?: unknown }).content
56
+ if (typeof content === 'string') {
57
+ const trimmed = content.trim()
58
+ return trimmed ? trimmed.slice(0, MESSAGE_PREVIEW_CHARS) : null
59
+ }
60
+ if (Array.isArray(content)) {
61
+ for (const part of content) {
62
+ if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') {
63
+ const text = (part as { text?: unknown }).text
64
+ if (typeof text === 'string') {
65
+ const trimmed = text.trim()
66
+ if (trimmed) return trimmed.slice(0, MESSAGE_PREVIEW_CHARS)
67
+ }
68
+ }
69
+ }
70
+ }
71
+ return null
72
+ }
73
+
74
+ export type StatusSnapshot = {
75
+ taskId: string
76
+ sessionId: string
77
+ subagentName: string
78
+ status: SubagentStatus
79
+ startedAt: number
80
+ elapsedMs: number
81
+ eventsCount: number
82
+ eventsRecent: SubagentProgressEvent[]
83
+ lastActivity: SubagentProgressEvent | null
84
+ statusSummary: string
85
+ completion?: SubagentCompletion
86
+ }
87
+
88
+ export class LiveSubagentRegistry {
89
+ private readonly entries = new Map<string, LiveSubagent>()
90
+ private readonly events = new Map<string, SubagentProgressEvent[]>()
91
+
92
+ register(live: LiveSubagent): void {
93
+ if (this.entries.has(live.taskId)) {
94
+ throw new Error(`task ${live.taskId} already registered`)
95
+ }
96
+ this.entries.set(live.taskId, live)
97
+ this.events.set(live.taskId, [{ kind: 'started', ts: live.startedAt }])
98
+ }
99
+
100
+ unregister(taskId: string): void {
101
+ this.entries.delete(taskId)
102
+ this.events.delete(taskId)
103
+ }
104
+
105
+ get(taskId: string): LiveSubagent | undefined {
106
+ return this.entries.get(taskId)
107
+ }
108
+
109
+ list(filter?: { parentSessionId?: string }): LiveSubagent[] {
110
+ const all = Array.from(this.entries.values())
111
+ if (filter?.parentSessionId === undefined) return all
112
+ return all.filter((e) => e.parentSessionId === filter.parentSessionId)
113
+ }
114
+
115
+ hasLiveForSession(sessionId: string): boolean {
116
+ for (const e of this.entries.values()) {
117
+ if (e.sessionId === sessionId && e.status === 'running') return true
118
+ }
119
+ return false
120
+ }
121
+
122
+ recordEvent(taskId: string, event: SubagentProgressEvent): void {
123
+ const ring = this.events.get(taskId)
124
+ if (ring === undefined) return
125
+ ring.push(event)
126
+ if (ring.length > MAX_EVENTS_PER_SUBAGENT) {
127
+ ring.splice(0, ring.length - MAX_EVENTS_PER_SUBAGENT)
128
+ }
129
+ }
130
+
131
+ recordCompletion(taskId: string, completion: SubagentCompletion): void {
132
+ const entry = this.entries.get(taskId)
133
+ if (entry === undefined) return
134
+ entry.completion = completion
135
+ entry.status = completion.ok ? 'completed' : 'failed'
136
+ }
137
+
138
+ snapshot(taskId: string, now: number = Date.now()): StatusSnapshot | undefined {
139
+ const entry = this.entries.get(taskId)
140
+ if (entry === undefined) return undefined
141
+ const events = this.events.get(taskId) ?? []
142
+ const eventsRecent = events.slice(-10)
143
+ const lastActivity: SubagentProgressEvent | null = events.length > 0 ? (events[events.length - 1] ?? null) : null
144
+ const elapsedMs = (entry.completion ? entry.startedAt + entry.completion.durationMs : now) - entry.startedAt
145
+ return {
146
+ taskId: entry.taskId,
147
+ sessionId: entry.sessionId,
148
+ subagentName: entry.subagentName,
149
+ status: entry.status,
150
+ startedAt: entry.startedAt,
151
+ elapsedMs,
152
+ eventsCount: events.length,
153
+ eventsRecent,
154
+ lastActivity,
155
+ statusSummary: renderStatusSummary(entry, events.length, lastActivity, elapsedMs),
156
+ ...(entry.completion ? { completion: entry.completion } : {}),
157
+ }
158
+ }
159
+
160
+ clear(): void {
161
+ this.entries.clear()
162
+ this.events.clear()
163
+ }
164
+ }
165
+
166
+ function renderStatusSummary(
167
+ entry: LiveSubagent,
168
+ eventsCount: number,
169
+ lastActivity: SubagentProgressEvent | null,
170
+ elapsedMs: number,
171
+ ): string {
172
+ const elapsed = formatElapsed(elapsedMs)
173
+ if (entry.status === 'completed') return `Completed in ${elapsed}.`
174
+ if (entry.status === 'failed') {
175
+ const err = entry.completion?.error ?? 'unknown error'
176
+ return `Failed after ${elapsed}: ${err}`
177
+ }
178
+ const last = describeLastActivity(lastActivity)
179
+ return `Running for ${elapsed}. ${eventsCount} event${eventsCount === 1 ? '' : 's'} so far${last ? `. Last: ${last}` : ''}.`
180
+ }
181
+
182
+ function describeLastActivity(event: SubagentProgressEvent | null): string | null {
183
+ if (event === null) return null
184
+ if (event.kind === 'tool') return `${event.ok ? '' : 'failed '}tool ${event.name}`
185
+ if (event.kind === 'message') {
186
+ const preview = event.preview.length > 60 ? `${event.preview.slice(0, 60)}…` : event.preview
187
+ return `message "${preview}"`
188
+ }
189
+ return null
190
+ }
191
+
192
+ function formatElapsed(ms: number): string {
193
+ if (ms < 1000) return `${ms}ms`
194
+ const totalSec = Math.floor(ms / 1000)
195
+ if (totalSec < 60) return `${totalSec}s`
196
+ const min = Math.floor(totalSec / 60)
197
+ const sec = totalSec % 60
198
+ return `${min}m${sec}s`
199
+ }
200
+
201
+ export function attachProgressCapture(
202
+ registry: LiveSubagentRegistry,
203
+ taskId: string,
204
+ session: Pick<AgentSession, 'subscribe'>,
205
+ ): () => void {
206
+ const unsubscribe = session.subscribe((event: unknown) => {
207
+ const coarsened = coarsen(event as AgentSessionEvent, Date.now())
208
+ if (coarsened !== null) {
209
+ registry.recordEvent(taskId, coarsened)
210
+ }
211
+ })
212
+ return () => {
213
+ if (typeof unsubscribe === 'function') unsubscribe()
214
+ }
215
+ }
@@ -16,6 +16,7 @@ import { z } from 'zod'
16
16
 
17
17
  import {
18
18
  ACKNOWLEDGE_GUARDS,
19
+ checkManagedConfigGuard,
19
20
  checkNonWorkspaceWriteGuard,
20
21
  checkSkillAuthoringGuard,
21
22
  } from '@/bundled-plugins/guard/policy'
@@ -31,15 +32,8 @@ import type {
31
32
  } from '@/plugin'
32
33
 
33
34
  import type { SessionOrigin } from './session-origin'
34
-
35
- type AnyAgentTool =
36
- | typeof piReadTool
37
- | typeof piBashTool
38
- | typeof piEditTool
39
- | typeof piWriteTool
40
- | typeof piGrepTool
41
- | typeof piFindTool
42
- | typeof piLsTool
35
+ import { webfetchTool } from './tools/webfetch'
36
+ import { websearchTool } from './tools/websearch'
43
37
 
44
38
  const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
45
39
  Type.Object(
@@ -50,22 +44,64 @@ const ACKNOWLEDGE_GUARDS_SCHEMA = Type.Optional(
50
44
  ),
51
45
  )
52
46
 
53
- const BUILTIN_TOOL_MAP: Record<string, AnyAgentTool> = {
47
+ // `BuiltinToolRef.__builtinTool` strings are dual-routed when a plugin
48
+ // subagent declares them: pi-coding-agent's own coding tools flow through
49
+ // `createAgentSession({ tools: AgentTool[] })` (which pi treats as a strict
50
+ // base-tool override — exactly the declared subset becomes active), and
51
+ // typeclaw's own web tools flow through `customTools: ToolDefinition[]` (the
52
+ // only path pi accepts for non-pi tool definitions). Routing typeclaw tools
53
+ // through `tools:` silently drops them (pi's `tools` validator rejects shapes
54
+ // it doesn't recognize); routing pi tools through `customTools:` would work
55
+ // but ALSO auto-injects pi's default 4 base tools (read/bash/edit/write),
56
+ // widening every plugin subagent's allowlist beyond what it declared. The
57
+ // dual route is the only shape that gives "subagent gets exactly what it
58
+ // asked for, nothing more." See `src/agent/index.ts` `createSessionWithDispose`
59
+ // for the consumer that splits the resolved arrays into the two pi fields.
60
+ type PiAgentToolName = 'read' | 'bash' | 'edit' | 'write' | 'grep' | 'find' | 'ls'
61
+ type TypeclawToolName = 'websearch' | 'webfetch'
62
+
63
+ const PI_AGENT_TOOL_MAP: Record<PiAgentToolName, AgentTool<any, any>> = {
64
+ read: piReadTool,
54
65
  bash: piBashTool,
55
66
  edit: piEditTool,
56
- find: piFindTool,
67
+ write: piWriteTool,
57
68
  grep: piGrepTool,
69
+ find: piFindTool,
58
70
  ls: piLsTool,
59
- read: piReadTool,
60
- write: piWriteTool,
61
71
  }
62
72
 
63
- export function resolveBuiltinToolRefs(refs: BuiltinToolRef[]): AnyAgentTool[] {
64
- return refs.map((ref) => {
65
- const tool = BUILTIN_TOOL_MAP[ref.__builtinTool]
66
- if (!tool) throw new Error(`unknown built-in tool ref: ${ref.__builtinTool}`)
67
- return tool
68
- })
73
+ const TYPECLAW_TOOL_DEFINITION_MAP: Record<TypeclawToolName, ToolDefinition<any, any, any>> = {
74
+ websearch: websearchTool,
75
+ webfetch: webfetchTool,
76
+ }
77
+
78
+ function isPiAgentToolName(name: string): name is PiAgentToolName {
79
+ return name in PI_AGENT_TOOL_MAP
80
+ }
81
+
82
+ function isTypeclawToolName(name: string): name is TypeclawToolName {
83
+ return name in TYPECLAW_TOOL_DEFINITION_MAP
84
+ }
85
+
86
+ export type ResolvedBuiltinTools = {
87
+ agentTools: AgentTool<any, any>[]
88
+ toolDefinitions: ToolDefinition<any, any, any>[]
89
+ }
90
+
91
+ export function resolveBuiltinToolRefs(refs: BuiltinToolRef[]): ResolvedBuiltinTools {
92
+ const agentTools: AgentTool<any, any>[] = []
93
+ const toolDefinitions: ToolDefinition<any, any, any>[] = []
94
+ for (const ref of refs) {
95
+ const name = ref.__builtinTool
96
+ if (isPiAgentToolName(name)) {
97
+ agentTools.push(PI_AGENT_TOOL_MAP[name])
98
+ } else if (isTypeclawToolName(name)) {
99
+ toolDefinitions.push(TYPECLAW_TOOL_DEFINITION_MAP[name])
100
+ } else {
101
+ throw new Error(`unknown built-in tool ref: ${name}`)
102
+ }
103
+ }
104
+ return { agentTools, toolDefinitions }
69
105
  }
70
106
 
71
107
  export type WrapToolOptions = {
@@ -274,7 +310,11 @@ function errorResult(message: string) {
274
310
  }
275
311
 
276
312
  async function runFinalWriteGuards(options: { tool: string; args: Record<string, unknown>; agentDir: string }) {
277
- return (await checkSkillAuthoringGuard(options)) ?? checkNonWorkspaceWriteGuard(options)
313
+ return (
314
+ (await checkManagedConfigGuard(options)) ??
315
+ (await checkSkillAuthoringGuard(options)) ??
316
+ checkNonWorkspaceWriteGuard(options)
317
+ )
278
318
  }
279
319
 
280
320
  function withGuardAcknowledgements<TParams extends TSchema>(toolName: string, parameters: TParams): TParams {
@@ -226,6 +226,21 @@ function renderChannelOrigin(
226
226
  'reply, your entire final visible response must be exactly `NO_REPLY`.',
227
227
  'Any other visible text without a channel tool call is blocked.',
228
228
  '',
229
+ '**Default to ONE reply per inbound.** Send a second `channel_reply` only',
230
+ 'when the user genuinely benefits from it:',
231
+ '',
232
+ '- the user asked multiple distinct things and each deserves its own',
233
+ ' scoped answer,',
234
+ '- your reply exceeds the platform message limit and must be chunked,',
235
+ '- you need to post an attachment AND commentary on it on Discord (on',
236
+ ' Slack, pass `text` and `attachments` in a single `channel_reply` call),',
237
+ '- you are emitting progress updates during a long-running task and the',
238
+ ' channel would otherwise sit silent.',
239
+ '',
240
+ 'Do NOT send a second reply just to rephrase, restate, summarize, or',
241
+ '"confirm in plain language" something you already said. After the first',
242
+ 'reply lands, end your turn — the user will respond if they want more.',
243
+ '',
229
244
  'To reply in this conversation, call `channel_reply({ text })`. Addressing',
230
245
  `is filled in from this session, including the thread${origin.thread !== null ? '' : ' (none here — this is a channel-root session)'}, so you don't`,
231
246
  'need to copy any of these fields:',