awb-agent-manager 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/dist/lib/agent-lockfile.d.ts +37 -0
  4. package/dist/lib/agent-lockfile.js +205 -0
  5. package/dist/lib/agent-lockfile.js.map +1 -0
  6. package/dist/lib/agent-manager-commands.d.ts +34 -0
  7. package/dist/lib/agent-manager-commands.js +531 -0
  8. package/dist/lib/agent-manager-commands.js.map +1 -0
  9. package/dist/lib/base-session-manager.d.ts +127 -0
  10. package/dist/lib/base-session-manager.js +667 -0
  11. package/dist/lib/base-session-manager.js.map +1 -0
  12. package/dist/lib/chat-attachment-prep.d.ts +35 -0
  13. package/dist/lib/chat-attachment-prep.js +180 -0
  14. package/dist/lib/chat-attachment-prep.js.map +1 -0
  15. package/dist/lib/chat-session-manager.d.ts +29 -0
  16. package/dist/lib/chat-session-manager.js +400 -0
  17. package/dist/lib/chat-session-manager.js.map +1 -0
  18. package/dist/lib/circuit-breaker.d.ts +34 -0
  19. package/dist/lib/circuit-breaker.js +146 -0
  20. package/dist/lib/circuit-breaker.js.map +1 -0
  21. package/dist/lib/cli-adapters/antigravity.d.ts +15 -0
  22. package/dist/lib/cli-adapters/antigravity.js +114 -0
  23. package/dist/lib/cli-adapters/antigravity.js.map +1 -0
  24. package/dist/lib/cli-adapters/base.d.ts +115 -0
  25. package/dist/lib/cli-adapters/base.js +111 -0
  26. package/dist/lib/cli-adapters/base.js.map +1 -0
  27. package/dist/lib/cli-adapters/claude.d.ts +19 -0
  28. package/dist/lib/cli-adapters/claude.js +233 -0
  29. package/dist/lib/cli-adapters/claude.js.map +1 -0
  30. package/dist/lib/cli-adapters/codex.d.ts +14 -0
  31. package/dist/lib/cli-adapters/codex.js +152 -0
  32. package/dist/lib/cli-adapters/codex.js.map +1 -0
  33. package/dist/lib/cli-adapters/deepseek.d.ts +15 -0
  34. package/dist/lib/cli-adapters/deepseek.js +70 -0
  35. package/dist/lib/cli-adapters/deepseek.js.map +1 -0
  36. package/dist/lib/cli-adapters/index.d.ts +8 -0
  37. package/dist/lib/cli-adapters/index.js +27 -0
  38. package/dist/lib/cli-adapters/index.js.map +1 -0
  39. package/dist/lib/cli-adapters/model-introspect.d.ts +3 -0
  40. package/dist/lib/cli-adapters/model-introspect.js +65 -0
  41. package/dist/lib/cli-adapters/model-introspect.js.map +1 -0
  42. package/dist/lib/cli-error-signatures.d.ts +9 -0
  43. package/dist/lib/cli-error-signatures.js +22 -0
  44. package/dist/lib/cli-error-signatures.js.map +1 -0
  45. package/dist/lib/cli-output-summary.d.ts +2 -0
  46. package/dist/lib/cli-output-summary.js +104 -0
  47. package/dist/lib/cli-output-summary.js.map +1 -0
  48. package/dist/lib/cli-resolver.d.ts +2 -0
  49. package/dist/lib/cli-resolver.js +177 -0
  50. package/dist/lib/cli-resolver.js.map +1 -0
  51. package/dist/lib/config.d.ts +13 -0
  52. package/dist/lib/config.js +124 -0
  53. package/dist/lib/config.js.map +1 -0
  54. package/dist/lib/constants.d.ts +36 -0
  55. package/dist/lib/constants.js +51 -0
  56. package/dist/lib/constants.js.map +1 -0
  57. package/dist/lib/environment-provisioner.d.ts +31 -0
  58. package/dist/lib/environment-provisioner.js +182 -0
  59. package/dist/lib/environment-provisioner.js.map +1 -0
  60. package/dist/lib/error-log-uploader.d.ts +19 -0
  61. package/dist/lib/error-log-uploader.js +138 -0
  62. package/dist/lib/error-log-uploader.js.map +1 -0
  63. package/dist/lib/event-dispatcher.d.ts +149 -0
  64. package/dist/lib/event-dispatcher.js +799 -0
  65. package/dist/lib/event-dispatcher.js.map +1 -0
  66. package/dist/lib/event-log-recorder.d.ts +11 -0
  67. package/dist/lib/event-log-recorder.js +77 -0
  68. package/dist/lib/event-log-recorder.js.map +1 -0
  69. package/dist/lib/event-stream.d.ts +26 -0
  70. package/dist/lib/event-stream.js +177 -0
  71. package/dist/lib/event-stream.js.map +1 -0
  72. package/dist/lib/fs-browser.d.ts +30 -0
  73. package/dist/lib/fs-browser.js +287 -0
  74. package/dist/lib/fs-browser.js.map +1 -0
  75. package/dist/lib/host-mcp/logs.d.ts +19 -0
  76. package/dist/lib/host-mcp/logs.js +102 -0
  77. package/dist/lib/host-mcp/logs.js.map +1 -0
  78. package/dist/lib/host-mcp/platform.d.ts +24 -0
  79. package/dist/lib/host-mcp/platform.js +133 -0
  80. package/dist/lib/host-mcp/platform.js.map +1 -0
  81. package/dist/lib/host-mcp/screens.d.ts +22 -0
  82. package/dist/lib/host-mcp/screens.js +172 -0
  83. package/dist/lib/host-mcp/screens.js.map +1 -0
  84. package/dist/lib/host-mcp/server.d.ts +3 -0
  85. package/dist/lib/host-mcp/server.js +233 -0
  86. package/dist/lib/host-mcp/server.js.map +1 -0
  87. package/dist/lib/host-mcp/tools.d.ts +172 -0
  88. package/dist/lib/host-mcp/tools.js +1241 -0
  89. package/dist/lib/host-mcp/tools.js.map +1 -0
  90. package/dist/lib/instance-heartbeat.d.ts +55 -0
  91. package/dist/lib/instance-heartbeat.js +118 -0
  92. package/dist/lib/instance-heartbeat.js.map +1 -0
  93. package/dist/lib/legacy-import.d.ts +17 -0
  94. package/dist/lib/legacy-import.js +70 -0
  95. package/dist/lib/legacy-import.js.map +1 -0
  96. package/dist/lib/logging.d.ts +3 -0
  97. package/dist/lib/logging.js +65 -0
  98. package/dist/lib/logging.js.map +1 -0
  99. package/dist/lib/managed-agent-context.d.ts +25 -0
  100. package/dist/lib/managed-agent-context.js +33 -0
  101. package/dist/lib/managed-agent-context.js.map +1 -0
  102. package/dist/lib/managed-agent-store.d.ts +34 -0
  103. package/dist/lib/managed-agent-store.js +160 -0
  104. package/dist/lib/managed-agent-store.js.map +1 -0
  105. package/dist/lib/managed-agents.d.ts +24 -0
  106. package/dist/lib/managed-agents.js +87 -0
  107. package/dist/lib/managed-agents.js.map +1 -0
  108. package/dist/lib/mcp-client.d.ts +8 -0
  109. package/dist/lib/mcp-client.js +122 -0
  110. package/dist/lib/mcp-client.js.map +1 -0
  111. package/dist/lib/orphan-cleanup.d.ts +6 -0
  112. package/dist/lib/orphan-cleanup.js +108 -0
  113. package/dist/lib/orphan-cleanup.js.map +1 -0
  114. package/dist/lib/presence-heartbeat.d.ts +8 -0
  115. package/dist/lib/presence-heartbeat.js +61 -0
  116. package/dist/lib/presence-heartbeat.js.map +1 -0
  117. package/dist/lib/prompts.d.ts +29 -0
  118. package/dist/lib/prompts.js +208 -0
  119. package/dist/lib/prompts.js.map +1 -0
  120. package/dist/lib/rest.d.ts +61 -0
  121. package/dist/lib/rest.js +307 -0
  122. package/dist/lib/rest.js.map +1 -0
  123. package/dist/lib/run-provisioner.d.ts +21 -0
  124. package/dist/lib/run-provisioner.js +115 -0
  125. package/dist/lib/run-provisioner.js.map +1 -0
  126. package/dist/lib/self-path.d.ts +7 -0
  127. package/dist/lib/self-path.js +29 -0
  128. package/dist/lib/self-path.js.map +1 -0
  129. package/dist/lib/self-update.d.ts +46 -0
  130. package/dist/lib/self-update.js +482 -0
  131. package/dist/lib/self-update.js.map +1 -0
  132. package/dist/lib/service-install.d.ts +23 -0
  133. package/dist/lib/service-install.js +673 -0
  134. package/dist/lib/service-install.js.map +1 -0
  135. package/dist/lib/setup.d.ts +16 -0
  136. package/dist/lib/setup.js +157 -0
  137. package/dist/lib/setup.js.map +1 -0
  138. package/dist/lib/subagent-manager.d.ts +84 -0
  139. package/dist/lib/subagent-manager.js +793 -0
  140. package/dist/lib/subagent-manager.js.map +1 -0
  141. package/dist/lib/subagent-monitor.d.ts +43 -0
  142. package/dist/lib/subagent-monitor.js +229 -0
  143. package/dist/lib/subagent-monitor.js.map +1 -0
  144. package/dist/lib/ticket-session-manager.d.ts +23 -0
  145. package/dist/lib/ticket-session-manager.js +471 -0
  146. package/dist/lib/ticket-session-manager.js.map +1 -0
  147. package/dist/lib/worktree-manager.d.ts +39 -0
  148. package/dist/lib/worktree-manager.js +226 -0
  149. package/dist/lib/worktree-manager.js.map +1 -0
  150. package/dist/main.d.ts +2 -0
  151. package/dist/main.js +670 -0
  152. package/dist/main.js.map +1 -0
  153. package/dist/package.json +57 -0
  154. package/package.json +57 -0
@@ -0,0 +1,793 @@
1
+ import { promises as fsp } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { spawn } from 'node:child_process';
5
+ import { SUBAGENTS_BASE_DIR, SUBAGENTS_PERSIST_PATH, TTL_SWEEP_INTERVAL_MS, SIGTERM_GRACE_MS, STOP_GRACE_MS, } from './constants.js';
6
+ import { log } from './logging.js';
7
+ import { createAdapter } from './cli-adapters/index.js';
8
+ import { ADAPTER_CAPABILITIES, describeHarness, partitionHarness, selectEffortSlice, } from './cli-adapters/base.js';
9
+ import { CircuitBreaker } from './circuit-breaker.js';
10
+ import { classifyCliError } from './cli-error-signatures.js';
11
+ import { summarizeCliJsonLine } from './cli-output-summary.js';
12
+ import { fireAndForgetTool } from './mcp-client.js';
13
+ import { postChatRoomMessage, postSilentExitSystemComment, } from './rest.js';
14
+ const { NATIVE_MCP } = ADAPTER_CAPABILITIES;
15
+ const TAIL_RING_MAX_LINES = 100;
16
+ const SILENT_EXIT_TAIL_MAX_CHARS = 4096;
17
+ const TICKET_COMMENT_TOOL_SUFFIXES = [
18
+ 'add_comment',
19
+ 'ask_question',
20
+ 'answer_question',
21
+ 'record_decision',
22
+ 'handoff_to_agent',
23
+ ];
24
+ export function findDuplicateSpawn(records, spec) {
25
+ const specRole = spec.role || '';
26
+ const specAgent = spec.agentId || '';
27
+ for (const rec of records) {
28
+ if (spec.triggerId && rec.trigger_id === spec.triggerId) {
29
+ return 'duplicate_trigger';
30
+ }
31
+ if (spec.chatRequestId && rec.chat_request_id === spec.chatRequestId) {
32
+ return 'duplicate_chat';
33
+ }
34
+ if (spec.kind === 'trigger' &&
35
+ !(spec.triggerId || '').startsWith('mention:') &&
36
+ spec.ticketId &&
37
+ rec.ticket_id === spec.ticketId &&
38
+ (rec.role || '') === specRole &&
39
+ (!specAgent || !(rec.agent_id || '') || (rec.agent_id || '') === specAgent)) {
40
+ return 'duplicate_trigger';
41
+ }
42
+ }
43
+ return false;
44
+ }
45
+ export function mentionTriggerId(commentId, agentId) {
46
+ return `mention:${commentId}:${agentId || ''}`;
47
+ }
48
+ export class SubagentManager {
49
+ #map = new Map();
50
+ #config;
51
+ #adapters = new Map();
52
+ #sweepTimer = null;
53
+ #reservationCounter = 0;
54
+ #persistPath;
55
+ #pidDir;
56
+ #initialized = false;
57
+ #monitor = null;
58
+ circuitBreaker;
59
+ onExit;
60
+ constructor(config, circuitBreaker) {
61
+ this.#config = config;
62
+ this.#persistPath = SUBAGENTS_PERSIST_PATH;
63
+ this.#pidDir = SUBAGENTS_BASE_DIR;
64
+ this.circuitBreaker = circuitBreaker ?? new CircuitBreaker();
65
+ }
66
+ setMonitor(monitor) {
67
+ this.#monitor = monitor;
68
+ }
69
+ async init() {
70
+ if (this.#initialized)
71
+ return;
72
+ this.#initialized = true;
73
+ try {
74
+ await fsp.mkdir(this.#pidDir, { recursive: true, mode: 0o700 });
75
+ }
76
+ catch (err) {
77
+ log(`SubagentManager: mkdir failed: ${err?.message ?? err}`);
78
+ }
79
+ await this.#reconcileOnStart();
80
+ await this.#sweepOrphanCfgs();
81
+ this.#sweepTimer = setInterval(() => this.#sweep(), TTL_SWEEP_INTERVAL_MS);
82
+ this.#sweepTimer.unref?.();
83
+ log(`SubagentManager initialized (per-agent cli, pidDir=${this.#pidDir}, cap=${this.#config.delegation.maxConcurrent}, ttl=${this.#config.delegation.ttlMinutes}min)`);
84
+ }
85
+ #adapterFor(cli) {
86
+ const t = String(cli || 'claude').toLowerCase();
87
+ let a = this.#adapters.get(t);
88
+ if (!a) {
89
+ a = createAdapter(t);
90
+ this.#adapters.set(t, a);
91
+ }
92
+ return a;
93
+ }
94
+ get adapter() {
95
+ return this.#adapterFor('claude');
96
+ }
97
+ async #sweepOrphanCfgs() {
98
+ let files;
99
+ try {
100
+ files = await fsp.readdir(this.#pidDir);
101
+ }
102
+ catch (err) {
103
+ log(`Orphan cfg sweep: readdir failed: ${err?.message ?? err}`);
104
+ return;
105
+ }
106
+ const liveCfgs = new Set();
107
+ for (const rec of this.#map.values()) {
108
+ if (rec.kind !== 'reservation' && rec.config_path)
109
+ liveCfgs.add(rec.config_path);
110
+ }
111
+ try {
112
+ const procEntries = await fsp.readdir('/proc');
113
+ for (const entry of procEntries) {
114
+ if (!/^\d+$/.test(entry))
115
+ continue;
116
+ try {
117
+ const cmdline = await fsp.readFile(`/proc/${entry}/cmdline`, 'utf8');
118
+ const parts = cmdline.split('\0');
119
+ const idx = parts.indexOf('--mcp-config');
120
+ if (idx >= 0 && parts[idx + 1])
121
+ liveCfgs.add(parts[idx + 1]);
122
+ }
123
+ catch {
124
+ }
125
+ }
126
+ }
127
+ catch {
128
+ }
129
+ let purged = 0;
130
+ for (const f of files) {
131
+ if (!f.startsWith('cfg-') || !f.endsWith('.json'))
132
+ continue;
133
+ const path = join(this.#pidDir, f);
134
+ if (liveCfgs.has(path))
135
+ continue;
136
+ try {
137
+ await fsp.unlink(path);
138
+ purged++;
139
+ }
140
+ catch {
141
+ }
142
+ }
143
+ if (purged > 0)
144
+ log(`Orphan cfg sweep: purged ${purged} stale config file(s)`);
145
+ }
146
+ canSpawn() {
147
+ const active = this.#activeCount();
148
+ return active < (this.#config.delegation.maxConcurrent ?? 5);
149
+ }
150
+ #activeCount() {
151
+ let n = 0;
152
+ for (const _ of this.#map.values())
153
+ n++;
154
+ return n;
155
+ }
156
+ async spawn(spec) {
157
+ const dup = findDuplicateSpawn(this.#map.values(), spec);
158
+ if (dup) {
159
+ return { spawned: false, reason: dup };
160
+ }
161
+ if (spec.kind === 'trigger' && spec.ticketId && spec.agentId) {
162
+ const cbKey = CircuitBreaker.key(spec.agentId, spec.ticketId, spec.role || '');
163
+ const blockReason = this.circuitBreaker.shouldBlock(cbKey);
164
+ if (blockReason) {
165
+ log(`[subagent] spawn blocked by circuit-breaker: ticket=${spec.ticketId.slice(0, 8)} ` +
166
+ `role=${spec.role || '_'} agent=${spec.agentId.slice(0, 8)} — ${blockReason}`);
167
+ return { spawned: false, reason: 'circuit_breaker_open' };
168
+ }
169
+ }
170
+ if (!this.canSpawn()) {
171
+ return { spawned: false, reason: 'cap_reached' };
172
+ }
173
+ const reservationId = -(++this.#reservationCounter);
174
+ this.#map.set(reservationId, {
175
+ kind: 'reservation',
176
+ started_at: Date.now(),
177
+ trigger_id: spec.triggerId || null,
178
+ chat_request_id: spec.chatRequestId || null,
179
+ ticket_id: spec.ticketId || null,
180
+ role: spec.role || null,
181
+ agent_id: spec.agentId || null,
182
+ });
183
+ const ctx = spec.agentContext;
184
+ const adapter = this.#adapterFor(ctx?.cli);
185
+ const effectiveApiKey = ctx?.api_key || this.#config.apiKey;
186
+ const effectiveCwd = ctx?.cwd || undefined;
187
+ const { applied: harness, skipped: harnessSkipped } = partitionHarness(adapter, spec.harness);
188
+ if (harnessSkipped.length > 0) {
189
+ log(`[subagent] harness keys skipped (cli=${adapter.cliType} can't express them): ${harnessSkipped.join(', ')}`);
190
+ }
191
+ if (harness) {
192
+ log(`[subagent] harness applied: ticket=${spec.ticketId.slice(0, 8) || '-'} cli=${adapter.cliType} ${describeHarness(harness)}`);
193
+ }
194
+ const slice = selectEffortSlice(adapter.cliType, spec.effortPreset);
195
+ const effectiveModel = slice?.model ?? harness?.model ?? ctx?.model ?? null;
196
+ const effortFlag = slice?.effort ?? null;
197
+ const ultracode = !!slice?.ultracode;
198
+ if (slice && (effortFlag || ultracode || slice.model)) {
199
+ log(`[subagent] effort applied: ticket=${spec.ticketId.slice(0, 8) || '-'} cli=${adapter.cliType} ` +
200
+ `effort=${effortFlag ?? '-'} ultracode=${ultracode} model=${slice.model ?? '-'}`);
201
+ }
202
+ let configPath = null;
203
+ let configPathIsTemp = false;
204
+ try {
205
+ const descriptor = adapter.buildOneshotSpawn({
206
+ rolePrompt: spec.rolePrompt || '',
207
+ taskText: spec.taskText,
208
+ mcpConfigPath: null,
209
+ model: effectiveModel,
210
+ harness,
211
+ effort: effortFlag,
212
+ ultracode,
213
+ });
214
+ if (descriptor.needsMcpConfig) {
215
+ const needsSessionPin = !!(spec.ticketId && spec.role);
216
+ if (ctx?.mcp_config_path && !needsSessionPin) {
217
+ configPath = ctx.mcp_config_path;
218
+ configPathIsTemp = false;
219
+ }
220
+ else {
221
+ configPath = join(this.#pidDir, `cfg-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
222
+ configPathIsTemp = true;
223
+ await fsp.mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
224
+ const headers = {
225
+ Authorization: `Bearer ${effectiveApiKey}`,
226
+ 'X-AWB-Client-Type': ctx ? 'managed-subagent' : 'subagent',
227
+ };
228
+ if (spec.ticketId)
229
+ headers['X-AWB-Subagent-Ticket-Id'] = spec.ticketId;
230
+ if (spec.role)
231
+ headers['X-AWB-Subagent-Role'] = spec.role;
232
+ if (spec.triggerSource)
233
+ headers['X-AWB-Subagent-Trigger-Source'] = spec.triggerSource;
234
+ const mcpConfig = {
235
+ mcpServers: {
236
+ awb: {
237
+ type: 'http',
238
+ url: `${this.#config.url.replace(/\/$/, '')}/mcp`,
239
+ headers,
240
+ },
241
+ },
242
+ };
243
+ await fsp.writeFile(configPath, JSON.stringify(mcpConfig), { mode: 0o600 });
244
+ }
245
+ Object.assign(descriptor, adapter.buildOneshotSpawn({
246
+ rolePrompt: spec.rolePrompt || '',
247
+ taskText: spec.taskText,
248
+ mcpConfigPath: configPath,
249
+ model: effectiveModel,
250
+ harness,
251
+ effort: effortFlag,
252
+ ultracode,
253
+ }));
254
+ }
255
+ const binOverride = adapter.cliType === 'claude' ? this.#config.delegation.claudeBin : null;
256
+ const resolvedBin = adapter.resolveBin(binOverride);
257
+ const cliHomeEnvKey = adapter.configDirEnv();
258
+ const cliHomeEnv = cliHomeEnvKey && ctx?.cli_home_dir
259
+ ? { [cliHomeEnvKey]: ctx.cli_home_dir }
260
+ : {};
261
+ const credentialEnv = ctx?.extra_env ?? {};
262
+ const baseEnv = { ...process.env };
263
+ if (ctx?.credential_provider) {
264
+ const stripped = [];
265
+ for (const k of adapter.authEnvKeys()) {
266
+ if (k in baseEnv) {
267
+ delete baseEnv[k];
268
+ stripped.push(k);
269
+ }
270
+ }
271
+ if (stripped.length > 0) {
272
+ log(`Subagent env strip: agent=${ctx.agent_id.slice(0, 8)} provider=${ctx.credential_provider} ` +
273
+ `removed=${stripped.join(',')} (operator-inherited auth would have overridden per-agent credential)`);
274
+ }
275
+ }
276
+ const child = spawn(resolvedBin, descriptor.args, {
277
+ stdio: descriptor.stdio || ['ignore', 'pipe', 'pipe'],
278
+ detached: process.platform !== 'win32',
279
+ windowsHide: true,
280
+ cwd: effectiveCwd,
281
+ env: {
282
+ ...baseEnv,
283
+ ...(spec.envVars ?? {}),
284
+ AWB_API_KEY: effectiveApiKey,
285
+ ...cliHomeEnv,
286
+ ...credentialEnv,
287
+ ...adapter.harnessEnv(harness),
288
+ },
289
+ shell: descriptor.shell ?? /\.(cmd|bat|ps1)$/i.test(resolvedBin),
290
+ });
291
+ child.once('error', (err) => {
292
+ log(`Subagent spawn error: code=${err?.code || ''} cli=${adapter.cliType} bin=${resolvedBin} msg=${err?.message}`);
293
+ });
294
+ child.unref();
295
+ const pid = child.pid;
296
+ if (!pid) {
297
+ if (configPath)
298
+ await fsp.unlink(configPath).catch(() => { });
299
+ this.#map.delete(reservationId);
300
+ return { spawned: false, reason: 'spawn_failed' };
301
+ }
302
+ if (typeof descriptor.writePrompt === 'function') {
303
+ try {
304
+ descriptor.writePrompt(child);
305
+ }
306
+ catch (err) {
307
+ log(`Subagent writePrompt failed: ${err?.message ?? err}`);
308
+ }
309
+ }
310
+ const record = {
311
+ pid,
312
+ kind: spec.kind,
313
+ cli_type: adapter.cliType,
314
+ trigger_id: spec.triggerId || null,
315
+ chat_request_id: spec.chatRequestId || null,
316
+ ticket_id: spec.ticketId || null,
317
+ agent_id: spec.agentId || null,
318
+ role: spec.role || null,
319
+ room_id: spec.roomId || null,
320
+ started_at: Date.now(),
321
+ expected_completion_at: Date.now() + (this.#config.delegation.ttlMinutes ?? 15) * 60_000,
322
+ config_path: configPath,
323
+ config_path_is_temp: configPathIsTemp,
324
+ process_handle: child,
325
+ captureOutput: !adapter.has(NATIVE_MCP),
326
+ outLines: [],
327
+ tailLines: [],
328
+ commentSent: false,
329
+ tap: null,
330
+ };
331
+ record.tap =
332
+ this.#monitor?.register({
333
+ kind: 'oneshot',
334
+ sessionKey: spec.triggerId
335
+ ? `oneshot:trigger:${spec.triggerId}`
336
+ : spec.chatRequestId
337
+ ? `oneshot:chat:${spec.chatRequestId}`
338
+ : `oneshot:${pid}`,
339
+ pid,
340
+ apiKey: ctx?.api_key,
341
+ }) || null;
342
+ this.#map.delete(reservationId);
343
+ this.#map.set(pid, record);
344
+ this.#persist();
345
+ this.#wireExitHandler(child, pid);
346
+ this.#wireStdioCapture(child, pid);
347
+ log(`Subagent spawned: pid=${pid} cli=${adapter.cliType} kind=${spec.kind} ticket=${spec.ticketId || '-'}`);
348
+ return { spawned: true, pid };
349
+ }
350
+ catch (err) {
351
+ this.#map.delete(reservationId);
352
+ if (configPath && configPathIsTemp) {
353
+ await fsp.unlink(configPath).catch(() => { });
354
+ }
355
+ log(`Subagent spawn error: ${err?.message ?? err}`);
356
+ return { spawned: false, reason: 'exception' };
357
+ }
358
+ }
359
+ #wireExitHandler(child, pid) {
360
+ child.once('exit', async (code, signal) => {
361
+ const record = this.#map.get(pid);
362
+ if (!record || record.kind === 'reservation')
363
+ return;
364
+ const durationSec = Math.round((Date.now() - record.started_at) / 1000);
365
+ this.#map.delete(pid);
366
+ this.#persist();
367
+ if (record.config_path && record.config_path_is_temp) {
368
+ try {
369
+ await fsp.unlink(record.config_path);
370
+ }
371
+ catch {
372
+ }
373
+ }
374
+ record.tap?.end({ exit_code: code, signal });
375
+ await this._handleOneshotExit(record, code);
376
+ record.tailLines = [];
377
+ log(`Subagent exit: pid=${pid} cli=${record.cli_type || '-'} kind=${record.kind} code=${code} signal=${signal || '-'} duration=${durationSec}s`);
378
+ if (typeof this.onExit === 'function') {
379
+ try {
380
+ this.onExit({ pid, record, code, signal, durationSec });
381
+ }
382
+ catch {
383
+ }
384
+ }
385
+ });
386
+ child.once('error', (err) => {
387
+ log(`Subagent spawn error pid=${pid}: ${err?.message ?? err}`);
388
+ });
389
+ }
390
+ async _handleOneshotExit(record, code) {
391
+ const pid = record.pid;
392
+ let errClass = classifyCliError(null);
393
+ if (record.captureOutput && (record.ticket_id || record.room_id)) {
394
+ try {
395
+ const answer = this.#adapterFor(record.cli_type).collectOneshotResult(record.outLines);
396
+ errClass = classifyCliError(answer, { exitCode: code });
397
+ if (record.room_id) {
398
+ if (answer) {
399
+ await this.#postOneshotChatAnswer(record, answer);
400
+ }
401
+ else if (code !== 0) {
402
+ await this.#postOneshotChatAnswer(record, `⚠️ Agent가 응답하지 못했습니다 (exit code ${code ?? 'unknown'}).`);
403
+ }
404
+ }
405
+ else if (answer) {
406
+ if (code === 0 && !errClass.isFatal) {
407
+ await this.#postOneshotAnswer(record, answer);
408
+ }
409
+ else {
410
+ log(`Subagent one-shot result NOT posted as agent answer: ticket=${(record.ticket_id || '').slice(0, 8)} ` +
411
+ `cli=${record.cli_type} code=${code} reason=${errClass.reason || (code !== 0 ? `nonzero_exit_${code}` : 'unknown')} ` +
412
+ `— routing to system silent-exit fallback`);
413
+ }
414
+ }
415
+ }
416
+ catch (err) {
417
+ log(`Subagent post-answer failed pid=${pid}: ${err?.message ?? err}`);
418
+ }
419
+ }
420
+ if (record.kind === 'trigger' && record.ticket_id && record.agent_id) {
421
+ const role = record.role || '';
422
+ const cbKey = CircuitBreaker.key(record.agent_id, record.ticket_id, role);
423
+ if (record.commentSent && code === 0) {
424
+ this.circuitBreaker.reset(cbKey);
425
+ }
426
+ else if (!CircuitBreaker.isTransientExit(code) || errClass.nonRetryable) {
427
+ const tail = this.#collectTail(record);
428
+ const { justOpened, entry } = this.circuitBreaker.record(cbKey, code, tail, {
429
+ forceOpen: errClass.nonRetryable,
430
+ });
431
+ if (justOpened) {
432
+ const exitDesc = errClass.reason
433
+ ? errClass.reason
434
+ : code === 0
435
+ ? 'clean exit with no comment'
436
+ : `exit code ${code}`;
437
+ const reason = `Agent failed ${entry.consecutiveFailures} consecutive time(s) (${exitDesc}). ` +
438
+ `Last output: ${entry.lastExitTail || '(none)'}. ` +
439
+ `Check agent CLI config/credentials and unpend when fixed.`;
440
+ await fireAndForgetTool(this.#config, 'pend_ticket', {
441
+ ticket_id: record.ticket_id,
442
+ reason,
443
+ });
444
+ }
445
+ }
446
+ }
447
+ if (record.ticket_id && (!record.commentSent || code !== 0)) {
448
+ try {
449
+ await this.#postSilentExitFallback(record, code);
450
+ }
451
+ catch (err) {
452
+ log(`Subagent silent-exit fallback failed pid=${pid}: ${err?.message ?? err}`);
453
+ }
454
+ }
455
+ }
456
+ #wireStdioCapture(child, pid) {
457
+ const tagFor = (record) => {
458
+ if (record && record.agent_id) {
459
+ return `[subagent:${pid}][agent:${record.agent_id.slice(0, 8)}]`;
460
+ }
461
+ return `[subagent:${pid}]`;
462
+ };
463
+ if (child.stdout) {
464
+ const rlOut = createInterface({ input: child.stdout });
465
+ rlOut.on('line', (line) => {
466
+ const rec = this.#map.get(pid);
467
+ const record = rec && rec.kind !== 'reservation' ? rec : undefined;
468
+ if (record) {
469
+ record.tap?.outLine(line);
470
+ if (record.captureOutput) {
471
+ if (record.outLines.length < 10000)
472
+ record.outLines.push(line);
473
+ }
474
+ this.#bufferTail(record, line);
475
+ this.#scanForCommentTool(record, line);
476
+ }
477
+ log(`${tagFor(record)} ${line}`);
478
+ });
479
+ }
480
+ if (child.stderr) {
481
+ const rlErr = createInterface({ input: child.stderr });
482
+ rlErr.on('line', (line) => {
483
+ const rec = this.#map.get(pid);
484
+ const record = rec && rec.kind !== 'reservation' ? rec : undefined;
485
+ if (record)
486
+ this.#bufferTail(record, line);
487
+ log(`${tagFor(record)}[err] ${line}`);
488
+ });
489
+ }
490
+ }
491
+ #bufferTail(record, line) {
492
+ const trimmed = line.trim();
493
+ if (!trimmed)
494
+ return;
495
+ let entry = trimmed;
496
+ if (trimmed.startsWith('{')) {
497
+ entry = summarizeCliJsonLine(trimmed);
498
+ if (!entry)
499
+ return;
500
+ }
501
+ record.tailLines.push(entry);
502
+ while (record.tailLines.length > TAIL_RING_MAX_LINES)
503
+ record.tailLines.shift();
504
+ }
505
+ #scanForCommentTool(record, line) {
506
+ if (record.commentSent)
507
+ return;
508
+ const trimmed = line.trim();
509
+ if (!trimmed.startsWith('{'))
510
+ return;
511
+ let parsed;
512
+ try {
513
+ parsed = JSON.parse(trimmed);
514
+ }
515
+ catch {
516
+ return;
517
+ }
518
+ if (parsed?.type !== 'assistant')
519
+ return;
520
+ const content = parsed?.message?.content;
521
+ if (!Array.isArray(content))
522
+ return;
523
+ for (const block of content) {
524
+ if (block?.type !== 'tool_use' || typeof block.name !== 'string')
525
+ continue;
526
+ for (const suffix of TICKET_COMMENT_TOOL_SUFFIXES) {
527
+ if (block.name.endsWith(suffix)) {
528
+ record.commentSent = true;
529
+ return;
530
+ }
531
+ }
532
+ }
533
+ }
534
+ async #postOneshotAnswer(record, answer) {
535
+ const MAX = 60_000;
536
+ const trimmed = answer.length > MAX ? answer.slice(0, MAX) + '\n\n…[truncated]' : answer;
537
+ await fireAndForgetTool(this.#config, 'add_comment', {
538
+ ticket_id: record.ticket_id,
539
+ content: trimmed,
540
+ type: 'note',
541
+ });
542
+ record.commentSent = true;
543
+ log(`Subagent posted answer to ticket=${record.ticket_id} (cli=${record.cli_type}, ${trimmed.length} chars)`);
544
+ }
545
+ async #postSilentExitFallback(record, code) {
546
+ const ticketId = record.ticket_id || '';
547
+ if (!ticketId)
548
+ return;
549
+ const tail = this.#collectTail(record);
550
+ const exitLabel = code === null ? 'null' : String(code);
551
+ const reasonLabel = code === 0
552
+ ? 'no audit-trail comments + clean exit'
553
+ : `non-zero exit code ${exitLabel}`;
554
+ const triggerId = record.trigger_id || '';
555
+ const header = `⚠️ Subagent exited without leaving a ticket comment (${reasonLabel}).`;
556
+ const metaParts = [];
557
+ metaParts.push(`cli=${record.cli_type}`);
558
+ metaParts.push(`exit_code=${exitLabel}`);
559
+ const classified = classifyCliError(tail, { exitCode: code });
560
+ if (classified.isFatal && classified.reason)
561
+ metaParts.push(`reason=${classified.reason}`);
562
+ if (triggerId)
563
+ metaParts.push(`trigger=${triggerId}`);
564
+ const metaLine = `_${metaParts.join(' · ')}_`;
565
+ const body = tail
566
+ ? `${header}\n\n${metaLine}\n\nLast CLI output:\n\`\`\`\n${tail}\n\`\`\``
567
+ : `${header}\n\n${metaLine}\n\n(no buffered CLI output captured)`;
568
+ log(`Subagent silent-exit fallback dispatched ticket=${ticketId.slice(0, 8)} pid=${record.pid} ` +
569
+ `cli=${record.cli_type} exit=${exitLabel} trigger=${triggerId.slice(0, 8) || '-'} outputLen=${tail.length}`);
570
+ await postSilentExitSystemComment(this.#config, ticketId, {
571
+ content: body,
572
+ exit_code: code,
573
+ cycle_trigger_id: triggerId,
574
+ actor_name: 'agent-manager',
575
+ });
576
+ }
577
+ #collectTail(record) {
578
+ if (!record.tailLines.length)
579
+ return '';
580
+ let body = record.tailLines.join('\n').trim();
581
+ if (body.length > SILENT_EXIT_TAIL_MAX_CHARS) {
582
+ body = '…' + body.slice(-SILENT_EXIT_TAIL_MAX_CHARS);
583
+ }
584
+ return body;
585
+ }
586
+ async #postOneshotChatAnswer(record, answer) {
587
+ const MAX = 60_000;
588
+ const trimmed = answer.length > MAX ? answer.slice(0, MAX) + '\n\n…[truncated]' : answer;
589
+ const agentId = record.agent_id || '';
590
+ await postChatRoomMessage(this.#config, record.room_id, agentId, trimmed);
591
+ log(`Subagent posted chat answer to room=${record.room_id} agent=${agentId.slice(0, 8)} (cli=${record.cli_type}, ${trimmed.length} chars)`);
592
+ }
593
+ #sweep() {
594
+ const now = Date.now();
595
+ for (const [pid, record] of this.#map.entries()) {
596
+ if (record.kind === 'reservation')
597
+ continue;
598
+ try {
599
+ process.kill(pid, 0);
600
+ }
601
+ catch (err) {
602
+ if (err?.code === 'ESRCH' || err?.code === 'EPERM') {
603
+ log(`Sweep: pid=${pid} no longer alive, removing record`);
604
+ this.#map.delete(pid);
605
+ if (record.config_path && record.config_path_is_temp) {
606
+ fsp.rm(dirname(record.config_path), { recursive: true, force: true }).catch(() => { });
607
+ }
608
+ continue;
609
+ }
610
+ }
611
+ if (now >= record.expected_completion_at) {
612
+ log(`Sweep: pid=${pid} exceeded TTL, sending SIGTERM`);
613
+ try {
614
+ process.kill(pid, 'SIGTERM');
615
+ }
616
+ catch {
617
+ }
618
+ setTimeout(() => {
619
+ try {
620
+ process.kill(pid, 0);
621
+ log(`Sweep: pid=${pid} still alive after SIGTERM grace, sending SIGKILL`);
622
+ try {
623
+ process.kill(pid, 'SIGKILL');
624
+ }
625
+ catch {
626
+ }
627
+ }
628
+ catch {
629
+ }
630
+ }, SIGTERM_GRACE_MS);
631
+ }
632
+ }
633
+ this.#persist();
634
+ }
635
+ async #reconcileOnStart() {
636
+ let raw;
637
+ try {
638
+ raw = await fsp.readFile(this.#persistPath, 'utf8');
639
+ }
640
+ catch {
641
+ return;
642
+ }
643
+ let persisted;
644
+ try {
645
+ persisted = JSON.parse(raw).pids || [];
646
+ }
647
+ catch {
648
+ return;
649
+ }
650
+ let revived = 0, dropped = 0;
651
+ for (const rec of persisted) {
652
+ if (!rec || !rec.pid)
653
+ continue;
654
+ try {
655
+ process.kill(rec.pid, 0);
656
+ this.#map.set(rec.pid, {
657
+ ...rec,
658
+ role: rec.role ?? null,
659
+ config_path_is_temp: rec.config_path_is_temp ?? true,
660
+ process_handle: null,
661
+ outLines: rec.outLines || [],
662
+ tailLines: [],
663
+ commentSent: rec.commentSent ?? false,
664
+ });
665
+ revived++;
666
+ }
667
+ catch (err) {
668
+ if (err?.code === 'ESRCH' || err?.code === 'EPERM')
669
+ dropped++;
670
+ }
671
+ }
672
+ if (revived || dropped) {
673
+ log(`SubagentManager reconciled: revived=${revived} dropped=${dropped}`);
674
+ }
675
+ this.#persist();
676
+ }
677
+ #persist() {
678
+ const pids = [];
679
+ for (const rec of this.#map.values()) {
680
+ if (rec.kind === 'reservation')
681
+ continue;
682
+ const { process_handle, outLines, tailLines, tap, ...serializable } = rec;
683
+ void process_handle;
684
+ void outLines;
685
+ void tailLines;
686
+ void tap;
687
+ pids.push(serializable);
688
+ }
689
+ fsp
690
+ .writeFile(this.#persistPath, JSON.stringify({ pids }, null, 2))
691
+ .catch((err) => log(`SubagentManager persist failed: ${err?.message ?? err}`));
692
+ }
693
+ async stopForAgent(agentId) {
694
+ if (!agentId)
695
+ return { count: 0, inflight: [] };
696
+ const victims = [];
697
+ for (const [pid, rec] of this.#map.entries()) {
698
+ if (rec.kind === 'reservation')
699
+ continue;
700
+ if (rec.agent_id !== agentId)
701
+ continue;
702
+ victims.push(rec);
703
+ this.#map.delete(pid);
704
+ }
705
+ if (victims.length === 0)
706
+ return { count: 0, inflight: [] };
707
+ for (const rec of victims) {
708
+ try {
709
+ process.kill(rec.pid, 'SIGTERM');
710
+ }
711
+ catch {
712
+ }
713
+ }
714
+ log(`SubagentManager stopForAgent: agent=${agentId.slice(0, 8)} signalled ${victims.length} one-shot subagent(s) — SIGTERM`);
715
+ setTimeout(() => {
716
+ for (const rec of victims) {
717
+ try {
718
+ process.kill(rec.pid, 0);
719
+ try {
720
+ process.kill(rec.pid, 'SIGKILL');
721
+ }
722
+ catch {
723
+ }
724
+ }
725
+ catch {
726
+ }
727
+ if (rec.config_path && rec.config_path_is_temp) {
728
+ fsp.unlink(rec.config_path).catch(() => { });
729
+ }
730
+ }
731
+ }, STOP_GRACE_MS).unref?.();
732
+ this.#persist();
733
+ return {
734
+ count: victims.length,
735
+ inflight: victims.map((rec) => ({
736
+ ticket_id: rec.ticket_id,
737
+ role: rec.role,
738
+ room_id: rec.room_id,
739
+ })),
740
+ };
741
+ }
742
+ async stop() {
743
+ if (this.#sweepTimer) {
744
+ clearInterval(this.#sweepTimer);
745
+ this.#sweepTimer = null;
746
+ }
747
+ const pids = [];
748
+ for (const [pid, rec] of this.#map.entries()) {
749
+ if (rec.kind === 'reservation')
750
+ continue;
751
+ pids.push(pid);
752
+ try {
753
+ process.kill(pid, 'SIGTERM');
754
+ }
755
+ catch {
756
+ }
757
+ }
758
+ if (pids.length === 0) {
759
+ this.#map.clear();
760
+ return;
761
+ }
762
+ await new Promise((r) => setTimeout(r, STOP_GRACE_MS));
763
+ for (const pid of pids) {
764
+ try {
765
+ process.kill(pid, 'SIGKILL');
766
+ }
767
+ catch {
768
+ }
769
+ }
770
+ this.#map.clear();
771
+ try {
772
+ await fsp.writeFile(this.#persistPath, JSON.stringify({ pids: [] }, null, 2));
773
+ }
774
+ catch {
775
+ }
776
+ log(`SubagentManager stopped (terminated ${pids.length} children)`);
777
+ }
778
+ _snapshot() {
779
+ const out = [];
780
+ for (const rec of this.#map.values()) {
781
+ if (rec.kind === 'reservation')
782
+ continue;
783
+ const { process_handle, outLines, tailLines, tap, ...serializable } = rec;
784
+ void process_handle;
785
+ void outLines;
786
+ void tailLines;
787
+ void tap;
788
+ out.push(serializable);
789
+ }
790
+ return out;
791
+ }
792
+ }
793
+ //# sourceMappingURL=subagent-manager.js.map