kernelbot 1.0.28 → 1.0.32

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.
@@ -0,0 +1,124 @@
1
+ import axios from 'axios';
2
+ import { createHash } from 'crypto';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ import { getLogger } from '../utils/logger.js';
7
+
8
+ const CACHE_DIR = join(homedir(), '.kernelbot', 'tts-cache');
9
+ const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'; // ElevenLabs "George" voice
10
+ const MAX_TEXT_LENGTH = 5000; // ElevenLabs limit
11
+
12
+ /**
13
+ * Text-to-Speech service using ElevenLabs API.
14
+ * Converts text to OGG/opus audio compatible with Telegram voice messages.
15
+ */
16
+ export class TTSService {
17
+ constructor(config = {}) {
18
+ this.apiKey = config.elevenlabs?.api_key || process.env.ELEVENLABS_API_KEY || null;
19
+ this.voiceId = config.elevenlabs?.voice_id || process.env.ELEVENLABS_VOICE_ID || DEFAULT_VOICE_ID;
20
+ this.enabled = config.voice?.tts_enabled !== false && !!this.apiKey;
21
+ this.logger = getLogger();
22
+
23
+ // Ensure cache directory exists
24
+ if (this.enabled) {
25
+ mkdirSync(CACHE_DIR, { recursive: true });
26
+ }
27
+ }
28
+
29
+ /** Check if TTS is available. */
30
+ isAvailable() {
31
+ return this.enabled && !!this.apiKey;
32
+ }
33
+
34
+ /**
35
+ * Convert text to an OGG/opus audio buffer.
36
+ * Returns the file path to the generated audio, or null on failure.
37
+ */
38
+ async synthesize(text) {
39
+ if (!this.isAvailable()) return null;
40
+ if (!text || text.trim().length === 0) return null;
41
+
42
+ // Truncate if too long
43
+ const cleanText = text.slice(0, MAX_TEXT_LENGTH).trim();
44
+
45
+ // Check cache
46
+ const cacheKey = this._cacheKey(cleanText, this.voiceId);
47
+ const cachedPath = join(CACHE_DIR, `${cacheKey}.ogg`);
48
+ if (existsSync(cachedPath)) {
49
+ this.logger.debug(`[TTS] Cache hit: ${cacheKey}`);
50
+ return cachedPath;
51
+ }
52
+
53
+ try {
54
+ this.logger.info(`[TTS] Synthesizing ${cleanText.length} chars with voice ${this.voiceId}`);
55
+
56
+ const response = await axios.post(
57
+ `https://api.elevenlabs.io/v1/text-to-speech/${this.voiceId}`,
58
+ {
59
+ text: cleanText,
60
+ model_id: 'eleven_multilingual_v2',
61
+ voice_settings: {
62
+ stability: 0.5,
63
+ similarity_boost: 0.75,
64
+ style: 0.0,
65
+ use_speaker_boost: true,
66
+ },
67
+ },
68
+ {
69
+ headers: {
70
+ 'Accept': 'audio/mpeg',
71
+ 'Content-Type': 'application/json',
72
+ 'xi-api-key': this.apiKey,
73
+ },
74
+ responseType: 'arraybuffer',
75
+ timeout: 30_000,
76
+ },
77
+ );
78
+
79
+ // ElevenLabs returns MP3 by default when Accept: audio/mpeg
80
+ // We write it as-is; Telegram accepts MP3 for voice messages via sendVoice
81
+ // when sent with the right content type
82
+ const audioBuffer = Buffer.from(response.data);
83
+
84
+ if (audioBuffer.length < 100) {
85
+ this.logger.warn('[TTS] Response too small, likely an error');
86
+ return null;
87
+ }
88
+
89
+ // Cache the result
90
+ writeFileSync(cachedPath, audioBuffer);
91
+ this.logger.info(`[TTS] Synthesized and cached: ${cachedPath} (${audioBuffer.length} bytes)`);
92
+
93
+ return cachedPath;
94
+ } catch (err) {
95
+ if (err.response) {
96
+ const errBody = err.response.data instanceof Buffer
97
+ ? err.response.data.toString('utf-8').slice(0, 200)
98
+ : JSON.stringify(err.response.data).slice(0, 200);
99
+ this.logger.error(`[TTS] API error ${err.response.status}: ${errBody}`);
100
+ } else {
101
+ this.logger.error(`[TTS] Request failed: ${err.message}`);
102
+ }
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /** Generate a deterministic cache key from text + voice. */
108
+ _cacheKey(text, voiceId) {
109
+ return createHash('sha256').update(`${voiceId}:${text}`).digest('hex').slice(0, 16);
110
+ }
111
+
112
+ /** Clear the TTS cache. */
113
+ clearCache() {
114
+ try {
115
+ const files = readdirSync(CACHE_DIR);
116
+ for (const file of files) {
117
+ unlinkSync(join(CACHE_DIR, file));
118
+ }
119
+ this.logger.info(`[TTS] Cache cleared (${files.length} files)`);
120
+ } catch {
121
+ // Cache dir may not exist yet
122
+ }
123
+ }
124
+ }
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { Job } from './job.js';
3
+ import { WORKER_TYPES } from './worker-registry.js';
3
4
  import { getLogger } from '../utils/logger.js';
4
5
 
5
6
  /**
@@ -10,6 +11,7 @@ import { getLogger } from '../utils/logger.js';
10
11
  * job:completed (job)
11
12
  * job:failed (job)
12
13
  * job:cancelled (job)
14
+ * job:ready (job) — emitted when a queued job's dependencies are all met
13
15
  */
14
16
  export class JobManager extends EventEmitter {
15
17
  constructor({ jobTimeoutSeconds = 300, cleanupIntervalMinutes = 30 } = {}) {
@@ -22,9 +24,12 @@ export class JobManager extends EventEmitter {
22
24
  /** Create a new job (status: queued). */
23
25
  createJob(chatId, workerType, task) {
24
26
  const job = new Job({ chatId, workerType, task });
27
+ // Set per-job timeout from worker type config, fall back to global
28
+ const workerConfig = WORKER_TYPES[workerType];
29
+ job.timeoutMs = workerConfig?.timeout ? workerConfig.timeout * 1000 : this.jobTimeoutMs;
25
30
  this.jobs.set(job.id, job);
26
31
  const logger = getLogger();
27
- logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — "${task.slice(0, 100)}"`);
32
+ logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — timeout: ${job.timeoutMs / 1000}s — "${task.slice(0, 100)}"`);
28
33
  logger.debug(`[JobManager] Total jobs tracked: ${this.jobs.size}`);
29
34
  return job;
30
35
  }
@@ -36,13 +41,17 @@ export class JobManager extends EventEmitter {
36
41
  getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → running`);
37
42
  }
38
43
 
39
- /** Move a job to completed with a result. */
40
- completeJob(jobId, result) {
44
+ /** Move a job to completed with a result and optional structured data. */
45
+ completeJob(jobId, result, structuredResult = null) {
41
46
  const job = this._get(jobId);
42
47
  job.transition('completed');
43
48
  job.result = result;
44
- getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → completed (${job.duration}s) — result: ${(result || '').length} chars`);
49
+ if (structuredResult) {
50
+ job.structuredResult = structuredResult;
51
+ }
52
+ getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → completed (${job.duration}s) — result: ${(result || '').length} chars, structured: ${!!structuredResult}`);
45
53
  this.emit('job:completed', job);
54
+ this._checkDependents(job);
46
55
  }
47
56
 
48
57
  /** Move a job to failed with an error message. */
@@ -52,6 +61,7 @@ export class JobManager extends EventEmitter {
52
61
  job.error = typeof error === 'string' ? error : error?.message || String(error);
53
62
  getLogger().error(`[JobManager] Job ${job.id} [${job.workerType}] → failed (${job.duration}s) — ${job.error}`);
54
63
  this.emit('job:failed', job);
64
+ this._failDependents(job);
55
65
  }
56
66
 
57
67
  /** Cancel a specific job. Returns the job or null if not found / already terminal. */
@@ -145,13 +155,14 @@ export class JobManager extends EventEmitter {
145
155
  if (job.status === 'running' && job.startedAt) {
146
156
  checkedCount++;
147
157
  const elapsed = now - job.startedAt;
148
- if (elapsed > this.jobTimeoutMs) {
149
- getLogger().warn(`[JobManager] Job ${job.id} [${job.workerType}] timed out — elapsed: ${Math.round(elapsed / 1000)}s, limit: ${this.jobTimeoutMs / 1000}s`);
158
+ const timeoutMs = job.timeoutMs || this.jobTimeoutMs;
159
+ if (elapsed > timeoutMs) {
160
+ getLogger().warn(`[JobManager] Job ${job.id} [${job.workerType}] timed out — elapsed: ${Math.round(elapsed / 1000)}s, limit: ${timeoutMs / 1000}s`);
150
161
  // Cancel the worker first so it stops executing & frees resources
151
162
  if (job.worker && typeof job.worker.cancel === 'function') {
152
163
  job.worker.cancel();
153
164
  }
154
- this.failJob(job.id, `Timed out after ${this.jobTimeoutMs / 1000}s`);
165
+ this.failJob(job.id, `Timed out after ${timeoutMs / 1000}s`);
155
166
  }
156
167
  }
157
168
  }
@@ -160,6 +171,42 @@ export class JobManager extends EventEmitter {
160
171
  }
161
172
  }
162
173
 
174
+ /** Check if any queued jobs have all dependencies met after a job completes. */
175
+ _checkDependents(completedJob) {
176
+ const logger = getLogger();
177
+ for (const job of this.jobs.values()) {
178
+ if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
179
+ if (!job.dependsOn.includes(completedJob.id)) continue;
180
+
181
+ // Check if ALL dependencies are completed
182
+ const allMet = job.dependsOn.every(depId => {
183
+ const dep = this.jobs.get(depId);
184
+ return dep && dep.status === 'completed';
185
+ });
186
+
187
+ if (allMet) {
188
+ logger.info(`[JobManager] Job ${job.id} [${job.workerType}] — all dependencies met, emitting job:ready`);
189
+ this.emit('job:ready', job);
190
+ }
191
+ }
192
+ }
193
+
194
+ /** Cascade failure to dependent jobs when a job fails. */
195
+ _failDependents(failedJob) {
196
+ const logger = getLogger();
197
+ for (const job of this.jobs.values()) {
198
+ if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
199
+ if (!job.dependsOn.includes(failedJob.id)) continue;
200
+
201
+ logger.warn(`[JobManager] Job ${job.id} [${job.workerType}] — dependency ${failedJob.id} failed, cascading failure`);
202
+ job.transition('failed');
203
+ job.error = `Dependency job ${failedJob.id} failed: ${failedJob.error || 'unknown error'}`;
204
+ this.emit('job:failed', job);
205
+ // Recursively cascade to jobs depending on this one
206
+ this._failDependents(job);
207
+ }
208
+ }
209
+
163
210
  /** Internal: get job or throw. */
164
211
  _get(jobId) {
165
212
  const job = this.jobs.get(jobId);
package/src/swarm/job.js CHANGED
@@ -20,12 +20,19 @@ export class Job {
20
20
  this.task = task;
21
21
  this.status = 'queued';
22
22
  this.result = null;
23
+ this.structuredResult = null; // WorkerResult: { summary, status, details, artifacts, followUp, toolsUsed, errors }
24
+ this.context = null; // Orchestrator-provided context string
25
+ this.dependsOn = []; // Job IDs this job depends on
26
+ this.userId = null; // Telegram user ID for persona loading
23
27
  this.error = null;
24
28
  this.worker = null; // WorkerAgent ref
25
29
  this.statusMessageId = null; // Telegram message for progress edits
26
30
  this.createdAt = Date.now();
27
31
  this.startedAt = null;
28
32
  this.completedAt = null;
33
+ this.timeoutMs = null; // Per-job timeout (set from worker type config)
34
+ this.progress = []; // Recent activity entries
35
+ this.lastActivity = null; // Timestamp of last activity
29
36
  }
30
37
 
31
38
  /** Transition to a new status. Throws if the transition is invalid. */
@@ -46,6 +53,13 @@ export class Job {
46
53
  return Math.round((end - this.startedAt) / 1000);
47
54
  }
48
55
 
56
+ /** Record a progress heartbeat from the worker. Caps at 20 entries. */
57
+ addProgress(text) {
58
+ this.progress.push(text);
59
+ if (this.progress.length > 20) this.progress.shift();
60
+ this.lastActivity = Date.now();
61
+ }
62
+
49
63
  /** Whether this job is in a terminal state. */
50
64
  get isTerminal() {
51
65
  return ['completed', 'failed', 'cancelled'].includes(this.status);
@@ -62,6 +76,10 @@ export class Job {
62
76
  };
63
77
  const emoji = statusEmoji[this.status] || '❓';
64
78
  const dur = this.duration != null ? ` (${this.duration}s)` : '';
65
- return `${emoji} \`${this.id}\` [${this.workerType}] ${this.task.slice(0, 60)}${this.task.length > 60 ? '...' : ''}${dur}`;
79
+ const summaryText = this.structuredResult?.summary || null;
80
+ const lastAct = !summaryText && this.progress.length > 0 ? ` | ${this.progress[this.progress.length - 1]}` : '';
81
+ const resultSnippet = summaryText ? ` | ${summaryText.slice(0, 80)}` : '';
82
+ const deps = this.dependsOn.length > 0 ? ` [deps: ${this.dependsOn.join(',')}]` : '';
83
+ return `${emoji} \`${this.id}\` [${this.workerType}] ${this.task.slice(0, 60)}${this.task.length > 60 ? '...' : ''}${dur}${resultSnippet}${lastAct}${deps}`;
66
84
  }
67
85
  }
@@ -10,30 +10,35 @@ export const WORKER_TYPES = {
10
10
  emoji: '💻',
11
11
  categories: ['core', 'coding', 'git', 'github'],
12
12
  description: 'Write code, fix bugs, create PRs',
13
+ timeout: 86400, // 24 hours — Claude Code can legitimately run for hours
13
14
  },
14
15
  browser: {
15
16
  label: 'Browser Worker',
16
17
  emoji: '🌐',
17
18
  categories: ['browser'],
18
19
  description: 'Web search, scraping, screenshots',
20
+ timeout: 300, // 5 minutes
19
21
  },
20
22
  system: {
21
23
  label: 'System Worker',
22
24
  emoji: '🖥️',
23
25
  categories: ['core', 'process', 'monitor', 'network'],
24
26
  description: 'OS operations, monitoring, network',
27
+ timeout: 600, // 10 minutes
25
28
  },
26
29
  devops: {
27
30
  label: 'DevOps Worker',
28
31
  emoji: '🚀',
29
32
  categories: ['core', 'docker', 'process', 'monitor', 'network', 'git'],
30
33
  description: 'Docker, deploy, infrastructure',
34
+ timeout: 3600, // 1 hour
31
35
  },
32
36
  research: {
33
37
  label: 'Research Worker',
34
38
  emoji: '🔍',
35
39
  categories: ['browser', 'core'],
36
40
  description: 'Deep web research and analysis',
41
+ timeout: 600, // 10 minutes
37
42
  },
38
43
  };
39
44
 
@@ -5,11 +5,15 @@ import { getLogger } from '../utils/logger.js';
5
5
 
6
6
  let spawner = null;
7
7
 
8
- function getSpawner(config) {
8
+ export function getSpawner(config) {
9
9
  if (!spawner) spawner = new ClaudeCodeSpawner(config);
10
10
  return spawner;
11
11
  }
12
12
 
13
+ export function resetClaudeCodeSpawner() {
14
+ spawner = null;
15
+ }
16
+
13
17
  export const definitions = [
14
18
  {
15
19
  name: 'spawn_claude_code',
@@ -57,6 +61,7 @@ export const handlers = {
57
61
  prompt: params.prompt,
58
62
  maxTurns: params.max_turns,
59
63
  onOutput: onUpdate,
64
+ signal: context.signal || null,
60
65
  });
61
66
 
62
67
  // Show stderr if any
@@ -23,6 +23,15 @@ export const orchestratorToolDefinitions = [
23
23
  type: 'string',
24
24
  description: 'A clear, detailed description of what the worker should do.',
25
25
  },
26
+ context: {
27
+ type: 'string',
28
+ description: 'Optional background context for the worker — relevant conversation details, goals, constraints. The worker cannot see the chat history, so include anything important here.',
29
+ },
30
+ depends_on: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: 'Optional array of job IDs that must complete before this worker starts. The worker will receive dependency results as context.',
34
+ },
26
35
  },
27
36
  required: ['worker_type', 'task'],
28
37
  },
@@ -158,12 +167,12 @@ export const orchestratorToolDefinitions = [
158
167
  */
159
168
  export async function executeOrchestratorTool(name, input, context) {
160
169
  const logger = getLogger();
161
- const { chatId, jobManager, config, spawnWorker, automationManager } = context;
170
+ const { chatId, jobManager, config, spawnWorker, automationManager, user } = context;
162
171
 
163
172
  switch (name) {
164
173
  case 'dispatch_task': {
165
- const { worker_type, task } = input;
166
- logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}"`);
174
+ const { worker_type, task, context: taskContext, depends_on } = input;
175
+ logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}", deps=${depends_on?.length || 0}`);
167
176
 
168
177
  // Validate worker type
169
178
  if (!WORKER_TYPES[worker_type]) {
@@ -171,31 +180,82 @@ export async function executeOrchestratorTool(name, input, context) {
171
180
  return { error: `Unknown worker type: ${worker_type}. Valid: ${workerTypeEnum.join(', ')}` };
172
181
  }
173
182
 
174
- // Check concurrent job limit
175
- const running = jobManager.getRunningJobsForChat(chatId);
176
- const maxConcurrent = config.swarm?.max_concurrent_jobs || 3;
177
- logger.debug(`[dispatch_task] Running jobs: ${running.length}/${maxConcurrent} for chat ${chatId}`);
178
- if (running.length >= maxConcurrent) {
179
- logger.warn(`[dispatch_task] Rejected concurrent limit reached: ${running.length}/${maxConcurrent} jobs for chat ${chatId}`);
180
- return { error: `Maximum concurrent jobs (${maxConcurrent}) reached. Wait for a job to finish or cancel one.` };
183
+ // Validate dependency IDs
184
+ const depIds = Array.isArray(depends_on) ? depends_on : [];
185
+ for (const depId of depIds) {
186
+ const depJob = jobManager.getJob(depId);
187
+ if (!depJob) {
188
+ logger.warn(`[dispatch_task] Unknown dependency job: ${depId}`);
189
+ return { error: `Dependency job not found: ${depId}` };
190
+ }
191
+ if (depJob.status === 'failed' || depJob.status === 'cancelled') {
192
+ logger.warn(`[dispatch_task] Dependency job ${depId} already ${depJob.status}`);
193
+ return { error: `Dependency job ${depId} already ${depJob.status}. Cannot dispatch.` };
194
+ }
195
+ }
196
+
197
+ // Check concurrent job limit (only if we'll spawn immediately)
198
+ const hasUnmetDeps = depIds.some(id => {
199
+ const dep = jobManager.getJob(id);
200
+ return dep && dep.status !== 'completed';
201
+ });
202
+
203
+ if (!hasUnmetDeps) {
204
+ const running = jobManager.getRunningJobsForChat(chatId);
205
+ const maxConcurrent = config.swarm?.max_concurrent_jobs || 3;
206
+ logger.debug(`[dispatch_task] Running jobs: ${running.length}/${maxConcurrent} for chat ${chatId}`);
207
+ if (running.length >= maxConcurrent) {
208
+ logger.warn(`[dispatch_task] Rejected — concurrent limit reached: ${running.length}/${maxConcurrent} jobs for chat ${chatId}`);
209
+ return { error: `Maximum concurrent jobs (${maxConcurrent}) reached. Wait for a job to finish or cancel one.` };
210
+ }
181
211
  }
182
212
 
183
213
  // Pre-check credentials for the worker's tools
184
214
  const toolNames = getToolNamesForWorkerType(worker_type);
215
+ const missingCreds = [];
185
216
  for (const toolName of toolNames) {
186
217
  const missing = getMissingCredential(toolName, config);
187
218
  if (missing) {
188
- logger.warn(`[dispatch_task] Missing credential for ${worker_type}: ${missing.envKey}`);
219
+ missingCreds.push(missing);
220
+ }
221
+ }
222
+
223
+ let credentialWarning = null;
224
+ if (missingCreds.length > 0) {
225
+ if (worker_type === 'coding') {
226
+ // Soft warning for coding worker — spawn_claude_code handles git/GitHub internally
227
+ const warnings = missingCreds.map(c => `${c.label} (${c.envKey})`).join(', ');
228
+ logger.info(`[dispatch_task] Coding worker — soft credential warning (non-blocking): ${warnings}`);
229
+ credentialWarning = `Note: The following credentials are not configured as env vars: ${warnings}. If git/GitHub tools are unavailable, use spawn_claude_code for ALL operations — it handles git, GitHub, and PR creation internally.`;
230
+ } else {
231
+ // Hard block for all other worker types
232
+ const first = missingCreds[0];
233
+ logger.warn(`[dispatch_task] Missing credential for ${worker_type}: ${first.envKey}`);
189
234
  return {
190
- error: `Missing credential for ${worker_type} worker: ${missing.label} (${missing.envKey}). Ask the user to provide it.`,
235
+ error: `Missing credential for ${worker_type} worker: ${first.label} (${first.envKey}). Ask the user to provide it.`,
191
236
  };
192
237
  }
193
238
  }
194
239
 
195
- // Create and spawn the job
240
+ // Create the job with context and dependencies
196
241
  const job = jobManager.createJob(chatId, worker_type, task);
242
+ job.context = [taskContext, credentialWarning].filter(Boolean).join('\n\n') || null;
243
+ job.dependsOn = depIds;
244
+ job.userId = user?.id || null;
197
245
  const workerConfig = WORKER_TYPES[worker_type];
198
246
 
247
+ // If dependencies are not all met, leave job queued (job:ready will spawn it later)
248
+ if (hasUnmetDeps) {
249
+ logger.info(`[dispatch_task] Job ${job.id} queued — waiting for dependencies: ${depIds.join(', ')}`);
250
+ return {
251
+ job_id: job.id,
252
+ worker_type,
253
+ status: 'queued_waiting',
254
+ depends_on: depIds,
255
+ message: `${workerConfig.emoji} ${workerConfig.label} queued — waiting for ${depIds.length} dependency job(s) to complete.`,
256
+ };
257
+ }
258
+
199
259
  logger.info(`[dispatch_task] Dispatching job ${job.id} — ${workerConfig.emoji} ${workerConfig.label}: "${task.slice(0, 100)}"`);
200
260
 
201
261
  // Fire and forget — spawnWorker handles lifecycle
@@ -224,14 +284,26 @@ export async function executeOrchestratorTool(name, input, context) {
224
284
  return { message: 'No jobs for this chat.' };
225
285
  }
226
286
  return {
227
- jobs: jobs.slice(0, 20).map((j) => ({
228
- id: j.id,
229
- worker_type: j.workerType,
230
- status: j.status,
231
- task: j.task.slice(0, 100),
232
- duration: j.duration,
233
- summary: j.toSummary(),
234
- })),
287
+ jobs: jobs.slice(0, 20).map((j) => {
288
+ const entry = {
289
+ id: j.id,
290
+ worker_type: j.workerType,
291
+ status: j.status,
292
+ task: j.task.slice(0, 100),
293
+ duration: j.duration,
294
+ recent_activity: j.progress.slice(-5),
295
+ last_activity_seconds_ago: j.lastActivity ? Math.round((Date.now() - j.lastActivity) / 1000) : null,
296
+ summary: j.toSummary(),
297
+ };
298
+ if (j.dependsOn.length > 0) entry.depends_on = j.dependsOn;
299
+ if (j.structuredResult) {
300
+ entry.result_summary = j.structuredResult.summary;
301
+ entry.result_status = j.structuredResult.status;
302
+ if (j.structuredResult.artifacts?.length > 0) entry.artifacts = j.structuredResult.artifacts;
303
+ if (j.structuredResult.followUp) entry.follow_up = j.structuredResult.followUp;
304
+ }
305
+ return entry;
306
+ }),
235
307
  };
236
308
  }
237
309
 
package/src/tools/os.js CHANGED
@@ -122,12 +122,15 @@ export const handlers = {
122
122
  }
123
123
 
124
124
  return new Promise((res) => {
125
+ let abortHandler = null;
126
+
125
127
  const child = exec(
126
128
  command,
127
129
  { timeout: timeout_seconds * 1000, maxBuffer: 10 * 1024 * 1024 },
128
130
  (error, stdout, stderr) => {
131
+ if (abortHandler && context.signal) context.signal.removeEventListener('abort', abortHandler);
129
132
  if (error && error.killed) {
130
- return res({ error: `Command timed out after ${timeout_seconds}s` });
133
+ return res({ error: `Command timed out or was cancelled after ${timeout_seconds}s` });
131
134
  }
132
135
  const result = {
133
136
  stdout: stdout || '',
@@ -147,6 +150,16 @@ export const handlers = {
147
150
  res(result);
148
151
  },
149
152
  );
153
+
154
+ // Wire abort signal to kill the child process
155
+ if (context.signal) {
156
+ if (context.signal.aborted) {
157
+ child.kill('SIGTERM');
158
+ } else {
159
+ abortHandler = () => child.kill('SIGTERM');
160
+ context.signal.addEventListener('abort', abortHandler, { once: true });
161
+ }
162
+ }
150
163
  });
151
164
  },
152
165