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.
- package/config/skills/agent/onboarding/synthesize-hierarchy/SKILL.md +65 -0
- package/config/skills/agent/onboarding/synthesize-hierarchy/execute.sh +61 -0
- package/config/skills/agent/web-search/SKILL.md +70 -0
- package/config/skills/agent/web-search/execute.sh +170 -0
- package/config/skills/agent/web-search/skill.json +23 -0
- package/dist/backend/backend/src/constants.d.ts +12 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +12 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +22 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +58 -0
- package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +3 -1
- package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts +27 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js +108 -0
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts +6 -2
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js +9 -3
- package/dist/backend/backend/src/controllers/orchestrator-onboarding/orchestrator-onboarding.routes.js.map +1 -1
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +36 -2
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts +18 -0
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js +24 -2
- package/dist/backend/backend/src/services/agent/crewly-agent/crewly-agent-external-runtime.service.js.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts +102 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +167 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts +21 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/fission/fission-guard.service.js +30 -0
- package/dist/backend/backend/src/services/fission/fission-guard.service.js.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts +4 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js +8 -0
- package/dist/backend/backend/src/services/intent-task/intent-classifier.rules.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts +79 -58
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js +140 -65
- package/dist/backend/backend/src/services/orchestrator/onboarding/materialize-team.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts +117 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js +189 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding/synthesize-hierarchy.js.map +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js +1 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode-loader.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js +2 -0
- package/dist/backend/backend/src/services/orchestrator/onboarding-mode.skill-allowlist.js.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.d.ts.map +1 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js +17 -1
- package/dist/backend/backend/src/services/orchestrator/prompts/onboarding-mode.prompt.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts +50 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js +71 -0
- package/dist/backend/backend/src/services/reconciler/reconcile-rules.js.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts +18 -0
- package/dist/backend/backend/src/services/reconciler/reconciler.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js +75 -1
- package/dist/backend/backend/src/services/reconciler/reconciler.service.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts +115 -0
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +189 -3
- package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js +61 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.js +67 -2
- package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts +19 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/cascade-request-status.js +39 -2
- package/dist/backend/backend/src/services/v3/cascade-request-status.js.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts +41 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/escalation-router.service.js +169 -0
- package/dist/backend/backend/src/services/v3/escalation-router.service.js.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts +4 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.d.ts.map +1 -1
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js +21 -0
- package/dist/backend/backend/src/services/v3/request-cascade.subscriber.js.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/intent-task.types.js +8 -0
- package/dist/backend/backend/src/types/intent-task.types.js.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts +1 -1
- package/dist/backend/backend/src/types/v2/request.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/v2/request.types.js +1 -0
- package/dist/backend/backend/src/types/v2/request.types.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +12 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +12 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/package.json +9 -3
- package/packages/crewly-agent/README.md +27 -0
- package/packages/crewly-agent/bin/crewly-agent +33 -0
- package/packages/crewly-agent/package.json +39 -0
- package/packages/crewly-agent/src/cli.ts +168 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.test.ts +2355 -0
- package/packages/crewly-agent/src/runtime/agent-runner.service.ts +1827 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.test.ts +153 -0
- package/packages/crewly-agent/src/runtime/agent-stream.service.ts +225 -0
- package/packages/crewly-agent/src/runtime/agent-worker.test.ts +171 -0
- package/packages/crewly-agent/src/runtime/agent-worker.ts +193 -0
- package/packages/crewly-agent/src/runtime/api-client.ts +143 -0
- package/packages/crewly-agent/src/runtime/approval-queue.service.ts +307 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.test.ts +208 -0
- package/packages/crewly-agent/src/runtime/audit-log.service.ts +332 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.test.ts +178 -0
- package/packages/crewly-agent/src/runtime/audit-trail.service.ts +151 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.test.ts +274 -0
- package/packages/crewly-agent/src/runtime/auditor-tools.ts +311 -0
- package/packages/crewly-agent/src/runtime/cloud-config.ts +67 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.test.ts +165 -0
- package/packages/crewly-agent/src/runtime/deepseek-sse-transform.ts +168 -0
- package/packages/crewly-agent/src/runtime/env-isolation.service.ts +246 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.test.ts +280 -0
- package/packages/crewly-agent/src/runtime/in-process-log-buffer.ts +317 -0
- package/packages/crewly-agent/src/runtime/index.ts +38 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.test.ts +352 -0
- package/packages/crewly-agent/src/runtime/mcp-tool-bridge.ts +244 -0
- package/packages/crewly-agent/src/runtime/model-manager.test.ts +326 -0
- package/packages/crewly-agent/src/runtime/model-manager.ts +363 -0
- package/packages/crewly-agent/src/runtime/output-filter.service.ts +175 -0
- package/packages/crewly-agent/src/runtime/prompt-guard.service.ts +303 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.test.ts +228 -0
- package/packages/crewly-agent/src/runtime/rate-limiter.ts +353 -0
- package/packages/crewly-agent/src/runtime/tool-registry.test.ts +2510 -0
- package/packages/crewly-agent/src/runtime/tool-registry.ts +2104 -0
- package/packages/crewly-agent/src/runtime/types.test.ts +519 -0
- package/packages/crewly-agent/src/runtime/types.ts +637 -0
- package/packages/crewly-agent/src/runtime/web-search.tool.test.ts +131 -0
- 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, '&')
|
|
491
|
+
.replace(/</g, '<')
|
|
492
|
+
.replace(/>/g, '>');
|
|
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 > 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
|
+
}
|