crewly 1.11.6 → 1.12.1

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 (142) hide show
  1. package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
  2. package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
  3. package/config/skills/agent/web-search/SKILL.md +70 -0
  4. package/config/skills/agent/web-search/execute.sh +170 -0
  5. package/config/skills/agent/web-search/skill.json +23 -0
  6. package/dist/backend/backend/src/constants.d.ts +12 -0
  7. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  8. package/dist/backend/backend/src/constants.js +12 -0
  9. package/dist/backend/backend/src/constants.js.map +1 -1
  10. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
  11. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  12. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
  13. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  14. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  15. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
  16. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  17. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
  18. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
  19. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
  20. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
  21. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
  22. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
  23. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
  24. package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
  25. package/dist/backend/backend/src/index.d.ts.map +1 -1
  26. package/dist/backend/backend/src/index.js +36 -2
  27. package/dist/backend/backend/src/index.js.map +1 -1
  28. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
  29. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
  31. package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
  32. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
  33. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +167 -0
  35. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
  36. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
  37. package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
  39. package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
  41. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
  43. package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
  44. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
  45. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
  46. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
  47. package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
  48. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
  49. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
  51. package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
  52. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
  54. package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
  55. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
  57. package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
  58. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
  59. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
  60. package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
  61. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
  62. package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
  63. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
  64. package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
  65. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
  66. package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
  68. package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
  70. package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
  71. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
  72. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  73. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  74. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  75. package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
  76. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  77. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  78. package/dist/backend/backend/src/services/template/template.service.js +67 -2
  79. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  80. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
  81. package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
  82. package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
  83. package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
  84. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
  85. package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
  86. package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
  87. package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
  88. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
  89. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
  91. package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
  92. package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/types/intent-task.types.js +8 -0
  94. package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
  95. package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
  96. package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
  97. package/dist/backend/backend/src/types/v2/request.types.js +1 -0
  98. package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
  99. package/dist/cli/backend/src/constants.d.ts +12 -0
  100. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  101. package/dist/cli/backend/src/constants.js +12 -0
  102. package/dist/cli/backend/src/constants.js.map +1 -1
  103. package/package.json +9 -3
  104. package/packages/crewly-agent/README.md +27 -0
  105. package/packages/crewly-agent/bin/crewly-agent +33 -0
  106. package/packages/crewly-agent/package.json +39 -0
  107. package/packages/crewly-agent/src/cli.ts +168 -0
  108. package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
  109. package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
  110. package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
  111. package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
  112. package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
  113. package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
  114. package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
  115. package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
  116. package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
  117. package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
  118. package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
  119. package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
  120. package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
  121. package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
  122. package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
  123. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
  124. package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
  125. package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
  126. package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
  127. package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
  128. package/packages/crewly-agent/src/runtime/index.ts +38 -0
  129. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
  130. package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
  131. package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
  132. package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
  133. package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
  134. package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
  135. package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
  136. package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
  137. package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
  138. package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
  139. package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
  140. package/packages/crewly-agent/src/runtime/types.ts +637 -0
  141. package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
  142. package/packages/crewly-agent/src/runtime/web-search.tool.ts +140 -0
@@ -0,0 +1,2104 @@
1
+ /**
2
+ * Crewly Agent Tool Registry
3
+ *
4
+ * Defines all AI SDK tools available to the Crewly Agent runtime.
5
+ * Each tool maps to a Crewly REST API endpoint, replacing the bash
6
+ * skill scripts used by PTY-based runtimes.
7
+ *
8
+ * @module services/agent/crewly-agent/tool-registry
9
+ */
10
+
11
+ import { promises as fsPromises } from 'fs';
12
+ import * as childProcess from 'child_process';
13
+ import { homedir } from 'os';
14
+ import * as path from 'path';
15
+ import { z } from 'zod';
16
+ import type { CrewlyApiClient } from './api-client.js';
17
+ import type { ToolDefinition, ToolCallbacks, ToolSensitivity, AuditEntry, ApprovalCheckResult, AuditLogFilters } from './types.js';
18
+ import { KEY_EXTRACTION_BLOCKED_COMMANDS, PromptGuardService } from './prompt-guard.service.js';
19
+ import { EnvIsolationService } from './env-isolation.service.js';
20
+ import { OutputFilterService } from './output-filter.service.js';
21
+ import { createWebSearchTool } from './web-search.tool.js';
22
+
23
+ /** TTL for delegation idle event subscriptions (minutes) */
24
+ const DELEGATION_SUBSCRIPTION_TTL_MINUTES = 120;
25
+
26
+ /** Maximum characters for git diff output */
27
+ const GIT_DIFF_MAX_CHARS = 5000;
28
+
29
+ /** Maximum characters for read_file output to prevent context overflow */
30
+ const READ_FILE_MAX_CHARS = 50_000;
31
+
32
+ /** Maximum lines to return from read_file when no limit is specified */
33
+ const READ_FILE_MAX_LINES = 2000;
34
+
35
+ /**
36
+ * Commands that could kill/signal the parent Crewly server process or
37
+ * cause irreversible system damage. Checked against the raw command
38
+ * string (case-insensitive, word-boundary match via regex).
39
+ */
40
+ const BLOCKED_COMMAND_PATTERNS: RegExp[] = [
41
+ /\bkill\b/i,
42
+ /\bkillall\b/i,
43
+ /\bpkill\b/i,
44
+ /\bshutdown\b/i,
45
+ /\breboot\b/i,
46
+ /\brm\s+-[^\s]*r[^\s]*\s+\/(?!\S)/i, // rm -rf / (root wipe)
47
+ /\bmkfs\b/i,
48
+ /\bdd\s+.*of=\/dev\//i, // dd of=/dev/...
49
+ /\blaunchctl\b/i,
50
+ /\bsystemctl\b/i,
51
+ // Key extraction protection (from prompt-guard.service.ts)
52
+ ...KEY_EXTRACTION_BLOCKED_COMMANDS,
53
+ ];
54
+
55
+ /**
56
+ * Bash commands that require explicit approval before execution.
57
+ * These are dangerous but not catastrophic — they modify remote state,
58
+ * delete files, or interact with container/network infrastructure.
59
+ *
60
+ * Matched against the raw command string (case-insensitive, word-boundary).
61
+ */
62
+ export const APPROVAL_REQUIRED_BASH_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
63
+ { pattern: /\bgit\s+push\b/i, label: 'git push (modifies remote repository)' },
64
+ { pattern: /\bgit\s+push\s+.*--force\b/i, label: 'git push --force (destructive remote rewrite)' },
65
+ { pattern: /\brm\s+/i, label: 'rm (file deletion)' },
66
+ { pattern: /\bdocker\s+(rm|rmi|stop|kill|exec|run|build|push|pull)\b/i, label: 'docker operation (container/image management)' },
67
+ { pattern: /\bcurl\b/i, label: 'curl (network request)' },
68
+ { pattern: /\bwget\b/i, label: 'wget (network download)' },
69
+ { pattern: /\bnpm\s+publish\b/i, label: 'npm publish (package registry push)' },
70
+ { pattern: /\bgit\s+reset\s+--hard\b/i, label: 'git reset --hard (destructive history rewrite)' },
71
+ ];
72
+
73
+ /**
74
+ * Validate a shell command against the blocklist.
75
+ *
76
+ * @param command - Raw shell command string
77
+ * @returns Error message if blocked, or null if allowed
78
+ */
79
+ export function validateBashCommand(command: string): string | null {
80
+ for (const pattern of BLOCKED_COMMAND_PATTERNS) {
81
+ if (pattern.test(command)) {
82
+ return `Command blocked by security policy: matches forbidden pattern ${pattern}`;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Check if a bash command matches any approval-required pattern.
90
+ *
91
+ * @param command - Raw shell command string
92
+ * @returns Matching label if approval is required, or null if safe to proceed
93
+ */
94
+ export function checkBashApprovalRequired(command: string): string | null {
95
+ for (const { pattern, label } of APPROVAL_REQUIRED_BASH_PATTERNS) {
96
+ if (pattern.test(command)) {
97
+ return label;
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Execute a shell command asynchronously in an isolated process group so that
105
+ * signals (SIGTERM, SIGINT) from the child cannot propagate to the Crewly server.
106
+ *
107
+ * Uses spawn (async) to avoid blocking the Node.js event loop. The child
108
+ * process runs in a detached process group so its signals don't reach the parent.
109
+ *
110
+ * @param cmd - Shell command to run
111
+ * @param workDir - Working directory
112
+ * @param timeoutMs - Timeout in milliseconds
113
+ * @returns Object with stdout, stderr, exitCode
114
+ */
115
+ function execIsolatedAsync(cmd: string, workDir: string, timeoutMs: number): Promise<{
116
+ stdout: string;
117
+ stderr: string;
118
+ exitCode: number;
119
+ }> {
120
+ return new Promise((resolve) => {
121
+ const stdoutChunks: Buffer[] = [];
122
+ const stderrChunks: Buffer[] = [];
123
+ let stdoutLen = 0;
124
+ let stderrLen = 0;
125
+ const maxBuffer = 1024 * 1024;
126
+ let finished = false;
127
+
128
+ // Apply environment isolation — strip sensitive vars from child process
129
+ const envIsolation = new EnvIsolationService();
130
+ const safeEnv = envIsolation.createSafeEnv(process.env as Record<string, string>);
131
+ safeEnv.FORCE_COLOR = '0';
132
+
133
+ const child = childProcess.spawn('/bin/sh', ['-c', cmd], {
134
+ cwd: workDir,
135
+ env: safeEnv,
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ detached: true,
138
+ });
139
+
140
+ const timer = setTimeout(() => {
141
+ if (!finished) {
142
+ finished = true;
143
+ // Kill the entire process group
144
+ try { process.kill(-child.pid!, 'SIGTERM'); } catch { /* ignore */ }
145
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
146
+ resolve({
147
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
148
+ stderr: Buffer.concat(stderrChunks).toString('utf-8')
149
+ + `\n[crewly] Process killed by signal: SIGTERM (timeout: ${timeoutMs}ms)`,
150
+ exitCode: 128,
151
+ });
152
+ }
153
+ }, timeoutMs);
154
+
155
+ child.stdout!.on('data', (chunk: Buffer) => {
156
+ if (stdoutLen < maxBuffer) {
157
+ stdoutChunks.push(chunk);
158
+ stdoutLen += chunk.length;
159
+ }
160
+ });
161
+
162
+ child.stderr!.on('data', (chunk: Buffer) => {
163
+ if (stderrLen < maxBuffer) {
164
+ stderrChunks.push(chunk);
165
+ stderrLen += chunk.length;
166
+ }
167
+ });
168
+
169
+ child.on('close', (code, signal) => {
170
+ if (!finished) {
171
+ finished = true;
172
+ clearTimeout(timer);
173
+ const exitCode = code ?? (signal ? 128 : 1);
174
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
175
+ const signalInfo = signal
176
+ ? `\n[crewly] Process killed by signal: ${signal} (timeout: ${timeoutMs}ms)`
177
+ : '';
178
+ resolve({
179
+ stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
180
+ stderr: stderr + signalInfo,
181
+ exitCode,
182
+ });
183
+ }
184
+ });
185
+
186
+ child.on('error', (err) => {
187
+ if (!finished) {
188
+ finished = true;
189
+ clearTimeout(timer);
190
+ resolve({
191
+ stdout: '',
192
+ stderr: err.message,
193
+ exitCode: 1,
194
+ });
195
+ }
196
+ });
197
+
198
+ // Unref so the child doesn't keep the process alive
199
+ child.unref();
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Execute a git command asynchronously without blocking the event loop.
205
+ *
206
+ * @param cmd - Git command to run
207
+ * @param cwd - Working directory
208
+ * @returns stdout string trimmed
209
+ * @throws Error if the command fails
210
+ */
211
+ async function execGitAsync(cmd: string, cwd: string): Promise<string> {
212
+ return new Promise<string>((resolve, reject) => {
213
+ childProcess.exec(cmd, {
214
+ cwd,
215
+ encoding: 'utf8',
216
+ maxBuffer: 10 * 1024 * 1024,
217
+ }, (error, stdout) => {
218
+ if (error) { reject(error); return; }
219
+ resolve(stdout as string);
220
+ });
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Expand ~ and $HOME in a file path to the user's home directory.
226
+ * If the path is relative and a baseDir is provided, resolve against it.
227
+ *
228
+ * @param filePath - Path that may contain ~ or $HOME, or be relative
229
+ * @param baseDir - Optional base directory to resolve relative paths against
230
+ * @returns Resolved absolute path
231
+ */
232
+ function expandPath(filePath: string, baseDir?: string): string {
233
+ const home = homedir();
234
+ if (filePath === '~' || filePath.startsWith('~/')) {
235
+ return home + filePath.slice(1);
236
+ }
237
+ if (filePath.startsWith('$HOME/') || filePath === '$HOME') {
238
+ return home + filePath.slice(5);
239
+ }
240
+ // Resolve relative paths against baseDir (e.g. projectPath)
241
+ if (baseDir && !path.isAbsolute(filePath)) {
242
+ return path.resolve(baseDir, filePath);
243
+ }
244
+ return filePath;
245
+ }
246
+
247
+ /** File extensions recognized as images for multimodal read_file support */
248
+ const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg']);
249
+
250
+ /** MIME type mapping for image extensions */
251
+ const IMAGE_MIME_TYPES: Record<string, string> = {
252
+ png: 'image/png',
253
+ jpg: 'image/jpeg',
254
+ jpeg: 'image/jpeg',
255
+ gif: 'image/gif',
256
+ webp: 'image/webp',
257
+ svg: 'image/svg+xml',
258
+ };
259
+
260
+ // ── Glob / Grep Native Helpers ──────────────────────────────────────────────
261
+
262
+ /** Maximum number of files returned by glob to prevent runaway results */
263
+ const GLOB_MAX_RESULTS = 1000;
264
+
265
+ /** Maximum number of matching lines returned by grep */
266
+ const GREP_MAX_MATCHES = 500;
267
+
268
+ /** Default directories/patterns to ignore during glob/grep traversal */
269
+ const DEFAULT_IGNORE_PATTERNS = [
270
+ 'node_modules', '.git', 'dist', 'build', '.next', '__pycache__',
271
+ '.cache', 'coverage', '.nyc_output', '.turbo', '.parcel-cache',
272
+ ];
273
+
274
+ /**
275
+ * Convert a glob pattern to a RegExp.
276
+ *
277
+ * Supports:
278
+ * - `*` matches any characters except `/`
279
+ * - `**` matches any characters including `/` (recursive)
280
+ * - `?` matches a single character except `/`
281
+ * - `{a,b}` matches either a or b
282
+ * - Character classes `[abc]`
283
+ *
284
+ * @param pattern - Glob pattern string (e.g., "src/**\/*.ts")
285
+ * @returns Compiled RegExp
286
+ */
287
+ export function globToRegExp(pattern: string): RegExp {
288
+ let regexStr = '';
289
+ let i = 0;
290
+ while (i < pattern.length) {
291
+ const ch = pattern[i];
292
+ if (ch === '*' && pattern[i + 1] === '*') {
293
+ // ** — match everything including path separators
294
+ if (pattern[i + 2] === '/') {
295
+ regexStr += '(?:.*/)?';
296
+ i += 3;
297
+ } else {
298
+ regexStr += '.*';
299
+ i += 2;
300
+ }
301
+ } else if (ch === '*') {
302
+ regexStr += '[^/]*';
303
+ i++;
304
+ } else if (ch === '?') {
305
+ regexStr += '[^/]';
306
+ i++;
307
+ } else if (ch === '{') {
308
+ const closeBrace = pattern.indexOf('}', i);
309
+ if (closeBrace === -1) {
310
+ regexStr += '\\{';
311
+ i++;
312
+ } else {
313
+ const alternatives = pattern.slice(i + 1, closeBrace).split(',');
314
+ regexStr += '(?:' + alternatives.map(a => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
315
+ i = closeBrace + 1;
316
+ }
317
+ } else if (ch === '[') {
318
+ const closeBracket = pattern.indexOf(']', i);
319
+ if (closeBracket === -1) {
320
+ regexStr += '\\[';
321
+ i++;
322
+ } else {
323
+ regexStr += pattern.slice(i, closeBracket + 1);
324
+ i = closeBracket + 1;
325
+ }
326
+ } else if ('.+^${}()|[]\\'.includes(ch)) {
327
+ regexStr += '\\' + ch;
328
+ i++;
329
+ } else {
330
+ regexStr += ch;
331
+ i++;
332
+ }
333
+ }
334
+ return new RegExp('^' + regexStr + '$');
335
+ }
336
+
337
+ /**
338
+ * Recursively walk a directory tree, yielding file paths that match
339
+ * the given glob pattern while respecting ignore patterns.
340
+ *
341
+ * Uses a stack-based iterative approach to avoid stack overflow on deep trees.
342
+ * Respects DEFAULT_IGNORE_PATTERNS and user-supplied ignore patterns.
343
+ *
344
+ * @param rootDir - Root directory to start traversal
345
+ * @param patternRegex - Compiled glob regex to test relative paths against
346
+ * @param ignoreSet - Set of directory base names to skip
347
+ * @param maxResults - Maximum number of results to return
348
+ * @returns Array of matching absolute file paths
349
+ */
350
+ export async function walkAndMatch(
351
+ rootDir: string,
352
+ patternRegex: RegExp,
353
+ ignoreSet: Set<string>,
354
+ maxResults: number,
355
+ ): Promise<string[]> {
356
+ const results: string[] = [];
357
+ const stack: string[] = [rootDir];
358
+
359
+ while (stack.length > 0 && results.length < maxResults) {
360
+ const dir = stack.pop()!;
361
+ let entries;
362
+ try {
363
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
364
+ } catch {
365
+ // Permission denied or not a directory — skip
366
+ continue;
367
+ }
368
+
369
+ for (const entry of entries) {
370
+ if (results.length >= maxResults) break;
371
+ if (ignoreSet.has(entry.name)) continue;
372
+
373
+ const fullPath = path.join(dir, entry.name);
374
+ const relativePath = path.relative(rootDir, fullPath);
375
+
376
+ if (entry.isDirectory()) {
377
+ stack.push(fullPath);
378
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
379
+ if (patternRegex.test(relativePath)) {
380
+ results.push(fullPath);
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ return results;
387
+ }
388
+
389
+ /**
390
+ * Search file contents for lines matching a regular expression.
391
+ *
392
+ * Reads the file once, splits into lines, and tests each against the pattern.
393
+ * Returns matching lines with line numbers and optional context lines.
394
+ *
395
+ * @param filePath - Absolute path to the file to search
396
+ * @param regex - Compiled RegExp to test each line
397
+ * @param contextLines - Number of lines before and after each match to include
398
+ * @returns Array of match objects with line number, content, and context
399
+ */
400
+ export async function searchFileContents(
401
+ filePath: string,
402
+ regex: RegExp,
403
+ contextLines: number,
404
+ ): Promise<Array<{ line: number; content: string; context?: string[] }>> {
405
+ const content = await fsPromises.readFile(filePath, 'utf8');
406
+ const lines = content.split('\n');
407
+ const matches: Array<{ line: number; content: string; context?: string[] }> = [];
408
+
409
+ for (let i = 0; i < lines.length; i++) {
410
+ if (regex.test(lines[i])) {
411
+ const match: { line: number; content: string; context?: string[] } = {
412
+ line: i + 1,
413
+ content: lines[i],
414
+ };
415
+ if (contextLines > 0) {
416
+ const start = Math.max(0, i - contextLines);
417
+ const end = Math.min(lines.length, i + contextLines + 1);
418
+ match.context = lines.slice(start, end).map((l, idx) => `${start + idx + 1}\t${l}`);
419
+ }
420
+ matches.push(match);
421
+ }
422
+ }
423
+
424
+ return matches;
425
+ }
426
+
427
+ /**
428
+ * Determine if a file is likely binary by reading its first 8KB and
429
+ * checking for null bytes.
430
+ *
431
+ * @param filePath - Absolute path to the file
432
+ * @returns True if the file appears to be binary
433
+ */
434
+ async function isBinaryFile(filePath: string): Promise<boolean> {
435
+ const fd = await fsPromises.open(filePath, 'r');
436
+ try {
437
+ const buffer = Buffer.alloc(8192);
438
+ const { bytesRead } = await fd.read(buffer, 0, 8192, 0);
439
+ for (let i = 0; i < bytesRead; i++) {
440
+ if (buffer[i] === 0) return true;
441
+ }
442
+ return false;
443
+ } finally {
444
+ await fd.close();
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Strip [NOTIFY]...[/NOTIFY] markers from text.
450
+ *
451
+ * The crewly-agent model sometimes wraps tool arguments in NOTIFY markers
452
+ * intended for the terminal gateway routing layer. When this leaks into
453
+ * reply_slack or other outward-facing tools, the raw markers appear in
454
+ * the Slack message. This function extracts the body content (after the
455
+ * --- separator if present) or the full block content.
456
+ *
457
+ * @param text - Text that may contain NOTIFY markers
458
+ * @returns Clean text with markers stripped and body content extracted
459
+ */
460
+ export function stripNotifyMarkers(text: string): string {
461
+ // Replace each [NOTIFY]...[/NOTIFY] block with its body content
462
+ const cleaned = text.replace(/\[NOTIFY\]([\s\S]*?)\[\/NOTIFY\]/gi, (_match, inner: string) => {
463
+ const trimmed = inner.trim();
464
+ // If there's a --- separator, extract only the body after it
465
+ const separatorIdx = trimmed.indexOf('---');
466
+ if (separatorIdx !== -1) {
467
+ return trimmed.slice(separatorIdx + 3).trim();
468
+ }
469
+ // No separator — return the full inner content
470
+ return trimmed;
471
+ });
472
+ return cleaned.trim();
473
+ }
474
+
475
+ /**
476
+ * Convert GitHub-flavored Markdown to Slack mrkdwn format (#181).
477
+ *
478
+ * Transformations applied (in order):
479
+ * 1. Escape &, <, > to HTML entities (Slack requirement)
480
+ * 2. Convert fenced code blocks (```lang\n...\n```) to Slack code blocks
481
+ * 3. Convert **bold** to *bold* (Slack uses single asterisks)
482
+ * 4. Convert [text](url) links to <url|text> (Slack link format)
483
+ *
484
+ * @param text - Markdown-formatted text
485
+ * @returns Text formatted for Slack mrkdwn
486
+ */
487
+ export function convertMarkdownToSlackMrkdwn(text: string): string {
488
+ // 1. Escape special Slack characters (must happen first, before adding <> for links)
489
+ let result = text
490
+ .replace(/&/g, '&amp;')
491
+ .replace(/</g, '&lt;')
492
+ .replace(/>/g, '&gt;');
493
+
494
+ // 2. Convert fenced code blocks: ```lang\n...\n``` → ```\n...\n```
495
+ // Slack doesn't support language hints in code blocks, so strip them
496
+ result = result.replace(/```\w*\n/g, '```\n');
497
+
498
+ // 3. Convert **bold** to *bold* (Slack bold is single asterisk)
499
+ // Must not touch single * (already italic in both formats)
500
+ result = result.replace(/\*\*(.+?)\*\*/g, '*$1*');
501
+
502
+ // 4. Convert [text](url) links to <url|text>
503
+ // The url may contain escaped &gt; from step 1, but real URLs won't have those
504
+ result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
505
+
506
+ return result;
507
+ }
508
+
509
+ /**
510
+ * Sensitivity classification for each tool.
511
+ * Used by the audit trail to classify tool invocations.
512
+ */
513
+ export const TOOL_SENSITIVITY: Record<string, ToolSensitivity> = {
514
+ // Safe: read-only, informational
515
+ get_agent_status: 'safe',
516
+ get_team_status: 'safe',
517
+ get_agent_logs: 'safe',
518
+ heartbeat: 'safe',
519
+ get_tasks: 'safe',
520
+ get_project_overview: 'safe',
521
+ read_file: 'safe',
522
+ glob: 'safe',
523
+ grep: 'safe',
524
+ recall_memory: 'safe',
525
+ subscribe_event: 'safe',
526
+ get_audit_log: 'safe',
527
+ get_scheduled_checks: 'safe',
528
+ compact_memory: 'safe',
529
+ get_context_budget: 'safe',
530
+ // Sensitive: modify state, communicate externally
531
+ delegate_task: 'sensitive',
532
+ send_message: 'sensitive',
533
+ reply_slack: 'sensitive',
534
+ schedule_check: 'sensitive',
535
+ cancel_schedule: 'sensitive',
536
+ register_self: 'sensitive',
537
+ report_status: 'sensitive',
538
+ remember: 'sensitive',
539
+ complete_task: 'sensitive',
540
+ broadcast: 'sensitive',
541
+ // Destructive: irreversible or high-impact operations
542
+ start_agent: 'destructive',
543
+ stop_agent: 'destructive',
544
+ handle_agent_failure: 'destructive',
545
+ edit_file: 'destructive',
546
+ write_file: 'destructive',
547
+ // Git tools (#176)
548
+ git_status: 'safe',
549
+ git_diff: 'safe',
550
+ git_commit: 'sensitive',
551
+ // Shell execution (#176)
552
+ bash_exec: 'destructive',
553
+ // Task handoff (F12)
554
+ handoff_task: 'sensitive',
555
+ // Cloud-backed web search
556
+ web_search: 'safe',
557
+ };
558
+
559
+ /**
560
+ * Wrap a tool's execute function with audit logging.
561
+ *
562
+ * Records tool name, sensitivity, args, result status, and duration.
563
+ * Sanitizes arguments to avoid logging secrets.
564
+ *
565
+ * @param toolName - Name of the tool being wrapped
566
+ * @param executeFn - Original execute function
567
+ * @param callbacks - Callbacks object with onAuditLog handler
568
+ * @returns Wrapped execute function
569
+ */
570
+ /**
571
+ * Wrap a tool's execute function with security policy enforcement and audit logging.
572
+ *
573
+ * Checks blocked tools and approval requirements before execution.
574
+ * Records tool name, sensitivity, args, result status, and duration.
575
+ * Sanitizes arguments to avoid logging secrets.
576
+ *
577
+ * @param toolName - Name of the tool being wrapped
578
+ * @param executeFn - Original execute function
579
+ * @param callbacks - Callbacks object with onAuditLog and onCheckApproval handlers
580
+ * @returns Wrapped execute function
581
+ */
582
+ function wrapWithAudit(
583
+ toolName: string,
584
+ executeFn: (args: Record<string, unknown>) => Promise<unknown>,
585
+ callbacks?: ToolCallbacks,
586
+ ): (args: Record<string, unknown>) => Promise<unknown> {
587
+ if (!callbacks?.onAuditLog && !callbacks?.onCheckApproval) return executeFn;
588
+
589
+ return async (args: Record<string, unknown>): Promise<unknown> => {
590
+ const start = Date.now();
591
+ const sensitivity = TOOL_SENSITIVITY[toolName] || 'safe';
592
+
593
+ // Sanitize args: redact potential secrets
594
+ const sanitizedArgs = sanitizeArgs(args);
595
+
596
+ // Check security policy before execution
597
+ if (callbacks?.onCheckApproval) {
598
+ const approvalResult: ApprovalCheckResult = callbacks.onCheckApproval(toolName, sensitivity);
599
+ if (!approvalResult.allowed) {
600
+ const entry: AuditEntry = {
601
+ timestamp: new Date().toISOString(),
602
+ toolName,
603
+ sensitivity,
604
+ args: sanitizedArgs,
605
+ success: false,
606
+ error: approvalResult.reason || 'Blocked by security policy',
607
+ durationMs: Date.now() - start,
608
+ };
609
+ if (callbacks?.onAuditLog) callbacks.onAuditLog(entry);
610
+ // Enqueue for approval if not hard-blocked
611
+ let approvalId: string | undefined;
612
+ if (!approvalResult.blocked && callbacks?.onEnqueueApproval) {
613
+ const enqueued = callbacks.onEnqueueApproval(toolName, sensitivity, sanitizedArgs);
614
+ approvalId = enqueued.approvalId;
615
+ }
616
+
617
+ return {
618
+ success: false,
619
+ blocked: approvalResult.blocked ?? false,
620
+ requiresApproval: !approvalResult.blocked,
621
+ approvalId,
622
+ error: approvalResult.reason || 'Tool execution denied by security policy',
623
+ };
624
+ }
625
+ }
626
+
627
+ // No audit logger — just execute
628
+ if (!callbacks?.onAuditLog) return executeFn(args);
629
+
630
+ try {
631
+ const result = await executeFn(args);
632
+ const success = result != null && typeof result === 'object'
633
+ ? (result as Record<string, unknown>).success !== false
634
+ : true;
635
+
636
+ const entry: AuditEntry = {
637
+ timestamp: new Date().toISOString(),
638
+ toolName,
639
+ sensitivity,
640
+ args: sanitizedArgs,
641
+ success,
642
+ durationMs: Date.now() - start,
643
+ };
644
+ if (!success && typeof result === 'object' && result !== null) {
645
+ entry.error = String((result as Record<string, unknown>).error || 'unknown');
646
+ }
647
+ callbacks.onAuditLog!(entry);
648
+ return result;
649
+ } catch (error) {
650
+ const entry: AuditEntry = {
651
+ timestamp: new Date().toISOString(),
652
+ toolName,
653
+ sensitivity,
654
+ args: sanitizedArgs,
655
+ success: false,
656
+ error: error instanceof Error ? error.message : String(error),
657
+ durationMs: Date.now() - start,
658
+ };
659
+ callbacks.onAuditLog!(entry);
660
+ throw error;
661
+ }
662
+ };
663
+ }
664
+
665
+ /**
666
+ * Sanitize tool arguments for audit logging.
667
+ * Redacts values for keys that may contain secrets.
668
+ *
669
+ * @param args - Raw tool arguments
670
+ * @returns Sanitized copy safe for logging
671
+ */
672
+ function sanitizeArgs(args: Record<string, unknown>): Record<string, unknown> {
673
+ const sensitiveKeys = ['password', 'secret', 'token', 'apiKey', 'api_key', 'authorization'];
674
+ const result: Record<string, unknown> = {};
675
+ for (const [key, value] of Object.entries(args)) {
676
+ if (sensitiveKeys.some(sk => key.toLowerCase().includes(sk))) {
677
+ result[key] = '[REDACTED]';
678
+ } else if (typeof value === 'string' && value.length > 2000) {
679
+ result[key] = value.substring(0, 2000) + '...[truncated]';
680
+ } else {
681
+ result[key] = value;
682
+ }
683
+ }
684
+ return result;
685
+ }
686
+
687
+ /**
688
+ * Create the complete set of AI SDK tools for the Crewly Agent.
689
+ *
690
+ * Each tool's execute function calls the Crewly REST API directly
691
+ * via the provided API client, bypassing the bash script layer.
692
+ * Tools are wrapped with audit logging when callbacks are provided.
693
+ *
694
+ * @param client - API client instance for making REST calls
695
+ * @param sessionName - Agent session name for identity context
696
+ * @param projectPath - Optional project path for auto-injection
697
+ * @param callbacks - Optional callbacks for compaction and audit logging
698
+ * @returns Object of named tools ready to pass to generateText
699
+ */
700
+ export function createTools(client: CrewlyApiClient, sessionName: string, projectPath?: string, callbacks?: ToolCallbacks, conversationId?: string, slackContext?: { channelId: string; threadTs?: string }, mcpTools?: Record<string, ToolDefinition>): Record<string, ToolDefinition> {
701
+ // Slack rate-limiting state: throttle messages within a 3-second window
702
+ let lastSlackSendMs = 0;
703
+ const SLACK_THROTTLE_MS = 3000;
704
+
705
+ // Slack dedup: ring buffer of recent messages to prevent duplicate sends
706
+ const recentSlackMessages: string[] = [];
707
+ const SLACK_DEDUP_WINDOW = 10;
708
+
709
+ const rawTools: Record<string, ToolDefinition> = {
710
+ // ===== Core Orchestration Tools =====
711
+
712
+ delegate_task: {
713
+ description: 'Delegate a task to a worker agent. Sends the task message and creates a task tracking file. Enforces TL hierarchy — if target has a parent TL, routes through TL instead.',
714
+ inputSchema: z.object({
715
+ to: z.string().describe('Target agent session name'),
716
+ task: z.string().describe('Task description and instructions'),
717
+ priority: z.enum(['low', 'normal', 'high', 'critical']).default('normal'),
718
+ context: z.string().optional().describe('Additional context for the task'),
719
+ projectPath: z.string().optional().describe('Project path for task tracking'),
720
+ }),
721
+ execute: async ({ to, task, priority, context, projectPath }) => {
722
+ // #164: TL hierarchy validation — enforce that the caller has delegation authority
723
+ const teamsResult = await client.get('/teams').catch(() => null);
724
+ if (teamsResult?.success && Array.isArray(teamsResult.data)) {
725
+ type MemberInfo = { id: string; sessionName: string; parentMemberId?: string; canDelegate?: boolean; subordinateIds?: string[]; agentStatus?: string; workingStatus?: string };
726
+ type TeamInfo = { members?: MemberInfo[] };
727
+ for (const team of teamsResult.data as TeamInfo[]) {
728
+ const targetMember = team.members?.find(m => m.sessionName === (to as string));
729
+ const callerMember = team.members?.find(m => m.sessionName === sessionName);
730
+
731
+ if (targetMember?.parentMemberId) {
732
+ const tlMember = team.members?.find(m => m.id === targetMember.parentMemberId);
733
+ // If the caller is NOT the TL, redirect through TL
734
+ if (tlMember && tlMember.sessionName !== sessionName) {
735
+ return {
736
+ success: false,
737
+ error: `Hierarchy violation: ${to} reports to TL ${tlMember.sessionName} (${tlMember.id}). You must delegate through the TL, not directly. Use send_message to ask ${tlMember.sessionName} to delegate this task to ${to}.`,
738
+ redirectTo: tlMember.sessionName,
739
+ hint: 'Use send_message to the TL with the task details, and ask them to delegate to the worker.',
740
+ };
741
+ }
742
+ }
743
+
744
+ // Verify caller has canDelegate permission when delegating to a subordinate
745
+ if (callerMember && targetMember && callerMember.canDelegate === false) {
746
+ return {
747
+ success: false,
748
+ error: `You (${sessionName}) do not have delegation permission (canDelegate: false). Ask your TL to delegate this task.`,
749
+ };
750
+ }
751
+
752
+ // Task stacking prevention — check if target already has in-progress tasks
753
+ if (targetMember && targetMember.workingStatus === 'in_progress') {
754
+ return {
755
+ success: false,
756
+ error: `Agent ${to} is already working on a task (workingStatus: in_progress). Wait for current task to complete before delegating a new one.`,
757
+ agentStatus: targetMember.agentStatus,
758
+ workingStatus: targetMember.workingStatus,
759
+ };
760
+ }
761
+ }
762
+ }
763
+
764
+ const taskMessage = buildTaskMessage(to as string, task as string, priority as string, context as string | undefined, projectPath as string | undefined);
765
+
766
+ // Deliver the task message
767
+ const deliverResult = await client.post(`/terminal/${to}/deliver`, {
768
+ message: taskMessage,
769
+ waitForReady: true,
770
+ waitTimeout: 15000,
771
+ });
772
+
773
+ if (!deliverResult.success) {
774
+ // Fallback: force delivery
775
+ const forceResult = await client.post(`/terminal/${to}/deliver`, {
776
+ message: taskMessage,
777
+ force: true,
778
+ });
779
+ if (!forceResult.success) {
780
+ return { success: false, error: `Failed to deliver task to ${to}: ${forceResult.error}` };
781
+ }
782
+ }
783
+
784
+ // Create task tracking entry as a V3 WorkItem.
785
+ // Migrated from v1 `/task-management/create` per spec
786
+ // 2026-05-06-task-management-v1-deprecation.md.
787
+ let taskId: string | undefined;
788
+ if (projectPath) {
789
+ const priorityNum =
790
+ priority === 'critical' ? 1 :
791
+ priority === 'high' ? 2 :
792
+ priority === 'low' ? 4 :
793
+ 3;
794
+ const wiId = `task-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
795
+ const createResult = await client.post('/task-pool/add', {
796
+ workItem: {
797
+ id: wiId,
798
+ title: typeof task === 'string' ? task.slice(0, 200) : 'Delegated task',
799
+ type: 'delegate',
800
+ owner: typeof to === 'string' ? to : 'system',
801
+ priority: priorityNum,
802
+ status: 'queued',
803
+ target: to,
804
+ briefMarkdown: typeof task === 'string' ? task : undefined,
805
+ metadata: { projectPath, milestone: 'delegated' },
806
+ },
807
+ });
808
+ if (createResult.success && createResult.data) {
809
+ const data = createResult.data as Record<string, unknown>;
810
+ taskId = (data.id as string | undefined) ?? wiId;
811
+ }
812
+ }
813
+
814
+ // Subscribe to idle event for monitoring (with dedup check)
815
+ const existingSubs = await client.get(`/events/subscriptions?subscriberSession=${encodeURIComponent(sessionName)}`).catch(() => null);
816
+ const alreadySubscribed = existingSubs?.success && Array.isArray(existingSubs.data) &&
817
+ (existingSubs.data as Array<{ eventType: string; filter?: Record<string, string> }>).some(
818
+ sub => sub.eventType === 'agent:idle' && sub.filter?.sessionName === (to as string),
819
+ );
820
+ if (!alreadySubscribed) {
821
+ await client.post('/events/subscribe', {
822
+ eventType: 'agent:idle',
823
+ filter: { sessionName: to },
824
+ subscriberSession: sessionName,
825
+ oneShot: true,
826
+ ttlMinutes: DELEGATION_SUBSCRIPTION_TTL_MINUTES,
827
+ });
828
+ }
829
+
830
+ return { success: true, delegatedTo: to, taskId, conversationId: conversationId || undefined };
831
+ },
832
+ },
833
+
834
+ send_message: {
835
+ description: 'Send a message to an agent terminal session.',
836
+ inputSchema: z.object({
837
+ sessionName: z.string().describe('Target agent session name'),
838
+ message: z.string().describe('Message content'),
839
+ force: z.boolean().default(false).describe('Force send without waiting for ready state'),
840
+ }),
841
+ execute: async ({ sessionName: target, message, force }) => {
842
+ const body = force
843
+ ? { message, force: true }
844
+ : { message, waitForReady: true, waitTimeout: 120000 };
845
+ const result = await client.post(`/terminal/${target}/deliver`, body);
846
+ return result.success
847
+ ? { success: true, delivered: true }
848
+ : { success: false, error: result.error };
849
+ },
850
+ },
851
+
852
+ get_agent_status: {
853
+ description: 'Get the current status of a specific agent by session name.',
854
+ inputSchema: z.object({
855
+ sessionName: z.string().describe('Agent session name to check'),
856
+ }),
857
+ execute: async ({ sessionName: target }) => {
858
+ // Record manual check so redundant scheduled checks are suppressed (fire-and-forget)
859
+ Promise.resolve(client.post('/schedule/manual-check', { agentSession: target })).catch(() => {});
860
+
861
+ const result = await client.get('/teams');
862
+ if (!result.success) return { error: result.error };
863
+
864
+ const teams = result.data as Record<string, unknown>[];
865
+ const teamArray = Array.isArray(teams) ? teams : [teams];
866
+ for (const team of teamArray) {
867
+ const members = (team as Record<string, unknown>).members as Array<Record<string, unknown>> | undefined;
868
+ if (!members) continue;
869
+ const agent = members.find(m => m.sessionName === target || m.name === target);
870
+ if (agent) return agent;
871
+ }
872
+ return { error: 'Agent not found', sessionName: target };
873
+ },
874
+ },
875
+
876
+ get_team_status: {
877
+ description: 'Get status of all teams and their agents.',
878
+ inputSchema: z.object({}),
879
+ execute: async () => {
880
+ const result = await client.get('/teams');
881
+ return result.success ? result.data : { error: result.error };
882
+ },
883
+ },
884
+
885
+ get_agent_logs: {
886
+ description: 'Get recent terminal output from an agent session.',
887
+ inputSchema: z.object({
888
+ sessionName: z.string().describe('Agent session name'),
889
+ lines: z.number().default(50).describe('Number of lines to retrieve'),
890
+ }),
891
+ execute: async ({ sessionName: target, lines }) => {
892
+ // Record manual check so redundant scheduled checks are suppressed (fire-and-forget)
893
+ Promise.resolve(client.post('/schedule/manual-check', { agentSession: target })).catch(() => {});
894
+
895
+ const result = await client.get(`/terminal/${target}/output?lines=${lines}`);
896
+ return result.success ? result.data : { error: result.error };
897
+ },
898
+ },
899
+
900
+ reply_slack: {
901
+ description: 'Send a text reply or upload an image to a Slack channel or thread. If channelId is omitted, uses the current Slack thread context. For images, provide imagePath (absolute path on disk) — the image is uploaded via Slack file upload API. Messages sent within 3 seconds are batched to avoid spam.',
902
+ inputSchema: z.object({
903
+ channelId: z.string().optional().describe('Slack channel ID (auto-filled from current thread context if omitted)'),
904
+ text: z.string().describe('Message text (used as comment when uploading an image)'),
905
+ threadTs: z.string().optional().describe('Thread timestamp for replies (auto-filled from current thread context if omitted)'),
906
+ imagePath: z.string().optional().describe('Absolute path to an image file to upload (png, jpg, gif, webp). When provided, uploads the image instead of sending a text message.'),
907
+ }),
908
+ execute: async ({ channelId, text, threadTs, imagePath }) => {
909
+ let cleanText = stripNotifyMarkers(text as string);
910
+ // #181: Convert markdown to Slack mrkdwn format
911
+ cleanText = convertMarkdownToSlackMrkdwn(cleanText);
912
+
913
+ // Auto-prefix with agent display name so Slack messages are attributable
914
+ // Session format: "crewly-{team}-{name}-{uuid}" → extract {name}
915
+ // Fallback: "crewly-orc" → "orc"
916
+ if (sessionName && !cleanText.startsWith('[')) {
917
+ const parts = sessionName.split('-');
918
+ // For "crewly-product-sam-217bfbbf": parts[2] = "sam"
919
+ // For "crewly-orc": parts[1] = "orc"
920
+ const namePart = parts.length >= 3 ? parts[2] : parts[parts.length - 1];
921
+ const capitalized = namePart.charAt(0).toUpperCase() + namePart.slice(1);
922
+ cleanText = `[${capitalized}] ${cleanText}`;
923
+ }
924
+ const resolvedChannelId = (channelId as string | undefined) || slackContext?.channelId;
925
+ const resolvedThreadTs = (threadTs as string | undefined) || slackContext?.threadTs;
926
+ if (!resolvedChannelId) {
927
+ return { success: false, error: 'No channelId provided and no Slack thread context available. Use reply_slack with an explicit channelId.' };
928
+ }
929
+
930
+ // ── Image upload path ──
931
+ if (imagePath) {
932
+ const uploadBody: Record<string, string> = {
933
+ channelId: resolvedChannelId,
934
+ filePath: imagePath as string,
935
+ initialComment: cleanText,
936
+ };
937
+ if (resolvedThreadTs) uploadBody.threadTs = resolvedThreadTs;
938
+ const result = await client.post('/slack/upload-image', uploadBody);
939
+ if (result.success) {
940
+ lastSlackSendMs = Date.now();
941
+ }
942
+ return result.success
943
+ ? { success: true, sent: true, uploaded: true, filePath: imagePath }
944
+ : { success: false, error: result.error };
945
+ }
946
+
947
+ // ── Text message path ──
948
+ // Dedup: skip if this exact message was recently sent
949
+ if (recentSlackMessages.includes(cleanText)) {
950
+ return { success: true, sent: false, deduplicated: true, reason: 'Message already sent recently' };
951
+ }
952
+
953
+ // Issue 3: Rate-limit Slack messages — throttle within a 3s window
954
+ const now = Date.now();
955
+ if (now - lastSlackSendMs < SLACK_THROTTLE_MS) {
956
+ return { success: true, sent: false, throttled: true, retryAfterMs: SLACK_THROTTLE_MS - (now - lastSlackSendMs) };
957
+ }
958
+
959
+ const body: Record<string, string> = { channelId: resolvedChannelId, text: cleanText };
960
+ if (resolvedThreadTs) body.threadTs = resolvedThreadTs;
961
+ if (conversationId) body.conversationId = conversationId;
962
+ if (sessionName) body.senderSessionName = sessionName;
963
+ const result = await client.post('/slack/send', body);
964
+ if (result.success) {
965
+ lastSlackSendMs = Date.now();
966
+ // Track for dedup
967
+ recentSlackMessages.push(cleanText);
968
+ if (recentSlackMessages.length > SLACK_DEDUP_WINDOW) {
969
+ recentSlackMessages.shift();
970
+ }
971
+ }
972
+ return result.success
973
+ ? { success: true, sent: true }
974
+ : { success: false, error: result.error };
975
+ },
976
+ },
977
+
978
+ // ===== Scheduling Tools =====
979
+
980
+ schedule_check: {
981
+ description: 'Schedule a future check-in reminder. Include taskId to auto-cancel when the linked task completes. Deduplicates: skips if a similar check already exists for the same target.',
982
+ inputSchema: z.object({
983
+ minutes: z.number().describe('Minutes from now'),
984
+ message: z.string().describe('Reminder message'),
985
+ target: z.string().optional().describe('Target session (defaults to self)'),
986
+ recurring: z.boolean().default(false),
987
+ maxOccurrences: z.number().optional(),
988
+ taskId: z.string().optional().describe('Link to a task ID — recurring check auto-cancels when this task completes'),
989
+ }),
990
+ execute: async ({ minutes, message, target, recurring, maxOccurrences, taskId }) => {
991
+ const targetSession = target || sessionName;
992
+
993
+ // Issue 2: Dedup check — list existing scheduled checks for this target
994
+ const existingResult = await client.get(`/schedule?session=${encodeURIComponent(targetSession as string)}`).catch(() => null);
995
+ if (existingResult?.success && Array.isArray(existingResult.data)) {
996
+ type CheckInfo = { targetSession: string; message: string; taskId?: string };
997
+ const msgPrefix = (message as string).slice(0, 50); // Match on message prefix
998
+ const isDuplicate = (existingResult.data as CheckInfo[]).some(check =>
999
+ check.targetSession === targetSession &&
1000
+ (check.message?.startsWith(msgPrefix) || (taskId && check.taskId === taskId))
1001
+ );
1002
+ if (isDuplicate) {
1003
+ return { success: true, status: 'already_scheduled', targetSession, message };
1004
+ }
1005
+ }
1006
+
1007
+ const body: Record<string, unknown> = {
1008
+ targetSession,
1009
+ minutes,
1010
+ message,
1011
+ };
1012
+ if (recurring) {
1013
+ body.isRecurring = true;
1014
+ body.intervalMinutes = minutes;
1015
+ }
1016
+ if (maxOccurrences) body.maxOccurrences = maxOccurrences;
1017
+ if (taskId) body.taskId = taskId;
1018
+ const result = await client.post('/schedule', body);
1019
+ return result.success ? result.data : { error: result.error };
1020
+ },
1021
+ },
1022
+
1023
+ cancel_schedule: {
1024
+ description: 'Cancel a scheduled check-in by ID.',
1025
+ inputSchema: z.object({
1026
+ checkId: z.string().describe('Schedule ID to cancel'),
1027
+ }),
1028
+ execute: async ({ checkId }) => {
1029
+ const result = await client.delete(`/schedule/${checkId}`);
1030
+ return result.success ? { success: true } : { error: result.error };
1031
+ },
1032
+ },
1033
+
1034
+ get_scheduled_checks: {
1035
+ description: 'List all active scheduled checks. Use to identify stale or completed checks that should be cancelled.',
1036
+ inputSchema: z.object({
1037
+ session: z.string().optional().describe('Filter by target session name'),
1038
+ }),
1039
+ execute: async ({ session }) => {
1040
+ const endpoint = session
1041
+ ? `/schedule?session=${encodeURIComponent(session as string)}`
1042
+ : '/schedule';
1043
+ const result = await client.get(endpoint);
1044
+ return result.success ? result.data : { error: result.error };
1045
+ },
1046
+ },
1047
+
1048
+ // ===== Agent Lifecycle Tools =====
1049
+
1050
+ start_agent: {
1051
+ description: 'Start a specific agent within a team. Safely skips if the agent is already active.',
1052
+ inputSchema: z.object({
1053
+ teamId: z.string().describe('Team UUID'),
1054
+ memberId: z.string().describe('Member UUID'),
1055
+ }),
1056
+ execute: async ({ teamId, memberId }) => {
1057
+ // Pre-check: if the agent is already active, return immediately to avoid
1058
+ // timeout and session interruption from redundant start calls
1059
+ const teamResult = await client.get(`/teams/${teamId}`).catch(() => null);
1060
+ if (teamResult?.success && teamResult.data) {
1061
+ const team = teamResult.data as { members?: Array<{ id: string; agentStatus?: string; sessionName?: string; name?: string }> };
1062
+ const member = team.members?.find(m => m.id === memberId);
1063
+ if (member && member.agentStatus === 'active' && member.sessionName) {
1064
+ return {
1065
+ success: true,
1066
+ memberName: member.name,
1067
+ memberId: member.id,
1068
+ sessionName: member.sessionName,
1069
+ status: 'already_active',
1070
+ };
1071
+ }
1072
+ }
1073
+
1074
+ const result = await client.post(`/teams/${teamId}/members/${memberId}/start`, {});
1075
+ return result.success ? result.data : { error: result.error };
1076
+ },
1077
+ },
1078
+
1079
+ stop_agent: {
1080
+ description: 'Stop a specific agent within a team.',
1081
+ inputSchema: z.object({
1082
+ teamId: z.string().describe('Team UUID'),
1083
+ memberId: z.string().describe('Member UUID'),
1084
+ }),
1085
+ execute: async ({ teamId, memberId }) => {
1086
+ const result = await client.post(`/teams/${teamId}/members/${memberId}/stop`, {});
1087
+ return result.success ? result.data : { error: result.error };
1088
+ },
1089
+ },
1090
+
1091
+ // ===== Event Tools =====
1092
+
1093
+ subscribe_event: {
1094
+ description: 'Subscribe to agent lifecycle events (e.g., agent:idle, agent:busy). Deduplicates: skips if an identical subscription already exists.',
1095
+ inputSchema: z.object({
1096
+ eventType: z.string().describe('Event type (e.g., "agent:idle")'),
1097
+ filter: z.record(z.string()).optional().describe('Event filter criteria'),
1098
+ oneShot: z.boolean().default(true),
1099
+ }),
1100
+ execute: async ({ eventType, filter, oneShot }) => {
1101
+ // Issue 2: Dedup check — list existing subscriptions and skip if duplicate
1102
+ const existingResult = await client.get(`/events/subscriptions?subscriberSession=${encodeURIComponent(sessionName)}`).catch(() => null);
1103
+ if (existingResult?.success && Array.isArray(existingResult.data)) {
1104
+ type SubInfo = { eventType: string; filter?: Record<string, string>; subscriberSession: string };
1105
+ const filterStr = JSON.stringify(filter || {});
1106
+ const isDuplicate = (existingResult.data as SubInfo[]).some(sub =>
1107
+ sub.eventType === eventType &&
1108
+ sub.subscriberSession === sessionName &&
1109
+ JSON.stringify(sub.filter || {}) === filterStr
1110
+ );
1111
+ if (isDuplicate) {
1112
+ return { success: true, status: 'already_subscribed', eventType, filter };
1113
+ }
1114
+ }
1115
+
1116
+ const result = await client.post('/events/subscribe', {
1117
+ eventType,
1118
+ filter: filter || {},
1119
+ subscriberSession: sessionName,
1120
+ oneShot,
1121
+ });
1122
+ return result.success ? result.data : { error: result.error };
1123
+ },
1124
+ },
1125
+
1126
+ // ===== Memory Tools =====
1127
+
1128
+ recall_memory: {
1129
+ description: 'Retrieve relevant knowledge from agent/project memory.',
1130
+ inputSchema: z.object({
1131
+ context: z.string().describe('What to search for'),
1132
+ scope: z.enum(['agent', 'project', 'both']).default('both'),
1133
+ projectPath: z.string().optional().describe('Project path (auto-injected if omitted)'),
1134
+ }),
1135
+ execute: async ({ context, scope, projectPath: pp }) => {
1136
+ const body: Record<string, unknown> = {
1137
+ agentId: sessionName,
1138
+ context,
1139
+ scope,
1140
+ };
1141
+ // Auto-inject projectPath when scope requires it
1142
+ const resolvedProjectPath = (pp as string | undefined) || projectPath;
1143
+ if (resolvedProjectPath && (scope === 'project' || scope === 'both')) {
1144
+ body.projectPath = resolvedProjectPath;
1145
+ }
1146
+ const result = await client.post('/memory/recall', body);
1147
+ return result.success ? result.data : { error: result.error };
1148
+ },
1149
+ },
1150
+
1151
+ remember: {
1152
+ description: 'Store knowledge for future reference.',
1153
+ inputSchema: z.object({
1154
+ content: z.string().describe('Knowledge to store'),
1155
+ category: z.enum(['pattern', 'decision', 'gotcha', 'fact', 'preference']),
1156
+ scope: z.enum(['agent', 'project']).default('project'),
1157
+ projectPath: z.string().optional().describe('Project path (auto-injected if omitted)'),
1158
+ }),
1159
+ execute: async ({ content, category, scope, projectPath: pp }) => {
1160
+ const body: Record<string, unknown> = {
1161
+ agentId: sessionName,
1162
+ content,
1163
+ category,
1164
+ scope,
1165
+ };
1166
+ const resolvedProjectPath = (pp as string | undefined) || projectPath;
1167
+ if (resolvedProjectPath) {
1168
+ body.projectPath = resolvedProjectPath;
1169
+ }
1170
+ const result = await client.post('/memory/remember', body);
1171
+ return result.success ? result.data : { error: result.error };
1172
+ },
1173
+ },
1174
+
1175
+ // ===== System Tools =====
1176
+
1177
+ heartbeat: {
1178
+ description: 'System health check. Returns teams, projects, and message queue status.',
1179
+ inputSchema: z.object({}),
1180
+ execute: async () => {
1181
+ const [teams, projects, queue] = await Promise.all([
1182
+ client.get('/teams'),
1183
+ client.get('/projects'),
1184
+ client.get('/messaging/queue/status'),
1185
+ ]);
1186
+ return {
1187
+ status: 'ok',
1188
+ timestamp: new Date().toISOString(),
1189
+ teams: teams.success ? teams.data : 'unavailable',
1190
+ projects: projects.success ? projects.data : 'unavailable',
1191
+ queue: queue.success ? queue.data : 'unavailable',
1192
+ };
1193
+ },
1194
+ },
1195
+
1196
+ get_tasks: {
1197
+ description: 'Get all tracked tasks for a project.',
1198
+ inputSchema: z.object({
1199
+ projectPath: z.string().describe('Project path'),
1200
+ status: z.string().optional().describe('Filter by status (e.g., "in_progress", "done")'),
1201
+ }),
1202
+ execute: async ({ projectPath: _projectPath, status }) => {
1203
+ // V3-only as of spec 2026-05-06-task-management-v1-deprecation.md.
1204
+ // The V3 task-pool is a single global file; `projectPath` is no
1205
+ // longer a filter. Status filter still applies.
1206
+ let endpoint = '/task-pool/items';
1207
+ if (status) endpoint += `?status=${encodeURIComponent(status as string)}`;
1208
+ const result = await client.get(endpoint);
1209
+ return result.success ? result.data : { error: result.error };
1210
+ },
1211
+ },
1212
+
1213
+ complete_task: {
1214
+ description: 'Mark a WorkItem as complete. Also cancels any scheduled checks targeting the completing agent session.',
1215
+ inputSchema: z.object({
1216
+ workItemId: z.string().describe('WorkItem id to complete'),
1217
+ sessionName: z.string().describe('Agent session that completed it'),
1218
+ summary: z.string().describe('Completion summary'),
1219
+ }),
1220
+ execute: async ({ workItemId, sessionName: agent, summary }) => {
1221
+ // V3-only as of spec 2026-05-06-task-management-v1-deprecation.md.
1222
+ // Replaces v1 `/task-management/complete`. Note: the legacy
1223
+ // `absoluteTaskPath` parameter is removed; callers must now pass
1224
+ // `workItemId` (returned from `start_agent_with_task` /
1225
+ // `delegate_task`).
1226
+ //
1227
+ // Hygiene #4: emit canonical body shape `{agentId, result:{summary}}`
1228
+ // required by task-pool.controller.ts `completeItem`. Prior shape
1229
+ // `{summary}` 400'd because the controller looks at `result.summary`
1230
+ // and requires non-empty `agentId`. Cf. fix-paired changes in:
1231
+ // - config/skills/agent/core/{report-status,complete-task}/execute.sh
1232
+ // - config/skills/orchestrator/complete-task/execute.sh
1233
+ // - tool-registry.ts §report_status auto-complete path (below)
1234
+ const result = await client.post(`/task-pool/complete/${workItemId}`, {
1235
+ agentId: agent,
1236
+ result: { summary },
1237
+ });
1238
+
1239
+ // Auto-cancel scheduled checks targeting the completing agent
1240
+ // to prevent stale recurring checks from firing after task completion
1241
+ let cancelledChecks = 0;
1242
+ try {
1243
+ const checksResult = await client.get(`/schedule?session=${encodeURIComponent(agent as string)}`);
1244
+ if (checksResult.success && Array.isArray(checksResult.data)) {
1245
+ for (const check of checksResult.data as Array<{ id: string; isRecurring?: boolean }>) {
1246
+ if (check.isRecurring) {
1247
+ await client.delete(`/schedule/${check.id}`);
1248
+ cancelledChecks++;
1249
+ }
1250
+ }
1251
+ }
1252
+ } catch {
1253
+ // Non-fatal: task completion succeeded even if check cleanup fails
1254
+ }
1255
+
1256
+ if (result.success) {
1257
+ return { ...result.data as Record<string, unknown>, cancelledChecks };
1258
+ }
1259
+ return { error: result.error };
1260
+ },
1261
+ },
1262
+
1263
+ broadcast: {
1264
+ description: 'Broadcast a message to all active agent sessions.',
1265
+ inputSchema: z.object({
1266
+ message: z.string().describe('Message to broadcast'),
1267
+ }),
1268
+ execute: async ({ message }) => {
1269
+ const sessionsResult = await client.get('/terminal/sessions');
1270
+ if (!sessionsResult.success) return { error: sessionsResult.error };
1271
+
1272
+ const sessions = sessionsResult.data as Record<string, unknown>[] | { data: Record<string, unknown>[] };
1273
+ const sessionList = Array.isArray(sessions) ? sessions : (sessions as Record<string, unknown>).data as Record<string, unknown>[] || [];
1274
+
1275
+ let sent = 0;
1276
+ let failed = 0;
1277
+ for (const session of sessionList) {
1278
+ const name = (session as Record<string, string>).name;
1279
+ if (!name || name === sessionName) continue;
1280
+ const result = await client.post(`/terminal/${name}/deliver`, { message });
1281
+ if (result.success) sent++;
1282
+ else failed++;
1283
+ }
1284
+ return { sent, failed };
1285
+ },
1286
+ },
1287
+
1288
+ handle_agent_failure: {
1289
+ description: 'Handle agent failure: restart the agent session.',
1290
+ inputSchema: z.object({
1291
+ teamId: z.string(),
1292
+ memberId: z.string(),
1293
+ sessionName: z.string().describe('Agent session name'),
1294
+ action: z.enum(['restart', 'retry', 'escalate']),
1295
+ reason: z.string().optional(),
1296
+ }),
1297
+ execute: async ({ teamId, memberId, sessionName: agent, action, reason }) => {
1298
+ if (action === 'restart') {
1299
+ await client.post(`/teams/${teamId}/members/${memberId}/stop`, {});
1300
+ const result = await client.post(`/teams/${teamId}/members/${memberId}/start`, {});
1301
+ return { action: 'restarted', sessionName: agent, success: result.success };
1302
+ }
1303
+ if (action === 'escalate') {
1304
+ return { action: 'escalated', sessionName: agent, reason: reason || 'unknown' };
1305
+ }
1306
+ return { action, sessionName: agent, note: 'Action acknowledged' };
1307
+ },
1308
+ },
1309
+
1310
+ // ===== File Editing Tools =====
1311
+
1312
+ edit_file: {
1313
+ description: 'Surgical file edit: replace an exact substring in a file with new content. Uses precise string matching (not regex) to find the old text and replace it. The old_string must appear exactly once in the file for the edit to succeed. This is safer than full file rewrites — only the targeted section changes.',
1314
+ inputSchema: z.object({
1315
+ file_path: z.string().describe('Absolute path to the file to edit'),
1316
+ old_string: z.string().describe('Exact string to find and replace (must be unique in the file)'),
1317
+ new_string: z.string().describe('Replacement string'),
1318
+ replace_all: z.boolean().default(false).describe('Replace all occurrences instead of requiring uniqueness'),
1319
+ }),
1320
+ execute: async ({ file_path, old_string, new_string, replace_all }) => {
1321
+ const fp = expandPath(file_path as string, projectPath), os = old_string as string, ns = new_string as string;
1322
+ try {
1323
+ // Read the file
1324
+ const content = await fsPromises.readFile(fp, 'utf8');
1325
+
1326
+ // Count occurrences
1327
+ const occurrences = content.split(os).length - 1;
1328
+
1329
+ if (occurrences === 0) {
1330
+ return {
1331
+ success: false,
1332
+ error: `old_string not found in ${fp}. Make sure the string matches exactly (including whitespace and indentation).`,
1333
+ };
1334
+ }
1335
+
1336
+ // When replace_all is true, replace all occurrences
1337
+ if (replace_all && occurrences > 1) {
1338
+ const newContent = content.replaceAll(os, ns);
1339
+ await fsPromises.writeFile(fp, newContent, 'utf8');
1340
+ return {
1341
+ success: true,
1342
+ file: fp,
1343
+ replacements: occurrences,
1344
+ };
1345
+ }
1346
+
1347
+ // Default behavior: require unique match for safety
1348
+ if (occurrences > 1) {
1349
+ return {
1350
+ success: false,
1351
+ error: `old_string found ${occurrences} times in ${fp}. Provide a larger unique string with more surrounding context, or set replace_all=true.`,
1352
+ occurrences,
1353
+ };
1354
+ }
1355
+
1356
+ // Perform the replacement (single occurrence)
1357
+ const newContent = content.replace(os, ns);
1358
+
1359
+ // Write back
1360
+ await fsPromises.writeFile(fp, newContent, 'utf8');
1361
+
1362
+ return {
1363
+ success: true,
1364
+ file: fp,
1365
+ replacements: 1,
1366
+ };
1367
+ } catch (error) {
1368
+ const msg = error instanceof Error ? error.message : String(error);
1369
+ if (msg.includes('ENOENT')) {
1370
+ return { success: false, error: `File not found: ${fp}` };
1371
+ }
1372
+ if (msg.includes('EACCES')) {
1373
+ return { success: false, error: `Permission denied: ${fp}` };
1374
+ }
1375
+ return { success: false, error: msg };
1376
+ }
1377
+ },
1378
+ },
1379
+
1380
+ read_file: {
1381
+ description: 'Read the contents of a file. Returns text content with line numbers, or base64-encoded image data for image files (png/jpg/gif/webp/svg). Image files are returned as multimodal content parts for AI SDK.',
1382
+ inputSchema: z.object({
1383
+ file_path: z.string().describe('Absolute path to the file to read'),
1384
+ offset: z.number().optional().describe('Line number to start reading from (1-based, text files only)'),
1385
+ limit: z.number().optional().describe('Maximum number of lines to read (text files only)'),
1386
+ }),
1387
+ execute: async ({ file_path, offset, limit }) => {
1388
+ const fp = expandPath(file_path as string, projectPath);
1389
+ try {
1390
+ // Check if this is an image file
1391
+ const ext = fp.split('.').pop()?.toLowerCase() || '';
1392
+ if (IMAGE_EXTENSIONS.has(ext)) {
1393
+ const buffer = await fsPromises.readFile(fp);
1394
+ const base64 = buffer.toString('base64');
1395
+ const mimeType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream';
1396
+ return {
1397
+ success: true,
1398
+ type: 'image',
1399
+ mimeType,
1400
+ data: base64,
1401
+ file: fp,
1402
+ sizeBytes: buffer.length,
1403
+ };
1404
+ }
1405
+
1406
+ const content = await fsPromises.readFile(fp, 'utf8');
1407
+ const lines = content.split('\n');
1408
+
1409
+ if (offset || limit) {
1410
+ const start = ((offset as number) || 1) - 1;
1411
+ const end = limit ? start + (limit as number) : lines.length;
1412
+ const sliced = lines.slice(start, end);
1413
+ return {
1414
+ success: true,
1415
+ content: sliced.map((line, i) => `${start + i + 1}\t${line}`).join('\n'),
1416
+ totalLines: lines.length,
1417
+ };
1418
+ }
1419
+
1420
+ // Auto-limit large files to prevent context overflow
1421
+ const effectiveLines = lines.length > READ_FILE_MAX_LINES
1422
+ ? lines.slice(0, READ_FILE_MAX_LINES)
1423
+ : lines;
1424
+ let output = effectiveLines.map((line, i) => `${i + 1}\t${line}`).join('\n');
1425
+ const wasTruncated = lines.length > READ_FILE_MAX_LINES;
1426
+ if (output.length > READ_FILE_MAX_CHARS) {
1427
+ output = output.slice(0, READ_FILE_MAX_CHARS) + '\n...[truncated — use offset/limit to read specific sections]';
1428
+ }
1429
+ return {
1430
+ success: true,
1431
+ content: output,
1432
+ totalLines: lines.length,
1433
+ ...(wasTruncated && { truncated: true, shownLines: READ_FILE_MAX_LINES }),
1434
+ };
1435
+ } catch (error) {
1436
+ const msg = error instanceof Error ? error.message : String(error);
1437
+ if (msg.includes('ENOENT')) {
1438
+ return { success: false, error: `File not found: ${fp}` };
1439
+ }
1440
+ return { success: false, error: msg };
1441
+ }
1442
+ },
1443
+ },
1444
+
1445
+ write_file: {
1446
+ description: 'Write content to a file, creating it if it does not exist. Overwrites existing content. Use edit_file for surgical modifications to existing files.',
1447
+ inputSchema: z.object({
1448
+ file_path: z.string().describe('Absolute path to the file to write'),
1449
+ content: z.string().describe('Full file content to write'),
1450
+ }),
1451
+ execute: async ({ file_path, content }) => {
1452
+ const fp = expandPath(file_path as string, projectPath), ct = content as string;
1453
+ try {
1454
+ // Ensure parent directory exists
1455
+ const dir = fp.substring(0, fp.lastIndexOf('/'));
1456
+ if (dir) {
1457
+ await fsPromises.mkdir(dir, { recursive: true });
1458
+ }
1459
+ await fsPromises.writeFile(fp, ct, 'utf8');
1460
+ return { success: true, file: fp, bytes: Buffer.byteLength(ct, 'utf8') };
1461
+ } catch (error) {
1462
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1463
+ }
1464
+ },
1465
+ },
1466
+
1467
+ // ===== Native File Search Tools (O7.KR1 — Claude Code parity) =====
1468
+
1469
+ glob: {
1470
+ description: 'Fast file pattern matching. Recursively find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.test.tsx"). Returns matching file paths sorted alphabetically. Use this instead of bash find.',
1471
+ inputSchema: z.object({
1472
+ pattern: z.string().describe('Glob pattern to match files (e.g., "**/*.ts", "src/components/**/*.tsx")'),
1473
+ path: z.string().optional().describe('Root directory to search in. Defaults to projectPath or cwd.'),
1474
+ ignore: z.array(z.string()).optional().describe('Additional directory names to ignore (node_modules, .git, dist are always ignored)'),
1475
+ }),
1476
+ execute: async ({ pattern, path: searchPath, ignore }) => {
1477
+ try {
1478
+ const rootDir = expandPath((searchPath as string | undefined) || projectPath || process.cwd());
1479
+ const stat = await fsPromises.stat(rootDir);
1480
+ if (!stat.isDirectory()) {
1481
+ return { success: false, error: `Not a directory: ${rootDir}` };
1482
+ }
1483
+
1484
+ const patternStr = pattern as string;
1485
+ const patternRegex = globToRegExp(patternStr);
1486
+
1487
+ const ignoreNames = new Set(DEFAULT_IGNORE_PATTERNS);
1488
+ if (Array.isArray(ignore)) {
1489
+ for (const ig of ignore) {
1490
+ ignoreNames.add(ig as string);
1491
+ }
1492
+ }
1493
+
1494
+ const results = await walkAndMatch(rootDir, patternRegex, ignoreNames, GLOB_MAX_RESULTS);
1495
+ results.sort();
1496
+
1497
+ return {
1498
+ success: true,
1499
+ pattern: patternStr,
1500
+ root: rootDir,
1501
+ matchCount: results.length,
1502
+ files: results,
1503
+ truncated: results.length >= GLOB_MAX_RESULTS,
1504
+ };
1505
+ } catch (error) {
1506
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1507
+ }
1508
+ },
1509
+ },
1510
+
1511
+ grep: {
1512
+ description: 'Search file contents for a regex pattern. Returns matching lines with line numbers and optional context. Searches recursively through files matching an optional file glob filter. Use this instead of bash grep/rg.',
1513
+ inputSchema: z.object({
1514
+ pattern: z.string().describe('Regular expression pattern to search for in file contents'),
1515
+ path: z.string().optional().describe('File or directory to search in. Defaults to projectPath or cwd.'),
1516
+ file_pattern: z.string().optional().describe('Glob pattern to filter which files to search (e.g., "*.ts", "**/*.tsx")'),
1517
+ context_lines: z.number().optional().describe('Number of lines before and after each match to include (default: 0)'),
1518
+ case_insensitive: z.boolean().optional().describe('Case insensitive search (default: false)'),
1519
+ max_matches: z.number().optional().describe('Maximum total matches to return (default: 500)'),
1520
+ ignore: z.array(z.string()).optional().describe('Additional directory names to ignore'),
1521
+ }),
1522
+ execute: async ({ pattern: searchPattern, path: searchPath, file_pattern, context_lines, case_insensitive, max_matches, ignore }) => {
1523
+ try {
1524
+ const patternStr = searchPattern as string;
1525
+ const flags = (case_insensitive as boolean) ? 'gi' : 'g';
1526
+ let regex: RegExp;
1527
+ try {
1528
+ regex = new RegExp(patternStr, flags);
1529
+ } catch (e) {
1530
+ return { success: false, error: `Invalid regex: ${patternStr} — ${e instanceof Error ? e.message : String(e)}` };
1531
+ }
1532
+
1533
+ // Also create a non-global version for line testing
1534
+ const testRegex = new RegExp(patternStr, (case_insensitive as boolean) ? 'i' : '');
1535
+ const ctxLines = (context_lines as number | undefined) || 0;
1536
+ const maxTotal = (max_matches as number | undefined) || GREP_MAX_MATCHES;
1537
+
1538
+ const rootDir = expandPath((searchPath as string | undefined) || projectPath || process.cwd());
1539
+ let filesToSearch: string[];
1540
+
1541
+ // Check if rootDir is a file or directory
1542
+ const stat = await fsPromises.stat(rootDir);
1543
+ if (stat.isFile()) {
1544
+ filesToSearch = [rootDir];
1545
+ } else {
1546
+ // Determine file pattern for filtering
1547
+ const filePatternStr = (file_pattern as string | undefined) || '**/*';
1548
+ const fileRegex = globToRegExp(filePatternStr);
1549
+
1550
+ const ignoreNames = new Set(DEFAULT_IGNORE_PATTERNS);
1551
+ if (Array.isArray(ignore)) {
1552
+ for (const ig of ignore) {
1553
+ ignoreNames.add(ig as string);
1554
+ }
1555
+ }
1556
+
1557
+ filesToSearch = await walkAndMatch(rootDir, fileRegex, ignoreNames, GLOB_MAX_RESULTS);
1558
+ }
1559
+
1560
+ const allMatches: Array<{
1561
+ file: string;
1562
+ line: number;
1563
+ content: string;
1564
+ context?: string[];
1565
+ }> = [];
1566
+ let totalMatches = 0;
1567
+
1568
+ for (const filePath of filesToSearch) {
1569
+ if (totalMatches >= maxTotal) break;
1570
+
1571
+ // Skip binary files
1572
+ try {
1573
+ if (await isBinaryFile(filePath)) continue;
1574
+ } catch {
1575
+ continue;
1576
+ }
1577
+
1578
+ try {
1579
+ const fileMatches = await searchFileContents(filePath, testRegex, ctxLines);
1580
+ for (const m of fileMatches) {
1581
+ if (totalMatches >= maxTotal) break;
1582
+ allMatches.push({
1583
+ file: filePath,
1584
+ line: m.line,
1585
+ content: m.content,
1586
+ ...(m.context ? { context: m.context } : {}),
1587
+ });
1588
+ totalMatches++;
1589
+ }
1590
+ } catch {
1591
+ // Permission denied, encoding error, etc. — skip file
1592
+ continue;
1593
+ }
1594
+ }
1595
+
1596
+ return {
1597
+ success: true,
1598
+ pattern: patternStr,
1599
+ root: rootDir,
1600
+ matchCount: allMatches.length,
1601
+ filesSearched: filesToSearch.length,
1602
+ matches: allMatches,
1603
+ truncated: totalMatches >= maxTotal,
1604
+ };
1605
+ } catch (error) {
1606
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1607
+ }
1608
+ },
1609
+ },
1610
+
1611
+ // ===== Agent Lifecycle Self-Registration Tools =====
1612
+
1613
+ register_self: {
1614
+ description: 'Register this agent as active with the Crewly backend. Call this immediately on startup.',
1615
+ inputSchema: z.object({
1616
+ role: z.string().describe('Agent role (e.g., "developer", "orchestrator")'),
1617
+ }),
1618
+ execute: async ({ role }) => {
1619
+ const result = await client.post('/teams/members/register', {
1620
+ role,
1621
+ sessionName,
1622
+ });
1623
+ return result.success ? result.data : { error: result.error };
1624
+ },
1625
+ },
1626
+
1627
+ get_project_overview: {
1628
+ description: 'Get an overview of all configured projects.',
1629
+ inputSchema: z.object({}),
1630
+ execute: async () => {
1631
+ const result = await client.get('/projects');
1632
+ return result.success ? result.data : { error: result.error };
1633
+ },
1634
+ },
1635
+
1636
+ report_status: {
1637
+ description: 'Report task status to the orchestrator. sessionName and role are auto-injected from the current session context.',
1638
+ inputSchema: z.object({
1639
+ status: z.enum(['in_progress', 'done', 'blocked', 'error']).describe('Current status'),
1640
+ summary: z.string().describe('Brief status summary'),
1641
+ }),
1642
+ execute: async ({ status, summary }) => {
1643
+ // Build status message matching the bash report-status format
1644
+ const statusUpper = (status as string).toUpperCase();
1645
+ const message = `[${statusUpper}] Agent ${sessionName}: ${summary}`;
1646
+
1647
+ // Send to orchestrator via chat API (matches bash skill behavior)
1648
+ const chatBody: Record<string, string> = {
1649
+ content: message,
1650
+ senderName: sessionName,
1651
+ senderType: 'agent',
1652
+ };
1653
+ if (conversationId) {
1654
+ chatBody.conversationId = conversationId;
1655
+ }
1656
+ const result = await client.post('/chat/agent-response', chatBody);
1657
+
1658
+ // Auto-complete the agent's currently-running WorkItem when done.
1659
+ // V3-only as of spec 2026-05-06-task-management-v1-deprecation.md:
1660
+ // resolve the running claim from the pool, then call
1661
+ // `/task-pool/complete/:id`. Replaces v1 `/task-management/complete-by-session`.
1662
+ if (status === 'done') {
1663
+ try {
1664
+ const poolResp = await client.get(
1665
+ `/task-pool/items?status=running&target=${encodeURIComponent(sessionName)}`,
1666
+ );
1667
+ const items = (poolResp.data as Record<string, unknown> | undefined);
1668
+ const list = (
1669
+ (items?.workItems as Array<{ id: string }> | undefined) ??
1670
+ (items?.data as Array<{ id: string }> | undefined) ??
1671
+ (Array.isArray(items) ? items as Array<{ id: string }> : [])
1672
+ );
1673
+ const wiId = list[0]?.id;
1674
+ if (wiId) {
1675
+ // Hygiene #4: canonical body shape `{agentId, result:{summary}}`.
1676
+ // `agentId` here is `sessionName` because that's the runtime
1677
+ // identity of the completing agent (the session whose
1678
+ // status=done message triggered this auto-complete path).
1679
+ await client
1680
+ .post(`/task-pool/complete/${wiId}`, {
1681
+ agentId: sessionName,
1682
+ result: { summary },
1683
+ })
1684
+ .catch(() => {});
1685
+ }
1686
+ } catch {
1687
+ // Non-fatal: status report still succeeded.
1688
+ }
1689
+ }
1690
+
1691
+ // Auto-persist key findings as project knowledge when done (#127)
1692
+ if (status === 'done' && summary && projectPath) {
1693
+ await client.post('/memory/remember', {
1694
+ agentId: sessionName,
1695
+ content: `Task completed by ${sessionName}: ${summary}`,
1696
+ category: 'pattern',
1697
+ scope: 'project',
1698
+ projectPath,
1699
+ }).catch(() => {});
1700
+ }
1701
+
1702
+ return result.success ? result.data : { error: result.error };
1703
+ },
1704
+ },
1705
+
1706
+ // ===== Context Compaction Tool =====
1707
+
1708
+ compact_memory: {
1709
+ description: 'Proactively compact conversation context to preserve critical state while freeing token budget. Use when context is growing large or before starting a new phase of work. Generates an AI-powered structured summary of older messages preserving: active tasks, decisions, findings, blockers, and current context.',
1710
+ inputSchema: z.object({}),
1711
+ sensitivity: 'safe',
1712
+ execute: async () => {
1713
+ if (!callbacks?.onCompactMemory) {
1714
+ return { success: false, error: 'Compaction not available — no runner callback configured' };
1715
+ }
1716
+ const result = await callbacks.onCompactMemory();
1717
+ return {
1718
+ success: result.compacted,
1719
+ ...result,
1720
+ };
1721
+ },
1722
+ },
1723
+
1724
+ get_context_budget: {
1725
+ description: 'Check current context window token budget usage. Returns total tokens used, context window size, usage percentage, and warning level (normal/warning/critical). Use proactively to decide when to compact or wrap up work.',
1726
+ inputSchema: z.object({}),
1727
+ sensitivity: 'safe',
1728
+ execute: async () => {
1729
+ if (!callbacks?.onGetContextBudget) {
1730
+ return { success: false, error: 'Context budget tracking not available — no runner callback configured' };
1731
+ }
1732
+ const budget = callbacks.onGetContextBudget();
1733
+ return { success: true, ...budget };
1734
+ },
1735
+ },
1736
+
1737
+ // ===== Security Audit Tool =====
1738
+
1739
+ get_audit_log: {
1740
+ description: 'Retrieve the security audit trail of recent tool invocations. Shows tool name, sensitivity classification, success/failure, duration, and sanitized arguments. Use for security review, debugging, or compliance.',
1741
+ inputSchema: z.object({
1742
+ limit: z.number().default(50).describe('Maximum entries to return (most recent first)'),
1743
+ sensitivity: z.enum(['safe', 'sensitive', 'destructive']).optional().describe('Filter by sensitivity level'),
1744
+ toolName: z.string().optional().describe('Filter by specific tool name'),
1745
+ }),
1746
+ sensitivity: 'safe',
1747
+ execute: async ({ limit, sensitivity: filterSensitivity, toolName: filterTool }) => {
1748
+ if (!callbacks?.onGetAuditLog) {
1749
+ return {
1750
+ success: false,
1751
+ error: 'Audit log not available — no runner callback configured',
1752
+ };
1753
+ }
1754
+
1755
+ const filters: AuditLogFilters = {
1756
+ limit: (limit as number) || 50,
1757
+ sensitivity: filterSensitivity as ToolSensitivity | undefined,
1758
+ toolName: filterTool as string | undefined,
1759
+ };
1760
+ const entries = callbacks.onGetAuditLog(filters);
1761
+
1762
+ return {
1763
+ success: true,
1764
+ totalEntries: entries.length,
1765
+ filters: {
1766
+ limit: filters.limit,
1767
+ sensitivity: filters.sensitivity || 'all',
1768
+ toolName: filters.toolName || 'all',
1769
+ },
1770
+ entries,
1771
+ };
1772
+ },
1773
+ },
1774
+
1775
+ // ===================================================================
1776
+ // Shell Execution (#176)
1777
+ // ===================================================================
1778
+
1779
+ bash_exec: {
1780
+ description: 'Execute a shell command and return stdout/stderr. Commands run with a timeout (default 60s, max 300s). Use for build, test, lint, system operations, and skill scripts. Some commands (kill, reboot, rm -rf /, etc.) are blocked by security policy.',
1781
+ inputSchema: z.object({
1782
+ command: z.string().describe('Shell command to execute'),
1783
+ cwd: z.string().optional().describe('Working directory (defaults to project path)'),
1784
+ timeout: z.number().optional().describe('Timeout in milliseconds (default: 60000, max: 300000)'),
1785
+ }),
1786
+ sensitivity: 'destructive' as ToolSensitivity,
1787
+ execute: async ({ command, cwd, timeout }) => {
1788
+ const cmd = command as string;
1789
+
1790
+ // Check command against blocklist before execution
1791
+ const blockReason = validateBashCommand(cmd);
1792
+ if (blockReason) {
1793
+ return {
1794
+ success: false,
1795
+ exitCode: 126,
1796
+ stdout: '',
1797
+ stderr: blockReason,
1798
+ error: blockReason,
1799
+ };
1800
+ }
1801
+
1802
+ // Check if command requires explicit approval (git push, rm, docker, network, etc.)
1803
+ // Only enforce if the security policy's requireApproval includes 'destructive'.
1804
+ const approvalLabel = checkBashApprovalRequired(cmd);
1805
+ if (approvalLabel && callbacks?.onEnqueueApproval && callbacks?.onCheckApproval) {
1806
+ const policyCheck = callbacks.onCheckApproval('bash_exec', 'destructive');
1807
+ if (!policyCheck.allowed && !policyCheck.blocked) {
1808
+ const sanitizedArgs = sanitizeArgs({ command: cmd, cwd, timeout });
1809
+ const enqueued = callbacks.onEnqueueApproval('bash_exec', 'destructive', sanitizedArgs);
1810
+ return {
1811
+ success: false,
1812
+ requiresApproval: true,
1813
+ approvalId: enqueued.approvalId,
1814
+ reason: `Command requires approval: ${approvalLabel}`,
1815
+ error: `Approval required for: ${approvalLabel}. Approval ID: ${enqueued.approvalId}`,
1816
+ };
1817
+ }
1818
+ }
1819
+
1820
+ const workDir = expandPath((cwd as string | undefined) || projectPath || process.cwd());
1821
+ const timeoutMs = Math.min((timeout as number | undefined) || 60000, 300000);
1822
+
1823
+ // Validate working directory exists — spawn gives cryptic "ENOENT" if cwd is missing
1824
+ try { await fsPromises.stat(workDir); } catch {
1825
+ return {
1826
+ success: false,
1827
+ exitCode: 1,
1828
+ stdout: '',
1829
+ stderr: `Working directory does not exist: ${workDir}`,
1830
+ error: `Working directory does not exist: ${workDir}`,
1831
+ };
1832
+ }
1833
+
1834
+ // Run in isolated process group to prevent signal propagation (async, non-blocking)
1835
+ const result = await execIsolatedAsync(cmd, workDir, timeoutMs);
1836
+
1837
+ if (result.exitCode === 0) {
1838
+ const truncated = result.stdout.length > 10000;
1839
+ return {
1840
+ success: true,
1841
+ exitCode: 0,
1842
+ stdout: truncated ? result.stdout.slice(-10000) + '\n...(truncated)' : result.stdout,
1843
+ truncated,
1844
+ };
1845
+ }
1846
+
1847
+ return {
1848
+ success: false,
1849
+ exitCode: result.exitCode,
1850
+ stdout: (result.stdout || '').slice(-5000),
1851
+ stderr: (result.stderr || '').slice(-5000),
1852
+ error: result.stderr ? result.stderr.slice(0, 500) : `Process exited with code ${result.exitCode}`,
1853
+ };
1854
+ },
1855
+ },
1856
+
1857
+ // ===================================================================
1858
+ // Git Tools (#176)
1859
+ // ===================================================================
1860
+
1861
+ git_status: {
1862
+ description: 'Get the git status of a project directory. Returns current branch, staged files, unstaged changes, and untracked files.',
1863
+ inputSchema: z.object({
1864
+ projectPath: z.string().describe('Absolute path to the git repository'),
1865
+ }),
1866
+ execute: async ({ projectPath }) => {
1867
+ const cwd = expandPath(projectPath as string);
1868
+ try {
1869
+ const branch = (await execGitAsync('git rev-parse --abbrev-ref HEAD', cwd)).trim();
1870
+ const statusOutput = await execGitAsync('git status --porcelain', cwd);
1871
+
1872
+ const staged: string[] = [];
1873
+ const unstaged: string[] = [];
1874
+ const untracked: string[] = [];
1875
+
1876
+ for (const line of statusOutput.split('\n')) {
1877
+ if (!line.trim()) continue;
1878
+ const x = line[0]; // index status
1879
+ const y = line[1]; // working tree status
1880
+ const file = line.slice(3);
1881
+ if (x === '?' && y === '?') {
1882
+ untracked.push(file);
1883
+ } else {
1884
+ if (x !== ' ' && x !== '?') staged.push(file);
1885
+ if (y !== ' ' && y !== '?') unstaged.push(file);
1886
+ }
1887
+ }
1888
+
1889
+ return { success: true, branch, staged, unstaged, untracked };
1890
+ } catch (error) {
1891
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1892
+ }
1893
+ },
1894
+ },
1895
+
1896
+ git_diff: {
1897
+ description: 'Get the git diff output for a project directory. Shows unstaged changes by default, or staged changes when staged=true. Output truncated to 5000 characters.',
1898
+ inputSchema: z.object({
1899
+ projectPath: z.string().describe('Absolute path to the git repository'),
1900
+ staged: z.boolean().default(false).describe('Show staged (cached) changes instead of unstaged'),
1901
+ }),
1902
+ execute: async ({ projectPath, staged }) => {
1903
+ const cwd = expandPath(projectPath as string);
1904
+ try {
1905
+ const cmd = staged ? 'git diff --cached' : 'git diff';
1906
+ const diff = await execGitAsync(cmd, cwd);
1907
+
1908
+ const truncated = diff.length > GIT_DIFF_MAX_CHARS;
1909
+ const output = truncated ? diff.slice(0, GIT_DIFF_MAX_CHARS) + '\n... (truncated)' : diff;
1910
+
1911
+ return { success: true, diff: output, truncated, totalLength: diff.length };
1912
+ } catch (error) {
1913
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1914
+ }
1915
+ },
1916
+ },
1917
+
1918
+ git_commit: {
1919
+ description: 'Stage files and create a git commit. If files are provided, stages only those files. Otherwise stages all changes (git add -A). Returns the new commit hash.',
1920
+ inputSchema: z.object({
1921
+ projectPath: z.string().describe('Absolute path to the git repository'),
1922
+ message: z.string().describe('Commit message'),
1923
+ files: z.array(z.string()).optional().describe('Specific files to stage (omit for git add -A)'),
1924
+ }),
1925
+ sensitivity: 'sensitive' as ToolSensitivity,
1926
+ execute: async ({ projectPath, message, files }) => {
1927
+ const cwd = expandPath(projectPath as string);
1928
+ try {
1929
+ // Stage files
1930
+ if (files && (files as string[]).length > 0) {
1931
+ for (const file of files as string[]) {
1932
+ await execGitAsync(`git add -- ${JSON.stringify(file)}`, cwd);
1933
+ }
1934
+ } else {
1935
+ await execGitAsync('git add -A', cwd);
1936
+ }
1937
+
1938
+ // Commit
1939
+ await execGitAsync(`git commit -m ${JSON.stringify(message as string)}`, cwd);
1940
+
1941
+ // Get the commit hash
1942
+ const hash = (await execGitAsync('git rev-parse HEAD', cwd)).trim();
1943
+
1944
+ return { success: true, commitHash: hash, message: message as string };
1945
+ } catch (error) {
1946
+ return { success: false, error: error instanceof Error ? error.message : String(error) };
1947
+ }
1948
+ },
1949
+ },
1950
+
1951
+ // ===================================================================
1952
+ // Task Handoff (F12)
1953
+ // ===================================================================
1954
+
1955
+ handoff_task: {
1956
+ description: 'Hand off an in-progress task to another agent with full context (progress, findings, blockers). Unlike delegate_task which assigns NEW work, handoff transfers ONGOING work so the receiving agent can continue where you left off.',
1957
+ inputSchema: z.object({
1958
+ to: z.string().describe('Target agent session name to hand off to'),
1959
+ reason: z.string().describe('Why you are handing off (blocked, expertise needed, etc.)'),
1960
+ progress: z.string().optional().describe('Current progress summary (what is done so far)'),
1961
+ findings: z.string().optional().describe('Key findings and decisions made'),
1962
+ blockers: z.string().optional().describe('Current blockers or notes for the receiving agent'),
1963
+ workItemId: z.string().optional().describe('WorkItem id being handed off (resolved from running claim if omitted)'),
1964
+ }),
1965
+ sensitivity: 'sensitive' as ToolSensitivity,
1966
+ execute: async ({ to, reason, progress, findings, blockers, workItemId }) => {
1967
+ const target = to as string;
1968
+ const handoffReason = reason as string;
1969
+
1970
+ // Build handoff context message
1971
+ let message = `[TASK HANDOFF] from ${sessionName}\n\n## Reason\n${handoffReason}\n`;
1972
+ if (progress) message += `\n## Progress\n${progress}\n`;
1973
+ if (findings) message += `\n## Key Findings\n${findings}\n`;
1974
+ if (blockers) message += `\n## Blockers / Notes\n${blockers}\n`;
1975
+
1976
+ if (projectPath) {
1977
+ message += `\n---\nWhen done, report back using: bash config/skills/agent/core/report-status/execute.sh '{"sessionName":"${target}","status":"done","summary":"<brief summary>","projectPath":"${projectPath}"}'`;
1978
+ }
1979
+
1980
+ // Deliver to target agent
1981
+ const deliverResult = await client.post(`/terminal/${target}/deliver`, {
1982
+ message,
1983
+ waitForReady: true,
1984
+ waitTimeout: 15000,
1985
+ }).catch(async () => {
1986
+ // Retry with force
1987
+ return client.post(`/terminal/${target}/deliver`, { message, force: true });
1988
+ });
1989
+
1990
+ // Record handoff via V3 task-pool (spec
1991
+ // 2026-05-06-task-management-v1-deprecation.md). Replaces
1992
+ // v1 `/task-management/handoff`. Resolves the source WI from
1993
+ // the running claim if `workItemId` was not supplied.
1994
+ let resolvedWiId = workItemId as string | undefined;
1995
+ if (!resolvedWiId) {
1996
+ try {
1997
+ const poolResp = await client.get(
1998
+ `/task-pool/items?status=running&target=${encodeURIComponent(sessionName)}`,
1999
+ );
2000
+ const data = poolResp.data as Record<string, unknown> | undefined;
2001
+ const list = (
2002
+ (data?.workItems as Array<{ id: string }> | undefined) ??
2003
+ (data?.data as Array<{ id: string }> | undefined) ??
2004
+ []
2005
+ );
2006
+ resolvedWiId = list[0]?.id;
2007
+ } catch {
2008
+ // fall through — handoff tracking will be marked unavailable
2009
+ }
2010
+ }
2011
+ const handoffRecord = resolvedWiId
2012
+ ? await client.post(`/task-pool/items/${resolvedWiId}/handoff`, {
2013
+ newTarget: target,
2014
+ fromAgent: sessionName,
2015
+ reason: handoffReason,
2016
+ }).catch(() => ({ success: false, error: 'handoff API unavailable' }))
2017
+ : { success: false, error: 'no active WorkItem to hand off' };
2018
+
2019
+ return {
2020
+ success: deliverResult?.success ?? false,
2021
+ handoff: {
2022
+ from: sessionName,
2023
+ to: target,
2024
+ reason: handoffReason,
2025
+ timestamp: new Date().toISOString(),
2026
+ },
2027
+ delivery: deliverResult?.success ? 'delivered' : (deliverResult?.error || 'failed'),
2028
+ tracking: handoffRecord?.success ? 'tracked' : 'not tracked',
2029
+ };
2030
+ },
2031
+ },
2032
+
2033
+ // ===== Cloud-Backed Web Search =====
2034
+
2035
+ web_search: createWebSearchTool(),
2036
+ };
2037
+
2038
+ // Apply sensitivity classifications and audit wrapping
2039
+ const tools: Record<string, ToolDefinition> = {};
2040
+ for (const [name, tool] of Object.entries(rawTools)) {
2041
+ tools[name] = {
2042
+ ...tool,
2043
+ sensitivity: tool.sensitivity || TOOL_SENSITIVITY[name] || 'safe',
2044
+ execute: wrapWithAudit(name, tool.execute, callbacks),
2045
+ };
2046
+ }
2047
+
2048
+ // Merge MCP-sourced tools with audit wrapping
2049
+ if (mcpTools) {
2050
+ for (const [name, tool] of Object.entries(mcpTools)) {
2051
+ tools[name] = {
2052
+ ...tool,
2053
+ execute: wrapWithAudit(name, tool.execute, callbacks),
2054
+ };
2055
+ }
2056
+ }
2057
+
2058
+ return tools;
2059
+ }
2060
+
2061
+ /**
2062
+ * Build a structured task message matching the delegate-task skill format.
2063
+ *
2064
+ * @param to - Target agent session
2065
+ * @param task - Task instructions
2066
+ * @param priority - Task priority
2067
+ * @param context - Optional additional context
2068
+ * @param projectPath - Optional project path
2069
+ * @returns Formatted task message string
2070
+ */
2071
+ function buildTaskMessage(
2072
+ to: string,
2073
+ task: string,
2074
+ priority: string,
2075
+ context?: string,
2076
+ projectPath?: string,
2077
+ ): string {
2078
+ let message = `New task from orchestrator (priority: ${priority}):\n\n${task}`;
2079
+ if (context) message += `\n\nContext: ${context}`;
2080
+ if (projectPath) {
2081
+ message += `\n\nWhen done, report back using: bash config/skills/agent/core/report-status/execute.sh '{"sessionName":"${to}","status":"done","summary":"<brief summary>","projectPath":"${projectPath}"}'`;
2082
+ }
2083
+ return message;
2084
+ }
2085
+
2086
+ /**
2087
+ * Get the list of tool names available in the registry.
2088
+ *
2089
+ * @returns Array of tool name strings
2090
+ */
2091
+ export function getToolNames(): string[] {
2092
+ return [
2093
+ 'delegate_task', 'send_message', 'get_agent_status', 'get_team_status',
2094
+ 'get_agent_logs', 'reply_slack', 'schedule_check', 'cancel_schedule',
2095
+ 'get_scheduled_checks', 'start_agent', 'stop_agent', 'subscribe_event',
2096
+ 'recall_memory', 'remember', 'heartbeat', 'get_tasks', 'complete_task',
2097
+ 'broadcast', 'handle_agent_failure', 'edit_file', 'read_file', 'write_file',
2098
+ 'glob', 'grep',
2099
+ 'register_self', 'get_project_overview', 'report_status',
2100
+ 'compact_memory', 'get_audit_log',
2101
+ 'git_status', 'git_diff', 'git_commit',
2102
+ 'web_search',
2103
+ ];
2104
+ }