opencode-orchestrator 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -27,7 +27,13 @@ You are Commander. Complete missions autonomously. Never stop until done.
27
27
  3. Never stop because agent returned nothing
28
28
  4. Always survey environment & codebase BEFORE coding
29
29
  5. Always verify with evidence based on runtime context
30
- 6. LANGUAGE: THINK and REASON in English for maximum stability. Report final summary in Korean.
30
+ 6. LANGUAGE:
31
+ - THINK and REASON in English for maximum stability
32
+ - FINAL REPORT: Detect the user's language from their request and respond in the SAME language
33
+ - If user writes in Korean \u2192 Report in Korean
34
+ - If user writes in English \u2192 Report in English
35
+ - If user writes in Japanese \u2192 Report in Japanese
36
+ - Default to English if language is unclear
31
37
  </core_rules>
32
38
 
33
39
  <phase_0 name="TRIAGE">
@@ -66,6 +72,44 @@ DEFAULT to Deep Track if unsure to act safely.
66
72
  </phase_2>
67
73
 
68
74
  <phase_3 name="DELEGATION">
75
+ <agent_calling>
76
+ \u26A0\uFE0F CRITICAL: USE delegate_task FOR ALL DELEGATION \u26A0\uFE0F
77
+
78
+ delegate_task has TWO MODES:
79
+ - background=true: Non-blocking, parallel execution
80
+ - background=false: Blocking, waits for result
81
+
82
+ | Situation | How to Call |
83
+ |-----------|-------------|
84
+ | Multiple independent tasks | \`delegate_task({ ..., background: true })\` for each |
85
+ | Single task, continue working | \`delegate_task({ ..., background: true })\` |
86
+ | Need result for VERY next step | \`delegate_task({ ..., background: false })\` |
87
+
88
+ PREFER background=true (PARALLEL):
89
+ - Run multiple agents simultaneously
90
+ - Continue analysis while they work
91
+ - System notifies when ALL complete
92
+
93
+ EXAMPLE - PARALLEL:
94
+ \`\`\`
95
+ // Multiple tasks in parallel
96
+ delegate_task({ agent: "builder", description: "Implement X", prompt: "...", background: true })
97
+ delegate_task({ agent: "inspector", description: "Review Y", prompt: "...", background: true })
98
+
99
+ // Continue other work (don't wait!)
100
+
101
+ // When notified "All Complete":
102
+ get_task_result({ taskId: "task_xxx" })
103
+ \`\`\`
104
+
105
+ EXAMPLE - SYNC (rare):
106
+ \`\`\`
107
+ // Only when you absolutely need the result now
108
+ const result = delegate_task({ agent: "builder", ..., background: false })
109
+ // Result is immediately available
110
+ \`\`\`
111
+ </agent_calling>
112
+
69
113
  <delegation_template>
70
114
  AGENT: [name]
71
115
  TASK: [one atomic action]
@@ -84,6 +128,45 @@ During implementation:
84
128
  - Match existing codebase style exactly
85
129
  - Run lsp_diagnostics after each change
86
130
 
131
+ <background_parallel_execution>
132
+ PARALLEL EXECUTION TOOLS:
133
+
134
+ 1. **spawn_agent** - Launch agents in parallel sessions
135
+ spawn_agent({ agent: "builder", description: "Implement X", prompt: "..." })
136
+ spawn_agent({ agent: "inspector", description: "Review Y", prompt: "..." })
137
+ \u2192 Agents run concurrently, system notifies when ALL complete
138
+ \u2192 Use get_task_result({ taskId }) to retrieve results
139
+
140
+ 2. **run_background** - Run shell commands asynchronously
141
+ run_background({ command: "npm run build" })
142
+ \u2192 Use check_background({ taskId }) for results
143
+
144
+ SAFETY FEATURES:
145
+ - Queue-based concurrency: Max 3 per agent type (extras queue automatically)
146
+ - Auto-timeout: 30 minutes max runtime
147
+ - Auto-cleanup: Removed from memory 5 min after completion
148
+ - Batched notifications: Notifies when ALL tasks complete (not individually)
149
+
150
+ MANAGEMENT TOOLS:
151
+ - list_tasks: View all parallel tasks and status
152
+ - cancel_task: Stop a running task (frees concurrency slot)
153
+
154
+ SAFE PATTERNS:
155
+ \u2705 Builder on file A + Inspector on file B (different files)
156
+ \u2705 Multiple research agents (read-only)
157
+ \u2705 Build command + Test command (independent)
158
+
159
+ UNSAFE PATTERNS:
160
+ \u274C Multiple builders editing SAME FILE (conflict!)
161
+
162
+ WORKFLOW:
163
+ 1. list_tasks: Check current status first
164
+ 2. spawn_agent: Launch for INDEPENDENT tasks
165
+ 3. Continue working (NO WAITING)
166
+ 4. Wait for "All Complete" notification
167
+ 5. get_task_result: Retrieve each result
168
+ </background_parallel_execution>
169
+
87
170
  <verification_methods>
88
171
  | Infra | Proof Method |
89
172
  |-------|--------------|
@@ -294,6 +377,16 @@ If your reasoning collapses into gibberish, stop and output "ERROR: REASONING_CO
294
377
  | Volume-mount | Host-level syntax + internal service check |
295
378
  </verification_by_context>
296
379
 
380
+ <background_tools>
381
+ USE BACKGROUND TASKS FOR PARALLEL VERIFICATION:
382
+ - run_background("npm run build") \u2192 Don't wait, continue analysis
383
+ - run_background("npm test") \u2192 Run tests in parallel with build
384
+ - list_background() \u2192 Check all running jobs
385
+ - check_background(taskId) \u2192 Get results when ready
386
+
387
+ ALWAYS prefer background for build/test commands.
388
+ </background_tools>
389
+
297
390
  <output_format>
298
391
  <pass>
299
392
  \u2705 PASS
@@ -772,6 +865,1210 @@ var globSearchTool = (directory) => tool3({
772
865
  });
773
866
  }
774
867
  });
868
+ var mgrepTool = (directory) => tool3({
869
+ description: `Search multiple patterns in parallel (high-performance).
870
+
871
+ <purpose>
872
+ Search for multiple regex patterns simultaneously using Rust's parallel execution.
873
+ Much faster than running grep multiple times sequentially.
874
+ </purpose>
875
+
876
+ <examples>
877
+ - patterns: ["useState", "useEffect", "useContext"] \u2192 Find all React hooks usage
878
+ - patterns: ["TODO", "FIXME", "HACK"] \u2192 Find all code annotations
879
+ - patterns: ["import.*lodash", "require.*lodash"] \u2192 Find all lodash imports
880
+ </examples>
881
+
882
+ <output>
883
+ Returns matches grouped by pattern, with file paths and line numbers.
884
+ </output>`,
885
+ args: {
886
+ patterns: tool3.schema.array(tool3.schema.string()).describe("Array of regex patterns to search for"),
887
+ dir: tool3.schema.string().optional().describe("Directory to search (defaults to project root)"),
888
+ max_results_per_pattern: tool3.schema.number().optional().describe("Max results per pattern (default: 50)")
889
+ },
890
+ async execute(args) {
891
+ return callRustTool("mgrep", {
892
+ patterns: args.patterns,
893
+ directory: args.dir || directory,
894
+ max_results_per_pattern: args.max_results_per_pattern || 50
895
+ });
896
+ }
897
+ });
898
+
899
+ // src/tools/background.ts
900
+ import { tool as tool4 } from "@opencode-ai/plugin";
901
+
902
+ // src/core/background.ts
903
+ import { spawn as spawn2 } from "child_process";
904
+ import { randomBytes } from "crypto";
905
+ var BackgroundTaskManager = class _BackgroundTaskManager {
906
+ static _instance;
907
+ tasks = /* @__PURE__ */ new Map();
908
+ debugMode = true;
909
+ // Enable debug mode
910
+ constructor() {
911
+ }
912
+ static get instance() {
913
+ if (!_BackgroundTaskManager._instance) {
914
+ _BackgroundTaskManager._instance = new _BackgroundTaskManager();
915
+ }
916
+ return _BackgroundTaskManager._instance;
917
+ }
918
+ /**
919
+ * Generate a unique task ID in the format job_xxxxxxxx
920
+ */
921
+ generateId() {
922
+ const hex = randomBytes(4).toString("hex");
923
+ return `job_${hex}`;
924
+ }
925
+ /**
926
+ * Debug logging helper
927
+ */
928
+ debug(taskId, message) {
929
+ if (this.debugMode) {
930
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().substring(11, 23);
931
+ console.log(`[BG-DEBUG ${timestamp}] ${taskId}: ${message}`);
932
+ }
933
+ }
934
+ /**
935
+ * Run a command in the background
936
+ */
937
+ run(options) {
938
+ const id = this.generateId();
939
+ const { command, cwd = process.cwd(), timeout = 3e5, label } = options;
940
+ const isWindows = process.platform === "win32";
941
+ const shell = isWindows ? "cmd.exe" : "/bin/sh";
942
+ const shellFlag = isWindows ? "/c" : "-c";
943
+ const task = {
944
+ id,
945
+ command,
946
+ args: [shellFlag, command],
947
+ cwd,
948
+ label,
949
+ status: "running",
950
+ output: "",
951
+ errorOutput: "",
952
+ exitCode: null,
953
+ startTime: Date.now(),
954
+ timeout
955
+ };
956
+ this.tasks.set(id, task);
957
+ this.debug(id, `Starting: ${command} (cwd: ${cwd})`);
958
+ try {
959
+ const proc = spawn2(shell, task.args, {
960
+ cwd,
961
+ stdio: ["ignore", "pipe", "pipe"],
962
+ detached: false
963
+ });
964
+ task.process = proc;
965
+ proc.stdout?.on("data", (data) => {
966
+ const text = data.toString();
967
+ task.output += text;
968
+ this.debug(id, `stdout: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`);
969
+ });
970
+ proc.stderr?.on("data", (data) => {
971
+ const text = data.toString();
972
+ task.errorOutput += text;
973
+ this.debug(id, `stderr: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`);
974
+ });
975
+ proc.on("close", (code) => {
976
+ task.exitCode = code;
977
+ task.endTime = Date.now();
978
+ task.status = code === 0 ? "done" : "error";
979
+ task.process = void 0;
980
+ const duration = ((task.endTime - task.startTime) / 1e3).toFixed(2);
981
+ this.debug(id, `Completed with code ${code} in ${duration}s`);
982
+ });
983
+ proc.on("error", (err) => {
984
+ task.status = "error";
985
+ task.errorOutput += `
986
+ Process error: ${err.message}`;
987
+ task.endTime = Date.now();
988
+ task.process = void 0;
989
+ this.debug(id, `Error: ${err.message}`);
990
+ });
991
+ setTimeout(() => {
992
+ if (task.status === "running" && task.process) {
993
+ this.debug(id, `Timeout after ${timeout}ms, killing process`);
994
+ task.process.kill("SIGKILL");
995
+ task.status = "timeout";
996
+ task.endTime = Date.now();
997
+ task.errorOutput += `
998
+ Process killed: timeout after ${timeout}ms`;
999
+ }
1000
+ }, timeout);
1001
+ } catch (err) {
1002
+ task.status = "error";
1003
+ task.errorOutput = `Failed to spawn: ${err instanceof Error ? err.message : String(err)}`;
1004
+ task.endTime = Date.now();
1005
+ this.debug(id, `Spawn failed: ${task.errorOutput}`);
1006
+ }
1007
+ return task;
1008
+ }
1009
+ /**
1010
+ * Get task by ID
1011
+ */
1012
+ get(taskId) {
1013
+ return this.tasks.get(taskId);
1014
+ }
1015
+ /**
1016
+ * Get all tasks
1017
+ */
1018
+ getAll() {
1019
+ return Array.from(this.tasks.values());
1020
+ }
1021
+ /**
1022
+ * Get tasks by status
1023
+ */
1024
+ getByStatus(status) {
1025
+ return this.getAll().filter((t) => t.status === status);
1026
+ }
1027
+ /**
1028
+ * Clear completed/failed tasks
1029
+ */
1030
+ clearCompleted() {
1031
+ let count = 0;
1032
+ for (const [id, task] of this.tasks) {
1033
+ if (task.status !== "running" && task.status !== "pending") {
1034
+ this.tasks.delete(id);
1035
+ count++;
1036
+ }
1037
+ }
1038
+ return count;
1039
+ }
1040
+ /**
1041
+ * Kill a running task
1042
+ */
1043
+ kill(taskId) {
1044
+ const task = this.tasks.get(taskId);
1045
+ if (task?.process) {
1046
+ task.process.kill("SIGKILL");
1047
+ task.status = "error";
1048
+ task.errorOutput += "\nKilled by user";
1049
+ task.endTime = Date.now();
1050
+ this.debug(taskId, "Killed by user");
1051
+ return true;
1052
+ }
1053
+ return false;
1054
+ }
1055
+ /**
1056
+ * Format duration for display
1057
+ */
1058
+ formatDuration(task) {
1059
+ const end = task.endTime || Date.now();
1060
+ const seconds = (end - task.startTime) / 1e3;
1061
+ if (seconds < 60) {
1062
+ return `${seconds.toFixed(1)}s`;
1063
+ }
1064
+ const minutes = Math.floor(seconds / 60);
1065
+ const remainingSeconds = seconds % 60;
1066
+ return `${minutes}m ${remainingSeconds.toFixed(0)}s`;
1067
+ }
1068
+ /**
1069
+ * Get status emoji
1070
+ */
1071
+ getStatusEmoji(status) {
1072
+ switch (status) {
1073
+ case "pending":
1074
+ return "\u23F8\uFE0F";
1075
+ case "running":
1076
+ return "\u23F3";
1077
+ case "done":
1078
+ return "\u2705";
1079
+ case "error":
1080
+ return "\u274C";
1081
+ case "timeout":
1082
+ return "\u23F0";
1083
+ default:
1084
+ return "\u2753";
1085
+ }
1086
+ }
1087
+ };
1088
+ var backgroundTaskManager = BackgroundTaskManager.instance;
1089
+
1090
+ // src/tools/background.ts
1091
+ var runBackgroundTool = tool4({
1092
+ description: `Run a shell command in the background and get a task ID.
1093
+
1094
+ <purpose>
1095
+ Execute long-running commands (builds, tests, etc.) without blocking.
1096
+ The command runs asynchronously - use check_background to get results.
1097
+ </purpose>
1098
+
1099
+ <examples>
1100
+ - "npm run build" \u2192 Build project in background
1101
+ - "cargo test" \u2192 Run Rust tests
1102
+ - "sleep 10 && echo done" \u2192 Delayed execution
1103
+ </examples>
1104
+
1105
+ <flow>
1106
+ 1. Call run_background with command
1107
+ 2. Get task ID immediately (e.g., job_a1b2c3d4)
1108
+ 3. Continue other work
1109
+ 4. Call check_background with task ID to get results
1110
+ </flow>`,
1111
+ args: {
1112
+ command: tool4.schema.string().describe("Shell command to execute"),
1113
+ cwd: tool4.schema.string().optional().describe("Working directory (default: project root)"),
1114
+ timeout: tool4.schema.number().optional().describe("Timeout in milliseconds (default: 300000 = 5 min)"),
1115
+ label: tool4.schema.string().optional().describe("Human-readable label for this task")
1116
+ },
1117
+ async execute(args) {
1118
+ const { command, cwd, timeout, label } = args;
1119
+ const task = backgroundTaskManager.run({
1120
+ command,
1121
+ cwd: cwd || process.cwd(),
1122
+ timeout: timeout || 3e5,
1123
+ label
1124
+ });
1125
+ const displayLabel = label ? ` (${label})` : "";
1126
+ return `\u{1F680} **Background Task Started**${displayLabel}
1127
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1128
+ | Property | Value |
1129
+ |----------|-------|
1130
+ | **Task ID** | \`${task.id}\` |
1131
+ | **Command** | \`${command}\` |
1132
+ | **Status** | ${backgroundTaskManager.getStatusEmoji(task.status)} ${task.status} |
1133
+ | **Working Dir** | ${task.cwd} |
1134
+ | **Timeout** | ${(task.timeout / 1e3).toFixed(0)}s |
1135
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1136
+
1137
+ \u{1F4CC} **Next Step**: Use \`check_background\` with task ID \`${task.id}\` to get results.`;
1138
+ }
1139
+ });
1140
+ var checkBackgroundTool = tool4({
1141
+ description: `Check the status and output of a background task.
1142
+
1143
+ <purpose>
1144
+ Retrieve the current status and output of a previously started background task.
1145
+ Use this after run_background to get results.
1146
+ </purpose>
1147
+
1148
+ <output_includes>
1149
+ - Status: running/done/error/timeout
1150
+ - Exit code (if completed)
1151
+ - Duration
1152
+ - Full output (stdout + stderr)
1153
+ </output_includes>`,
1154
+ args: {
1155
+ taskId: tool4.schema.string().describe("Task ID from run_background (e.g., job_a1b2c3d4)"),
1156
+ tailLines: tool4.schema.number().optional().describe("Limit output to last N lines (default: show all)")
1157
+ },
1158
+ async execute(args) {
1159
+ const { taskId, tailLines } = args;
1160
+ const task = backgroundTaskManager.get(taskId);
1161
+ if (!task) {
1162
+ const allTasks = backgroundTaskManager.getAll();
1163
+ if (allTasks.length === 0) {
1164
+ return `\u274C Task \`${taskId}\` not found. No background tasks exist.`;
1165
+ }
1166
+ const taskList = allTasks.map((t) => `- \`${t.id}\`: ${t.command.substring(0, 30)}...`).join("\n");
1167
+ return `\u274C Task \`${taskId}\` not found.
1168
+
1169
+ **Available tasks:**
1170
+ ${taskList}`;
1171
+ }
1172
+ const duration = backgroundTaskManager.formatDuration(task);
1173
+ const statusEmoji = backgroundTaskManager.getStatusEmoji(task.status);
1174
+ let output = task.output;
1175
+ let stderr = task.errorOutput;
1176
+ if (tailLines && tailLines > 0) {
1177
+ const outputLines = output.split("\n");
1178
+ const stderrLines = stderr.split("\n");
1179
+ output = outputLines.slice(-tailLines).join("\n");
1180
+ stderr = stderrLines.slice(-tailLines).join("\n");
1181
+ }
1182
+ const maxLen = 1e4;
1183
+ if (output.length > maxLen) {
1184
+ output = `[...truncated ${output.length - maxLen} chars...]
1185
+ ` + output.substring(output.length - maxLen);
1186
+ }
1187
+ if (stderr.length > maxLen) {
1188
+ stderr = `[...truncated ${stderr.length - maxLen} chars...]
1189
+ ` + stderr.substring(stderr.length - maxLen);
1190
+ }
1191
+ const labelDisplay = task.label ? ` (${task.label})` : "";
1192
+ let result = `${statusEmoji} **Task ${task.id}**${labelDisplay}
1193
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1194
+ | Property | Value |
1195
+ |----------|-------|
1196
+ | **Command** | \`${task.command}\` |
1197
+ | **Status** | ${statusEmoji} **${task.status.toUpperCase()}** |
1198
+ | **Duration** | ${duration}${task.status === "running" ? " (ongoing)" : ""} |
1199
+ ${task.exitCode !== null ? `| **Exit Code** | ${task.exitCode} |` : ""}
1200
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`;
1201
+ if (output.trim()) {
1202
+ result += `
1203
+
1204
+ \u{1F4E4} **Output (stdout)**:
1205
+ \`\`\`
1206
+ ${output.trim()}
1207
+ \`\`\``;
1208
+ }
1209
+ if (stderr.trim()) {
1210
+ result += `
1211
+
1212
+ \u26A0\uFE0F **Errors (stderr)**:
1213
+ \`\`\`
1214
+ ${stderr.trim()}
1215
+ \`\`\``;
1216
+ }
1217
+ if (task.status === "running") {
1218
+ result += `
1219
+
1220
+ \u23F3 Task still running... Check again later with:
1221
+ \`check_background({ taskId: "${task.id}" })\``;
1222
+ }
1223
+ return result;
1224
+ }
1225
+ });
1226
+ var listBackgroundTool = tool4({
1227
+ description: `List all background tasks and their current status.
1228
+
1229
+ <purpose>
1230
+ Get an overview of all running and completed background tasks.
1231
+ Useful to check what's in progress before starting new tasks.
1232
+ </purpose>`,
1233
+ args: {
1234
+ status: tool4.schema.enum(["all", "running", "done", "error"]).optional().describe("Filter by status (default: all)")
1235
+ },
1236
+ async execute(args) {
1237
+ const { status = "all" } = args;
1238
+ let tasks;
1239
+ if (status === "all") {
1240
+ tasks = backgroundTaskManager.getAll();
1241
+ } else {
1242
+ tasks = backgroundTaskManager.getByStatus(status);
1243
+ }
1244
+ if (tasks.length === 0) {
1245
+ return `\u{1F4CB} **No background tasks** ${status !== "all" ? `with status "${status}"` : ""}
1246
+
1247
+ Use \`run_background\` to start a new background task.`;
1248
+ }
1249
+ tasks.sort((a, b) => b.startTime - a.startTime);
1250
+ const rows = tasks.map((task) => {
1251
+ const emoji = backgroundTaskManager.getStatusEmoji(task.status);
1252
+ const duration = backgroundTaskManager.formatDuration(task);
1253
+ const cmdShort = task.command.length > 25 ? task.command.substring(0, 22) + "..." : task.command;
1254
+ const labelPart = task.label ? ` [${task.label}]` : "";
1255
+ return `| \`${task.id}\` | ${emoji} ${task.status.padEnd(7)} | ${cmdShort.padEnd(25)}${labelPart} | ${duration.padStart(8)} |`;
1256
+ }).join("\n");
1257
+ const runningCount = tasks.filter((t) => t.status === "running").length;
1258
+ const doneCount = tasks.filter((t) => t.status === "done").length;
1259
+ const errorCount = tasks.filter((t) => t.status === "error" || t.status === "timeout").length;
1260
+ return `\u{1F4CB} **Background Tasks** (${tasks.length} total)
1261
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1262
+ | \u23F3 Running: ${runningCount} | \u2705 Done: ${doneCount} | \u274C Error/Timeout: ${errorCount} |
1263
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
1264
+
1265
+ | Task ID | Status | Command | Duration |
1266
+ |---------|--------|---------|----------|
1267
+ ${rows}
1268
+
1269
+ \u{1F4A1} Use \`check_background({ taskId: "job_xxxxx" })\` to see full output.`;
1270
+ }
1271
+ });
1272
+ var killBackgroundTool = tool4({
1273
+ description: `Kill a running background task.
1274
+
1275
+ <purpose>
1276
+ Stop a background task that is taking too long or no longer needed.
1277
+ </purpose>`,
1278
+ args: {
1279
+ taskId: tool4.schema.string().describe("Task ID to kill (e.g., job_a1b2c3d4)")
1280
+ },
1281
+ async execute(args) {
1282
+ const { taskId } = args;
1283
+ const task = backgroundTaskManager.get(taskId);
1284
+ if (!task) {
1285
+ return `\u274C Task \`${taskId}\` not found.`;
1286
+ }
1287
+ if (task.status !== "running") {
1288
+ return `\u26A0\uFE0F Task \`${taskId}\` is not running (status: ${task.status}).`;
1289
+ }
1290
+ const killed = backgroundTaskManager.kill(taskId);
1291
+ if (killed) {
1292
+ return `\u{1F6D1} Task \`${taskId}\` has been killed.
1293
+ Command: \`${task.command}\`
1294
+ Duration before kill: ${backgroundTaskManager.formatDuration(task)}`;
1295
+ }
1296
+ return `\u26A0\uFE0F Could not kill task \`${taskId}\`. It may have already finished.`;
1297
+ }
1298
+ });
1299
+
1300
+ // src/core/async-agent.ts
1301
+ var TASK_TTL_MS = 30 * 60 * 1e3;
1302
+ var CLEANUP_DELAY_MS = 5 * 60 * 1e3;
1303
+ var MIN_STABILITY_MS = 5 * 1e3;
1304
+ var POLL_INTERVAL_MS = 2e3;
1305
+ var DEFAULT_CONCURRENCY = 3;
1306
+ var DEBUG = process.env.DEBUG_PARALLEL_AGENT === "true";
1307
+ var log = (...args) => {
1308
+ if (DEBUG) console.log("[parallel-agent]", ...args);
1309
+ };
1310
+ var ConcurrencyController = class {
1311
+ counts = /* @__PURE__ */ new Map();
1312
+ queues = /* @__PURE__ */ new Map();
1313
+ limits = /* @__PURE__ */ new Map();
1314
+ setLimit(key, limit) {
1315
+ this.limits.set(key, limit);
1316
+ }
1317
+ getLimit(key) {
1318
+ return this.limits.get(key) ?? DEFAULT_CONCURRENCY;
1319
+ }
1320
+ async acquire(key) {
1321
+ const limit = this.getLimit(key);
1322
+ if (limit === 0) return;
1323
+ const current = this.counts.get(key) ?? 0;
1324
+ if (current < limit) {
1325
+ this.counts.set(key, current + 1);
1326
+ log(`Acquired slot for ${key}: ${current + 1}/${limit}`);
1327
+ return;
1328
+ }
1329
+ log(`Queueing for ${key}: ${current}/${limit} (waiting...)`);
1330
+ return new Promise((resolve) => {
1331
+ const queue = this.queues.get(key) ?? [];
1332
+ queue.push(resolve);
1333
+ this.queues.set(key, queue);
1334
+ });
1335
+ }
1336
+ release(key) {
1337
+ const limit = this.getLimit(key);
1338
+ if (limit === 0) return;
1339
+ const queue = this.queues.get(key);
1340
+ if (queue && queue.length > 0) {
1341
+ const next = queue.shift();
1342
+ log(`Released slot for ${key}: next in queue`);
1343
+ next();
1344
+ } else {
1345
+ const current = this.counts.get(key) ?? 0;
1346
+ if (current > 0) {
1347
+ this.counts.set(key, current - 1);
1348
+ log(`Released slot for ${key}: ${current - 1}`);
1349
+ }
1350
+ }
1351
+ }
1352
+ getQueueLength(key) {
1353
+ return this.queues.get(key)?.length ?? 0;
1354
+ }
1355
+ };
1356
+ var ParallelAgentManager = class _ParallelAgentManager {
1357
+ static _instance;
1358
+ // Core state
1359
+ tasks = /* @__PURE__ */ new Map();
1360
+ pendingByParent = /* @__PURE__ */ new Map();
1361
+ notifications = /* @__PURE__ */ new Map();
1362
+ // Dependencies
1363
+ client;
1364
+ directory;
1365
+ concurrency;
1366
+ // Polling
1367
+ pollingInterval;
1368
+ constructor(client, directory) {
1369
+ this.client = client;
1370
+ this.directory = directory;
1371
+ this.concurrency = new ConcurrencyController();
1372
+ }
1373
+ static getInstance(client, directory) {
1374
+ if (!_ParallelAgentManager._instance) {
1375
+ if (!client || !directory) {
1376
+ throw new Error("ParallelAgentManager requires client and directory on first call");
1377
+ }
1378
+ _ParallelAgentManager._instance = new _ParallelAgentManager(client, directory);
1379
+ }
1380
+ return _ParallelAgentManager._instance;
1381
+ }
1382
+ // ========================================================================
1383
+ // Public API
1384
+ // ========================================================================
1385
+ /**
1386
+ * Launch an agent in a new session (async, non-blocking)
1387
+ */
1388
+ async launch(input) {
1389
+ const concurrencyKey = input.agent;
1390
+ await this.concurrency.acquire(concurrencyKey);
1391
+ this.pruneExpiredTasks();
1392
+ try {
1393
+ const createResult = await this.client.session.create({
1394
+ body: {
1395
+ parentID: input.parentSessionID,
1396
+ title: `Parallel: ${input.description}`
1397
+ },
1398
+ query: {
1399
+ directory: this.directory
1400
+ }
1401
+ });
1402
+ if (createResult.error) {
1403
+ this.concurrency.release(concurrencyKey);
1404
+ throw new Error(`Failed to create session: ${createResult.error}`);
1405
+ }
1406
+ const sessionID = createResult.data.id;
1407
+ const taskId = `task_${crypto.randomUUID().slice(0, 8)}`;
1408
+ const task = {
1409
+ id: taskId,
1410
+ sessionID,
1411
+ parentSessionID: input.parentSessionID,
1412
+ description: input.description,
1413
+ agent: input.agent,
1414
+ status: "running",
1415
+ startedAt: /* @__PURE__ */ new Date(),
1416
+ concurrencyKey
1417
+ };
1418
+ this.tasks.set(taskId, task);
1419
+ this.trackPending(input.parentSessionID, taskId);
1420
+ this.startPolling();
1421
+ this.client.session.prompt({
1422
+ path: { id: sessionID },
1423
+ body: {
1424
+ agent: input.agent,
1425
+ parts: [{ type: "text", text: input.prompt }]
1426
+ }
1427
+ }).catch((error) => {
1428
+ log(`Prompt error for ${taskId}:`, error);
1429
+ this.handleTaskError(taskId, error);
1430
+ });
1431
+ log(`Launched ${taskId} in session ${sessionID}`);
1432
+ return task;
1433
+ } catch (error) {
1434
+ this.concurrency.release(concurrencyKey);
1435
+ throw error;
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Get task by ID
1440
+ */
1441
+ getTask(id) {
1442
+ return this.tasks.get(id);
1443
+ }
1444
+ /**
1445
+ * Get all running tasks
1446
+ */
1447
+ getRunningTasks() {
1448
+ return Array.from(this.tasks.values()).filter((t) => t.status === "running");
1449
+ }
1450
+ /**
1451
+ * Get all tasks
1452
+ */
1453
+ getAllTasks() {
1454
+ return Array.from(this.tasks.values());
1455
+ }
1456
+ /**
1457
+ * Get tasks by parent session
1458
+ */
1459
+ getTasksByParent(parentSessionID) {
1460
+ return Array.from(this.tasks.values()).filter((t) => t.parentSessionID === parentSessionID);
1461
+ }
1462
+ /**
1463
+ * Cancel a running task
1464
+ */
1465
+ async cancelTask(taskId) {
1466
+ const task = this.tasks.get(taskId);
1467
+ if (!task || task.status !== "running") {
1468
+ return false;
1469
+ }
1470
+ task.status = "error";
1471
+ task.error = "Cancelled by user";
1472
+ task.completedAt = /* @__PURE__ */ new Date();
1473
+ if (task.concurrencyKey) {
1474
+ this.concurrency.release(task.concurrencyKey);
1475
+ }
1476
+ this.untrackPending(task.parentSessionID, taskId);
1477
+ try {
1478
+ await this.client.session.delete({
1479
+ path: { id: task.sessionID }
1480
+ });
1481
+ console.log(`[parallel] \u{1F5D1}\uFE0F Session ${task.sessionID.slice(0, 8)}... deleted`);
1482
+ } catch {
1483
+ console.log(`[parallel] \u{1F5D1}\uFE0F Session ${task.sessionID.slice(0, 8)}... already gone`);
1484
+ }
1485
+ this.scheduleCleanup(taskId);
1486
+ console.log(`[parallel] \u{1F6D1} CANCELLED ${taskId}`);
1487
+ log(`Cancelled ${taskId}`);
1488
+ return true;
1489
+ }
1490
+ /**
1491
+ * Get result from completed task
1492
+ */
1493
+ async getResult(taskId) {
1494
+ const task = this.tasks.get(taskId);
1495
+ if (!task) return null;
1496
+ if (task.result) return task.result;
1497
+ if (task.status === "error") return `Error: ${task.error}`;
1498
+ if (task.status === "running") return null;
1499
+ try {
1500
+ const messagesResult = await this.client.session.messages({
1501
+ path: { id: task.sessionID }
1502
+ });
1503
+ if (messagesResult.error) {
1504
+ return `Error: ${messagesResult.error}`;
1505
+ }
1506
+ const messages = messagesResult.data ?? [];
1507
+ const assistantMsgs = messages.filter((m) => m.info?.role === "assistant").reverse();
1508
+ const lastMsg = assistantMsgs[0];
1509
+ if (!lastMsg) return "(No response)";
1510
+ const textParts = lastMsg.parts?.filter(
1511
+ (p) => p.type === "text" || p.type === "reasoning"
1512
+ ) ?? [];
1513
+ const result = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n");
1514
+ task.result = result;
1515
+ return result;
1516
+ } catch (error) {
1517
+ return `Error: ${error instanceof Error ? error.message : String(error)}`;
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Set concurrency limit for agent type
1522
+ */
1523
+ setConcurrencyLimit(agentType, limit) {
1524
+ this.concurrency.setLimit(agentType, limit);
1525
+ }
1526
+ /**
1527
+ * Get pending notification count
1528
+ */
1529
+ getPendingCount(parentSessionID) {
1530
+ return this.pendingByParent.get(parentSessionID)?.size ?? 0;
1531
+ }
1532
+ /**
1533
+ * Cleanup all state
1534
+ */
1535
+ cleanup() {
1536
+ this.stopPolling();
1537
+ this.tasks.clear();
1538
+ this.pendingByParent.clear();
1539
+ this.notifications.clear();
1540
+ }
1541
+ // ========================================================================
1542
+ // Internal: Tracking
1543
+ // ========================================================================
1544
+ trackPending(parentSessionID, taskId) {
1545
+ const pending = this.pendingByParent.get(parentSessionID) ?? /* @__PURE__ */ new Set();
1546
+ pending.add(taskId);
1547
+ this.pendingByParent.set(parentSessionID, pending);
1548
+ }
1549
+ untrackPending(parentSessionID, taskId) {
1550
+ const pending = this.pendingByParent.get(parentSessionID);
1551
+ if (pending) {
1552
+ pending.delete(taskId);
1553
+ if (pending.size === 0) {
1554
+ this.pendingByParent.delete(parentSessionID);
1555
+ }
1556
+ }
1557
+ }
1558
+ // ========================================================================
1559
+ // Internal: Error Handling
1560
+ // ========================================================================
1561
+ handleTaskError(taskId, error) {
1562
+ const task = this.tasks.get(taskId);
1563
+ if (!task) return;
1564
+ task.status = "error";
1565
+ task.error = error instanceof Error ? error.message : String(error);
1566
+ task.completedAt = /* @__PURE__ */ new Date();
1567
+ if (task.concurrencyKey) {
1568
+ this.concurrency.release(task.concurrencyKey);
1569
+ }
1570
+ this.untrackPending(task.parentSessionID, taskId);
1571
+ this.queueNotification(task);
1572
+ this.notifyParentIfAllComplete(task.parentSessionID);
1573
+ this.scheduleCleanup(taskId);
1574
+ }
1575
+ // ========================================================================
1576
+ // Internal: Polling
1577
+ // ========================================================================
1578
+ startPolling() {
1579
+ if (this.pollingInterval) return;
1580
+ this.pollingInterval = setInterval(() => {
1581
+ this.pollRunningTasks();
1582
+ }, POLL_INTERVAL_MS);
1583
+ this.pollingInterval.unref();
1584
+ }
1585
+ stopPolling() {
1586
+ if (this.pollingInterval) {
1587
+ clearInterval(this.pollingInterval);
1588
+ this.pollingInterval = void 0;
1589
+ }
1590
+ }
1591
+ async pollRunningTasks() {
1592
+ this.pruneExpiredTasks();
1593
+ const runningTasks = this.getRunningTasks();
1594
+ if (runningTasks.length === 0) {
1595
+ this.stopPolling();
1596
+ return;
1597
+ }
1598
+ try {
1599
+ const statusResult = await this.client.session.status();
1600
+ const allStatuses = statusResult.data ?? {};
1601
+ for (const task of runningTasks) {
1602
+ const sessionStatus = allStatuses[task.sessionID];
1603
+ if (sessionStatus?.type === "idle") {
1604
+ const elapsed = Date.now() - task.startedAt.getTime();
1605
+ if (elapsed < MIN_STABILITY_MS) continue;
1606
+ const hasOutput = await this.validateSessionHasOutput(task.sessionID);
1607
+ if (!hasOutput) continue;
1608
+ task.status = "completed";
1609
+ task.completedAt = /* @__PURE__ */ new Date();
1610
+ if (task.concurrencyKey) {
1611
+ this.concurrency.release(task.concurrencyKey);
1612
+ }
1613
+ this.untrackPending(task.parentSessionID, task.id);
1614
+ this.queueNotification(task);
1615
+ this.notifyParentIfAllComplete(task.parentSessionID);
1616
+ this.scheduleCleanup(task.id);
1617
+ const duration = this.formatDuration(task.startedAt, task.completedAt);
1618
+ console.log(`[parallel] \u2705 COMPLETED ${task.id} \u2192 ${task.agent}: ${task.description} (${duration})`);
1619
+ log(`Completed ${task.id}`);
1620
+ }
1621
+ }
1622
+ } catch (error) {
1623
+ log("Polling error:", error);
1624
+ }
1625
+ }
1626
+ // ========================================================================
1627
+ // Internal: Validation
1628
+ // ========================================================================
1629
+ async validateSessionHasOutput(sessionID) {
1630
+ try {
1631
+ const response = await this.client.session.messages({
1632
+ path: { id: sessionID }
1633
+ });
1634
+ const messages = response.data ?? [];
1635
+ const hasContent = messages.some((m) => {
1636
+ if (m.info?.role !== "assistant") return false;
1637
+ const parts = m.parts ?? [];
1638
+ return parts.some(
1639
+ (p) => p.type === "text" && p.text?.trim() || p.type === "reasoning" && p.text?.trim() || p.type === "tool"
1640
+ );
1641
+ });
1642
+ return hasContent;
1643
+ } catch {
1644
+ return true;
1645
+ }
1646
+ }
1647
+ // ========================================================================
1648
+ // Internal: Cleanup & TTL
1649
+ // ========================================================================
1650
+ pruneExpiredTasks() {
1651
+ const now = Date.now();
1652
+ for (const [taskId, task] of this.tasks.entries()) {
1653
+ const age = now - task.startedAt.getTime();
1654
+ if (age > TASK_TTL_MS) {
1655
+ log(`Timeout: ${taskId} (${Math.round(age / 1e3)}s)`);
1656
+ if (task.status === "running") {
1657
+ task.status = "timeout";
1658
+ task.error = "Task exceeded 30 minute time limit";
1659
+ task.completedAt = /* @__PURE__ */ new Date();
1660
+ if (task.concurrencyKey) {
1661
+ this.concurrency.release(task.concurrencyKey);
1662
+ }
1663
+ this.untrackPending(task.parentSessionID, taskId);
1664
+ console.log(`[parallel] \u23F1\uFE0F TIMEOUT ${taskId} \u2192 ${task.agent}: ${task.description}`);
1665
+ }
1666
+ this.client.session.delete({
1667
+ path: { id: task.sessionID }
1668
+ }).then(() => {
1669
+ console.log(`[parallel] \u{1F5D1}\uFE0F CLEANED ${taskId} (timeout session deleted)`);
1670
+ }).catch(() => {
1671
+ console.log(`[parallel] \u{1F5D1}\uFE0F CLEANED ${taskId} (timeout session already gone)`);
1672
+ });
1673
+ this.tasks.delete(taskId);
1674
+ }
1675
+ }
1676
+ for (const [sessionID, queue] of this.notifications.entries()) {
1677
+ if (queue.length === 0) {
1678
+ this.notifications.delete(sessionID);
1679
+ }
1680
+ }
1681
+ }
1682
+ scheduleCleanup(taskId) {
1683
+ const task = this.tasks.get(taskId);
1684
+ const sessionID = task?.sessionID;
1685
+ setTimeout(async () => {
1686
+ if (sessionID) {
1687
+ try {
1688
+ await this.client.session.delete({
1689
+ path: { id: sessionID }
1690
+ });
1691
+ console.log(`[parallel] \u{1F5D1}\uFE0F CLEANED ${taskId} (session deleted)`);
1692
+ log(`Deleted session ${sessionID}`);
1693
+ } catch {
1694
+ console.log(`[parallel] \u{1F5D1}\uFE0F CLEANED ${taskId} (session already gone)`);
1695
+ }
1696
+ }
1697
+ this.tasks.delete(taskId);
1698
+ log(`Cleaned up ${taskId} from memory`);
1699
+ }, CLEANUP_DELAY_MS);
1700
+ }
1701
+ // ========================================================================
1702
+ // Internal: Notifications
1703
+ // ========================================================================
1704
+ queueNotification(task) {
1705
+ const queue = this.notifications.get(task.parentSessionID) ?? [];
1706
+ queue.push(task);
1707
+ this.notifications.set(task.parentSessionID, queue);
1708
+ }
1709
+ async notifyParentIfAllComplete(parentSessionID) {
1710
+ const pending = this.pendingByParent.get(parentSessionID);
1711
+ if (pending && pending.size > 0) {
1712
+ log(`${pending.size} tasks still pending for ${parentSessionID}`);
1713
+ return;
1714
+ }
1715
+ const completedTasks = this.notifications.get(parentSessionID) ?? [];
1716
+ if (completedTasks.length === 0) return;
1717
+ const summary = completedTasks.map((t) => {
1718
+ const status = t.status === "completed" ? "\u2705" : "\u274C";
1719
+ return `${status} \`${t.id}\`: ${t.description}`;
1720
+ }).join("\n");
1721
+ const notification = `<system-notification>
1722
+ **All Parallel Tasks Complete**
1723
+
1724
+ ${summary}
1725
+
1726
+ Use \`get_task_result({ taskId: "task_xxx" })\` to retrieve results.
1727
+ </system-notification>`;
1728
+ try {
1729
+ await this.client.session.prompt({
1730
+ path: { id: parentSessionID },
1731
+ body: {
1732
+ noReply: true,
1733
+ parts: [{ type: "text", text: notification }]
1734
+ }
1735
+ });
1736
+ log(`Notified parent ${parentSessionID}: ${completedTasks.length} tasks`);
1737
+ } catch (error) {
1738
+ log("Notification error:", error);
1739
+ }
1740
+ this.notifications.delete(parentSessionID);
1741
+ }
1742
+ // ========================================================================
1743
+ // Internal: Formatting
1744
+ // ========================================================================
1745
+ formatDuration(start, end) {
1746
+ const duration = (end ?? /* @__PURE__ */ new Date()).getTime() - start.getTime();
1747
+ const seconds = Math.floor(duration / 1e3);
1748
+ const minutes = Math.floor(seconds / 60);
1749
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
1750
+ return `${seconds}s`;
1751
+ }
1752
+ };
1753
+ var parallelAgentManager = {
1754
+ getInstance: ParallelAgentManager.getInstance.bind(ParallelAgentManager)
1755
+ };
1756
+
1757
+ // src/tools/async-agent.ts
1758
+ import { tool as tool5 } from "@opencode-ai/plugin";
1759
+ var createDelegateTaskTool = (manager, client) => tool5({
1760
+ description: `Delegate a task to an agent.
1761
+
1762
+ <mode>
1763
+ - background=true: Non-blocking. Task runs in parallel session. Use get_task_result later.
1764
+ - background=false: Blocking. Waits for result. Use when you need output immediately.
1765
+ </mode>
1766
+
1767
+ <when_to_use_background_true>
1768
+ - Multiple independent tasks to run in parallel
1769
+ - Long-running tasks (build, test, analysis)
1770
+ - You have other work to do while waiting
1771
+ - Example: "Build module A" + "Test module B" in parallel
1772
+ </when_to_use_background_true>
1773
+
1774
+ <when_to_use_background_false>
1775
+ - You need the result for the very next step
1776
+ - Single task with nothing else to do
1777
+ - Quick questions that return fast
1778
+ </when_to_use_background_false>
1779
+
1780
+ <safety>
1781
+ - Max 3 background tasks per agent type (extras queue automatically)
1782
+ - Auto-timeout: 30 minutes
1783
+ - Auto-cleanup: 5 minutes after completion
1784
+ </safety>`,
1785
+ args: {
1786
+ agent: tool5.schema.string().describe("Agent name (e.g., 'builder', 'inspector', 'architect')"),
1787
+ description: tool5.schema.string().describe("Short task description"),
1788
+ prompt: tool5.schema.string().describe("Full prompt/instructions for the agent"),
1789
+ background: tool5.schema.boolean().describe("true=async (returns task_id), false=sync (waits for result). REQUIRED.")
1790
+ },
1791
+ async execute(args, context) {
1792
+ const { agent, description, prompt, background } = args;
1793
+ const ctx = context;
1794
+ const sessionClient = client;
1795
+ if (background === void 0) {
1796
+ return `\u274C 'background' parameter is REQUIRED.
1797
+ - background=true: Run in parallel, returns task ID
1798
+ - background=false: Wait for result (blocking)`;
1799
+ }
1800
+ if (background === true) {
1801
+ try {
1802
+ const task = await manager.launch({
1803
+ agent,
1804
+ description,
1805
+ prompt,
1806
+ parentSessionID: ctx.sessionID
1807
+ });
1808
+ const runningCount = manager.getRunningTasks().length;
1809
+ const pendingCount = manager.getPendingCount(ctx.sessionID);
1810
+ console.log(`[parallel] \u{1F680} SPAWNED ${task.id} \u2192 ${agent}: ${description}`);
1811
+ return `
1812
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
1813
+ \u2551 \u{1F680} BACKGROUND TASK SPAWNED \u2551
1814
+ \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
1815
+ \u2551 Task ID: ${task.id.padEnd(45)}\u2551
1816
+ \u2551 Agent: ${task.agent.padEnd(45)}\u2551
1817
+ \u2551 Description: ${task.description.slice(0, 45).padEnd(45)}\u2551
1818
+ \u2551 Status: \u23F3 RUNNING (background) \u2551
1819
+ \u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563
1820
+ \u2551 Running: ${String(runningCount).padEnd(5)} \u2502 Pending: ${String(pendingCount).padEnd(5)} \u2551
1821
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
1822
+
1823
+ \u{1F4CC} Continue your work! System notifies when ALL complete.
1824
+ \u{1F50D} Use \`get_task_result({ taskId: "${task.id}" })\` later.`;
1825
+ } catch (error) {
1826
+ const message = error instanceof Error ? error.message : String(error);
1827
+ console.log(`[parallel] \u274C FAILED: ${message}`);
1828
+ return `\u274C Failed to spawn background task: ${message}`;
1829
+ }
1830
+ }
1831
+ console.log(`[delegate] \u23F3 SYNC ${agent}: ${description}`);
1832
+ try {
1833
+ const session = sessionClient.session;
1834
+ const directory = ".";
1835
+ const createResult = await session.create({
1836
+ body: {
1837
+ parentID: ctx.sessionID,
1838
+ title: `Task: ${description}`
1839
+ },
1840
+ query: { directory }
1841
+ });
1842
+ if (createResult.error || !createResult.data?.id) {
1843
+ return `\u274C Failed to create session: ${createResult.error || "No session ID"}`;
1844
+ }
1845
+ const sessionID = createResult.data.id;
1846
+ const startTime = Date.now();
1847
+ try {
1848
+ await session.prompt({
1849
+ path: { id: sessionID },
1850
+ body: {
1851
+ agent,
1852
+ parts: [{ type: "text", text: prompt }]
1853
+ }
1854
+ });
1855
+ } catch (promptError) {
1856
+ const errorMessage = promptError instanceof Error ? promptError.message : String(promptError);
1857
+ return `\u274C Failed to send prompt: ${errorMessage}
1858
+
1859
+ Session ID: ${sessionID}`;
1860
+ }
1861
+ const POLL_INTERVAL_MS2 = 500;
1862
+ const MAX_POLL_TIME_MS = 10 * 60 * 1e3;
1863
+ const MIN_STABILITY_MS2 = 5e3;
1864
+ const STABILITY_POLLS_REQUIRED = 3;
1865
+ let stablePolls = 0;
1866
+ let lastMsgCount = 0;
1867
+ while (Date.now() - startTime < MAX_POLL_TIME_MS) {
1868
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS2));
1869
+ const statusResult = await session.status();
1870
+ const allStatuses = statusResult.data ?? {};
1871
+ const sessionStatus = allStatuses[sessionID];
1872
+ if (sessionStatus?.type !== "idle") {
1873
+ stablePolls = 0;
1874
+ lastMsgCount = 0;
1875
+ continue;
1876
+ }
1877
+ if (Date.now() - startTime < MIN_STABILITY_MS2) continue;
1878
+ const messagesResult2 = await session.messages({ path: { id: sessionID } });
1879
+ const messages2 = messagesResult2.data ?? [];
1880
+ const currentMsgCount = messages2.length;
1881
+ if (currentMsgCount === lastMsgCount) {
1882
+ stablePolls++;
1883
+ if (stablePolls >= STABILITY_POLLS_REQUIRED) break;
1884
+ } else {
1885
+ stablePolls = 0;
1886
+ lastMsgCount = currentMsgCount;
1887
+ }
1888
+ }
1889
+ const messagesResult = await session.messages({ path: { id: sessionID } });
1890
+ const messages = messagesResult.data ?? [];
1891
+ const assistantMsgs = messages.filter((m) => m.info?.role === "assistant").reverse();
1892
+ const lastMsg = assistantMsgs[0];
1893
+ if (!lastMsg) {
1894
+ return `\u274C No assistant response found.
1895
+
1896
+ Session ID: ${sessionID}`;
1897
+ }
1898
+ const textParts = lastMsg.parts?.filter(
1899
+ (p) => p.type === "text" || p.type === "reasoning"
1900
+ ) ?? [];
1901
+ const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n");
1902
+ const duration = Math.floor((Date.now() - startTime) / 1e3);
1903
+ console.log(`[delegate] \u2705 COMPLETED ${agent}: ${description} (${duration}s)`);
1904
+ return `\u2705 **Task Completed** (${duration}s)
1905
+
1906
+ Agent: ${agent}
1907
+ Session ID: ${sessionID}
1908
+
1909
+ ---
1910
+
1911
+ ${textContent || "(No text output)"}`;
1912
+ } catch (error) {
1913
+ const message = error instanceof Error ? error.message : String(error);
1914
+ console.log(`[delegate] \u274C FAILED: ${message}`);
1915
+ return `\u274C Task failed: ${message}`;
1916
+ }
1917
+ }
1918
+ });
1919
+ var createGetTaskResultTool = (manager) => tool5({
1920
+ description: `Get the result from a completed background task.
1921
+
1922
+ <note>
1923
+ If the task is still running, returns status info.
1924
+ Wait for the "All Complete" notification before checking.
1925
+ </note>`,
1926
+ args: {
1927
+ taskId: tool5.schema.string().describe("Task ID from delegate_task (e.g., 'task_a1b2c3d4')")
1928
+ },
1929
+ async execute(args) {
1930
+ const { taskId } = args;
1931
+ const task = manager.getTask(taskId);
1932
+ if (!task) {
1933
+ return `\u274C Task not found: \`${taskId}\`
1934
+
1935
+ Use \`list_tasks\` to see available tasks.`;
1936
+ }
1937
+ if (task.status === "running") {
1938
+ const elapsed = Math.floor((Date.now() - task.startedAt.getTime()) / 1e3);
1939
+ return `\u23F3 **Task Still Running**
1940
+
1941
+ | Property | Value |
1942
+ |----------|-------|
1943
+ | **Task ID** | \`${taskId}\` |
1944
+ | **Agent** | ${task.agent} |
1945
+ | **Elapsed** | ${elapsed}s |
1946
+
1947
+ Wait for "All Complete" notification, then try again.`;
1948
+ }
1949
+ const result = await manager.getResult(taskId);
1950
+ const duration = manager.formatDuration(task.startedAt, task.completedAt);
1951
+ if (task.status === "error" || task.status === "timeout") {
1952
+ return `\u274C **Task ${task.status === "timeout" ? "Timed Out" : "Failed"}**
1953
+
1954
+ | Property | Value |
1955
+ |----------|-------|
1956
+ | **Task ID** | \`${taskId}\` |
1957
+ | **Agent** | ${task.agent} |
1958
+ | **Error** | ${task.error} |
1959
+ | **Duration** | ${duration} |`;
1960
+ }
1961
+ return `\u2705 **Task Completed**
1962
+
1963
+ | Property | Value |
1964
+ |----------|-------|
1965
+ | **Task ID** | \`${taskId}\` |
1966
+ | **Agent** | ${task.agent} |
1967
+ | **Duration** | ${duration} |
1968
+
1969
+ ---
1970
+
1971
+ **Result:**
1972
+
1973
+ ${result || "(No output)"}`;
1974
+ }
1975
+ });
1976
+ var createListTasksTool = (manager) => tool5({
1977
+ description: `List all background tasks and their status.`,
1978
+ args: {
1979
+ status: tool5.schema.string().optional().describe("Filter: 'all', 'running', 'completed', 'error'")
1980
+ },
1981
+ async execute(args) {
1982
+ const { status = "all" } = args;
1983
+ let tasks;
1984
+ switch (status) {
1985
+ case "running":
1986
+ tasks = manager.getRunningTasks();
1987
+ break;
1988
+ case "completed":
1989
+ tasks = manager.getAllTasks().filter((t) => t.status === "completed");
1990
+ break;
1991
+ case "error":
1992
+ tasks = manager.getAllTasks().filter((t) => t.status === "error" || t.status === "timeout");
1993
+ break;
1994
+ default:
1995
+ tasks = manager.getAllTasks();
1996
+ }
1997
+ if (tasks.length === 0) {
1998
+ return `\u{1F4CB} No background tasks found${status !== "all" ? ` (filter: ${status})` : ""}.
1999
+
2000
+ Use \`delegate_task({ ..., background: true })\` to spawn background tasks.`;
2001
+ }
2002
+ const runningCount = manager.getRunningTasks().length;
2003
+ const completedCount = manager.getAllTasks().filter((t) => t.status === "completed").length;
2004
+ const errorCount = manager.getAllTasks().filter((t) => t.status === "error" || t.status === "timeout").length;
2005
+ const statusIcon = (s) => {
2006
+ switch (s) {
2007
+ case "running":
2008
+ return "\u23F3";
2009
+ case "completed":
2010
+ return "\u2705";
2011
+ case "error":
2012
+ return "\u274C";
2013
+ case "timeout":
2014
+ return "\u23F1\uFE0F";
2015
+ default:
2016
+ return "\u2753";
2017
+ }
2018
+ };
2019
+ const rows = tasks.map((t) => {
2020
+ const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1e3);
2021
+ const desc = t.description.length > 25 ? t.description.slice(0, 22) + "..." : t.description;
2022
+ return `| \`${t.id}\` | ${statusIcon(t.status)} ${t.status} | ${t.agent} | ${desc} | ${elapsed}s |`;
2023
+ }).join("\n");
2024
+ return `\u{1F4CB} **Background Tasks** (${tasks.length} shown)
2025
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
2026
+ | \u23F3 Running: ${runningCount} | \u2705 Completed: ${completedCount} | \u274C Error: ${errorCount} |
2027
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
2028
+
2029
+ | Task ID | Status | Agent | Description | Elapsed |
2030
+ |---------|--------|-------|-------------|---------|
2031
+ ${rows}
2032
+
2033
+ \u{1F4A1} Use \`get_task_result({ taskId: "task_xxx" })\` to get results.
2034
+ \u{1F6D1} Use \`cancel_task({ taskId: "task_xxx" })\` to stop a running task.`;
2035
+ }
2036
+ });
2037
+ var createCancelTaskTool = (manager) => tool5({
2038
+ description: `Cancel a running background task.
2039
+
2040
+ <purpose>
2041
+ Stop a runaway or no-longer-needed task.
2042
+ Frees up concurrency slot for other tasks.
2043
+ </purpose>`,
2044
+ args: {
2045
+ taskId: tool5.schema.string().describe("Task ID to cancel (e.g., 'task_a1b2c3d4')")
2046
+ },
2047
+ async execute(args) {
2048
+ const { taskId } = args;
2049
+ const cancelled = await manager.cancelTask(taskId);
2050
+ if (cancelled) {
2051
+ return `\u{1F6D1} **Task Cancelled**
2052
+
2053
+ Task \`${taskId}\` has been stopped. Concurrency slot released.`;
2054
+ }
2055
+ const task = manager.getTask(taskId);
2056
+ if (task) {
2057
+ return `\u26A0\uFE0F Cannot cancel: Task \`${taskId}\` is ${task.status} (not running).`;
2058
+ }
2059
+ return `\u274C Task \`${taskId}\` not found.
2060
+
2061
+ Use \`list_tasks\` to see available tasks.`;
2062
+ }
2063
+ });
2064
+ function createAsyncAgentTools(manager, client) {
2065
+ return {
2066
+ delegate_task: createDelegateTaskTool(manager, client),
2067
+ get_task_result: createGetTaskResultTool(manager),
2068
+ list_tasks: createListTasksTool(manager),
2069
+ cancel_task: createCancelTaskTool(manager)
2070
+ };
2071
+ }
775
2072
 
776
2073
  // src/utils/common.ts
777
2074
  function detectSlashCommand(text) {
@@ -779,8 +2076,129 @@ function detectSlashCommand(text) {
779
2076
  if (!match) return null;
780
2077
  return { command: match[1], args: match[2] || "" };
781
2078
  }
2079
+ function formatTimestamp(date = /* @__PURE__ */ new Date()) {
2080
+ const pad = (n) => n.toString().padStart(2, "0");
2081
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
2082
+ }
2083
+ function formatElapsedTime(startMs, endMs = Date.now()) {
2084
+ const elapsed = endMs - startMs;
2085
+ if (elapsed < 0) return "0s";
2086
+ const seconds = Math.floor(elapsed / 1e3) % 60;
2087
+ const minutes = Math.floor(elapsed / (1e3 * 60)) % 60;
2088
+ const hours = Math.floor(elapsed / (1e3 * 60 * 60));
2089
+ const parts = [];
2090
+ if (hours > 0) parts.push(`${hours}h`);
2091
+ if (minutes > 0) parts.push(`${minutes}m`);
2092
+ if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
2093
+ return parts.join(" ");
2094
+ }
2095
+
2096
+ // src/utils/sanity.ts
2097
+ function checkOutputSanity(text) {
2098
+ if (!text || text.length < 50) {
2099
+ return { isHealthy: true, severity: "ok" };
2100
+ }
2101
+ if (/(.)\1{15,}/.test(text)) {
2102
+ return {
2103
+ isHealthy: false,
2104
+ reason: "Single character repetition detected",
2105
+ severity: "critical"
2106
+ };
2107
+ }
2108
+ if (/(.{2,6})\1{8,}/.test(text)) {
2109
+ return {
2110
+ isHealthy: false,
2111
+ reason: "Pattern loop detected",
2112
+ severity: "critical"
2113
+ };
2114
+ }
2115
+ if (text.length > 200) {
2116
+ const cleanText = text.replace(/\s/g, "");
2117
+ if (cleanText.length > 100) {
2118
+ const uniqueChars = new Set(cleanText).size;
2119
+ const ratio = uniqueChars / cleanText.length;
2120
+ if (ratio < 0.02) {
2121
+ return {
2122
+ isHealthy: false,
2123
+ reason: "Low information density",
2124
+ severity: "critical"
2125
+ };
2126
+ }
2127
+ }
2128
+ }
2129
+ const boxChars = (text.match(/[\u2500-\u257f\u2580-\u259f\u2800-\u28ff]/g) || []).length;
2130
+ if (boxChars > 100 && boxChars / text.length > 0.3) {
2131
+ return {
2132
+ isHealthy: false,
2133
+ reason: "Visual gibberish detected",
2134
+ severity: "critical"
2135
+ };
2136
+ }
2137
+ const lines = text.split("\n").filter((l) => l.trim().length > 10);
2138
+ if (lines.length > 10) {
2139
+ const lineSet = new Set(lines);
2140
+ if (lineSet.size < lines.length * 0.2) {
2141
+ return {
2142
+ isHealthy: false,
2143
+ reason: "Excessive line repetition",
2144
+ severity: "warning"
2145
+ };
2146
+ }
2147
+ }
2148
+ const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
2149
+ if (cjkChars > 200) {
2150
+ const uniqueCjk = new Set(
2151
+ text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []
2152
+ ).size;
2153
+ if (uniqueCjk < 10 && cjkChars / uniqueCjk > 20) {
2154
+ return {
2155
+ isHealthy: false,
2156
+ reason: "CJK character spam detected",
2157
+ severity: "critical"
2158
+ };
2159
+ }
2160
+ }
2161
+ return { isHealthy: true, severity: "ok" };
2162
+ }
2163
+ var RECOVERY_PROMPT = `<anomaly_recovery>
2164
+ \u26A0\uFE0F SYSTEM NOTICE: Previous output was malformed (gibberish/loop detected).
2165
+
2166
+ <recovery_protocol>
2167
+ 1. DISCARD the corrupted output completely - do not reference it
2168
+ 2. RECALL the original mission objective
2169
+ 3. IDENTIFY the last confirmed successful step
2170
+ 4. RESTART with a simpler, more focused approach
2171
+ </recovery_protocol>
2172
+
2173
+ <instructions>
2174
+ - If a sub-agent produced bad output: try a different agent or simpler task
2175
+ - If stuck in a loop: break down the task into smaller pieces
2176
+ - If context seems corrupted: call recorder to restore context
2177
+ - THINK in English for maximum stability
2178
+ </instructions>
2179
+
2180
+ What was the original task? Proceed from the last known good state.
2181
+ </anomaly_recovery>`;
2182
+ var ESCALATION_PROMPT = `<critical_anomaly>
2183
+ \u{1F6A8} CRITICAL: Multiple consecutive malformed outputs detected.
2184
+
2185
+ <emergency_protocol>
2186
+ 1. STOP current execution path immediately
2187
+ 2. DO NOT continue with the same approach - it is failing
2188
+ 3. CALL architect for a completely new strategy
2189
+ 4. If architect also fails: report status to user and await guidance
2190
+ </emergency_protocol>
2191
+
2192
+ <diagnosis>
2193
+ The current approach is producing corrupted output.
2194
+ This may indicate: context overload, model instability, or task complexity.
2195
+ </diagnosis>
2196
+
2197
+ Request a fresh plan from architect with reduced scope.
2198
+ </critical_anomaly>`;
782
2199
 
783
2200
  // src/index.ts
2201
+ var PLUGIN_VERSION = "0.2.4";
784
2202
  var DEFAULT_MAX_STEPS = 500;
785
2203
  var TASK_COMMAND_MAX_STEPS = 1e3;
786
2204
  var AGENT_EMOJI2 = {
@@ -808,14 +2226,32 @@ Execute it NOW.
808
2226
  </auto_continue>`;
809
2227
  var OrchestratorPlugin = async (input) => {
810
2228
  const { directory, client } = input;
2229
+ console.log(`[orchestrator] v${PLUGIN_VERSION} loaded`);
811
2230
  const sessions = /* @__PURE__ */ new Map();
2231
+ const parallelAgentManager2 = ParallelAgentManager.getInstance(client, directory);
2232
+ const asyncAgentTools = createAsyncAgentTools(parallelAgentManager2, client);
812
2233
  return {
2234
+ // -----------------------------------------------------------------
2235
+ // Tools we expose to the LLM
2236
+ // -----------------------------------------------------------------
813
2237
  tool: {
814
2238
  call_agent: callAgentTool,
815
2239
  slashcommand: createSlashcommandTool(),
816
2240
  grep_search: grepSearchTool(directory),
817
- glob_search: globSearchTool(directory)
2241
+ glob_search: globSearchTool(directory),
2242
+ mgrep: mgrepTool(directory),
2243
+ // Multi-pattern grep (parallel, Rust-powered)
2244
+ // Background task tools - run shell commands asynchronously
2245
+ run_background: runBackgroundTool,
2246
+ check_background: checkBackgroundTool,
2247
+ list_background: listBackgroundTool,
2248
+ kill_background: killBackgroundTool,
2249
+ // Async agent tools - spawn agents in parallel sessions
2250
+ ...asyncAgentTools
818
2251
  },
2252
+ // -----------------------------------------------------------------
2253
+ // Config hook - registers our commands and agents with OpenCode
2254
+ // -----------------------------------------------------------------
819
2255
  config: async (config) => {
820
2256
  const existingCommands = config.command ?? {};
821
2257
  const existingAgents = config.agent ?? {};
@@ -837,6 +2273,10 @@ var OrchestratorPlugin = async (input) => {
837
2273
  config.command = { ...orchestratorCommands, ...existingCommands };
838
2274
  config.agent = { ...orchestratorAgents, ...existingAgents };
839
2275
  },
2276
+ // -----------------------------------------------------------------
2277
+ // chat.message hook - runs when user sends a message
2278
+ // This is where we intercept commands and set up sessions
2279
+ // -----------------------------------------------------------------
840
2280
  "chat.message": async (msgInput, msgOutput) => {
841
2281
  const parts = msgOutput.parts;
842
2282
  const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text);
@@ -846,18 +2286,22 @@ var OrchestratorPlugin = async (input) => {
846
2286
  const sessionID = msgInput.sessionID;
847
2287
  const agentName = (msgInput.agent || "").toLowerCase();
848
2288
  if (agentName === "commander" && !sessions.has(sessionID)) {
2289
+ const now = Date.now();
849
2290
  sessions.set(sessionID, {
850
2291
  active: true,
851
2292
  step: 0,
852
2293
  maxSteps: DEFAULT_MAX_STEPS,
853
- timestamp: Date.now()
2294
+ timestamp: now,
2295
+ startTime: now,
2296
+ lastStepTime: now
854
2297
  });
855
2298
  state.missionActive = true;
856
2299
  state.sessions.set(sessionID, {
857
2300
  enabled: true,
858
2301
  iterations: 0,
859
2302
  taskRetries: /* @__PURE__ */ new Map(),
860
- currentTask: ""
2303
+ currentTask: "",
2304
+ anomalyCount: 0
861
2305
  });
862
2306
  if (!parsed) {
863
2307
  const userMessage = originalText.trim();
@@ -870,18 +2314,22 @@ var OrchestratorPlugin = async (input) => {
870
2314
  }
871
2315
  }
872
2316
  if (parsed?.command === "task") {
2317
+ const now = Date.now();
873
2318
  sessions.set(sessionID, {
874
2319
  active: true,
875
2320
  step: 0,
876
2321
  maxSteps: TASK_COMMAND_MAX_STEPS,
877
- timestamp: Date.now()
2322
+ timestamp: now,
2323
+ startTime: now,
2324
+ lastStepTime: now
878
2325
  });
879
2326
  state.missionActive = true;
880
2327
  state.sessions.set(sessionID, {
881
2328
  enabled: true,
882
2329
  iterations: 0,
883
2330
  taskRetries: /* @__PURE__ */ new Map(),
884
- currentTask: ""
2331
+ currentTask: "",
2332
+ anomalyCount: 0
885
2333
  });
886
2334
  parts[textPartIndex].text = COMMANDS["task"].template.replace(
887
2335
  /\$ARGUMENTS/g,
@@ -897,12 +2345,43 @@ var OrchestratorPlugin = async (input) => {
897
2345
  }
898
2346
  }
899
2347
  },
2348
+ // -----------------------------------------------------------------
2349
+ // tool.execute.after hook - runs after any tool call completes
2350
+ // We use this to track progress and detect problems
2351
+ // -----------------------------------------------------------------
900
2352
  "tool.execute.after": async (toolInput, toolOutput) => {
901
2353
  const session = sessions.get(toolInput.sessionID);
902
2354
  if (!session?.active) return;
2355
+ const now = Date.now();
2356
+ const stepDuration = formatElapsedTime(session.lastStepTime, now);
2357
+ const totalElapsed = formatElapsedTime(session.startTime, now);
903
2358
  session.step++;
904
- session.timestamp = Date.now();
2359
+ session.timestamp = now;
2360
+ session.lastStepTime = now;
905
2361
  const stateSession = state.sessions.get(toolInput.sessionID);
2362
+ if (toolInput.tool === "call_agent" && stateSession) {
2363
+ const sanityResult = checkOutputSanity(toolOutput.output);
2364
+ if (!sanityResult.isHealthy) {
2365
+ stateSession.anomalyCount = (stateSession.anomalyCount || 0) + 1;
2366
+ const agentName = toolInput.arguments?.agent || "unknown";
2367
+ toolOutput.output = `\u26A0\uFE0F [${agentName.toUpperCase()}] OUTPUT ANOMALY DETECTED
2368
+
2369
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
2370
+ \u26A0\uFE0F Gibberish/loop detected: ${sanityResult.reason}
2371
+ Anomaly count: ${stateSession.anomalyCount}
2372
+ \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501
2373
+
2374
+ ` + (stateSession.anomalyCount >= 2 ? ESCALATION_PROMPT : RECOVERY_PROMPT);
2375
+ return;
2376
+ } else {
2377
+ if (stateSession.anomalyCount > 0) {
2378
+ stateSession.anomalyCount = 0;
2379
+ }
2380
+ if (toolOutput.output.length < 5e3) {
2381
+ stateSession.lastHealthyOutput = toolOutput.output.substring(0, 1e3);
2382
+ }
2383
+ }
2384
+ }
906
2385
  if (toolInput.tool === "call_agent" && toolInput.arguments?.task && stateSession) {
907
2386
  const taskIdMatch = toolInput.arguments.task.match(/\[(TASK-\d+)\]/i);
908
2387
  if (taskIdMatch) {
@@ -973,16 +2452,54 @@ ${stateSession.graph.getTaskSummary()}`;
973
2452
  \u{1F449} NEXT: ${readyTasks.map((t) => `[${t.id}]`).join(", ")}`;
974
2453
  }
975
2454
  }
2455
+ const currentTime = formatTimestamp();
976
2456
  toolOutput.output += `
977
2457
 
978
- [${session.step}/${session.maxSteps}]`;
2458
+ \u23F1\uFE0F [${currentTime}] Step ${session.step}/${session.maxSteps} | This step: ${stepDuration} | Total: ${totalElapsed}`;
979
2459
  },
2460
+ // -----------------------------------------------------------------
2461
+ // assistant.done hook - runs when the LLM finishes responding
2462
+ // This is the heart of the "relentless loop" - we keep pushing it
2463
+ // to continue until we see MISSION COMPLETE or hit the limit
2464
+ // -----------------------------------------------------------------
980
2465
  "assistant.done": async (assistantInput, assistantOutput) => {
981
2466
  const sessionID = assistantInput.sessionID;
982
2467
  const session = sessions.get(sessionID);
983
2468
  if (!session?.active) return;
984
2469
  const parts = assistantOutput.parts;
985
2470
  const textContent = parts?.filter((p) => p.type === "text" || p.type === "reasoning").map((p) => p.text || "").join("\n") || "";
2471
+ const stateSession = state.sessions.get(sessionID);
2472
+ const sanityResult = checkOutputSanity(textContent);
2473
+ if (!sanityResult.isHealthy && stateSession) {
2474
+ stateSession.anomalyCount = (stateSession.anomalyCount || 0) + 1;
2475
+ session.step++;
2476
+ session.timestamp = Date.now();
2477
+ const recoveryText = stateSession.anomalyCount >= 2 ? ESCALATION_PROMPT : RECOVERY_PROMPT;
2478
+ try {
2479
+ if (client?.session?.prompt) {
2480
+ await client.session.prompt({
2481
+ path: { id: sessionID },
2482
+ body: {
2483
+ parts: [{
2484
+ type: "text",
2485
+ text: `\u26A0\uFE0F ANOMALY #${stateSession.anomalyCount}: ${sanityResult.reason}
2486
+
2487
+ ` + recoveryText + `
2488
+
2489
+ [Recovery Step ${session.step}/${session.maxSteps}]`
2490
+ }]
2491
+ }
2492
+ });
2493
+ }
2494
+ } catch {
2495
+ session.active = false;
2496
+ state.missionActive = false;
2497
+ }
2498
+ return;
2499
+ }
2500
+ if (stateSession && stateSession.anomalyCount > 0) {
2501
+ stateSession.anomalyCount = 0;
2502
+ }
986
2503
  if (textContent.includes("\u2705 MISSION COMPLETE") || textContent.includes("MISSION COMPLETE")) {
987
2504
  session.active = false;
988
2505
  state.missionActive = false;
@@ -997,8 +2514,13 @@ ${stateSession.graph.getTaskSummary()}`;
997
2514
  state.sessions.delete(sessionID);
998
2515
  return;
999
2516
  }
2517
+ const now = Date.now();
2518
+ const stepDuration = formatElapsedTime(session.lastStepTime, now);
2519
+ const totalElapsed = formatElapsedTime(session.startTime, now);
1000
2520
  session.step++;
1001
- session.timestamp = Date.now();
2521
+ session.timestamp = now;
2522
+ session.lastStepTime = now;
2523
+ const currentTime = formatTimestamp();
1002
2524
  if (session.step >= session.maxSteps) {
1003
2525
  session.active = false;
1004
2526
  state.missionActive = false;
@@ -1013,7 +2535,7 @@ ${stateSession.graph.getTaskSummary()}`;
1013
2535
  type: "text",
1014
2536
  text: CONTINUE_INSTRUCTION + `
1015
2537
 
1016
- [Step ${session.step}/${session.maxSteps}]`
2538
+ \u23F1\uFE0F [${currentTime}] Step ${session.step}/${session.maxSteps} | This step: ${stepDuration} | Total: ${totalElapsed}`
1017
2539
  }]
1018
2540
  }
1019
2541
  });
@@ -1033,6 +2555,9 @@ ${stateSession.graph.getTaskSummary()}`;
1033
2555
  }
1034
2556
  }
1035
2557
  },
2558
+ // -----------------------------------------------------------------
2559
+ // Event handler - cleans up when sessions are deleted
2560
+ // -----------------------------------------------------------------
1036
2561
  handler: async ({ event }) => {
1037
2562
  if (event.type === "session.deleted") {
1038
2563
  const props = event.properties;