opencode-task 0.1.0 → 0.1.3

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 (2) hide show
  1. package/dist/index.js +436 -5
  2. package/package.json +6 -1
package/dist/index.js CHANGED
@@ -12329,22 +12329,453 @@ function tool(input) {
12329
12329
  return input;
12330
12330
  }
12331
12331
  tool.schema = exports_external;
12332
+ // utils.ts
12333
+ import { appendFile } from "node:fs/promises";
12334
+ class Semaphore {
12335
+ maxConcurrent;
12336
+ permits;
12337
+ waiting = [];
12338
+ acquired = 0;
12339
+ draining = false;
12340
+ constructor(maxConcurrent) {
12341
+ this.maxConcurrent = maxConcurrent;
12342
+ this.permits = maxConcurrent;
12343
+ }
12344
+ async acquire() {
12345
+ if (this.draining)
12346
+ throw new Error("Semaphore is draining");
12347
+ if (this.permits > 0) {
12348
+ this.permits--;
12349
+ this.acquired++;
12350
+ return;
12351
+ }
12352
+ return new Promise((resolve, reject) => {
12353
+ this.waiting.push({ resolve, reject });
12354
+ });
12355
+ }
12356
+ release() {
12357
+ this.acquired--;
12358
+ const next = this.waiting.shift();
12359
+ if (next) {
12360
+ this.acquired++;
12361
+ next.resolve();
12362
+ } else {
12363
+ this.permits++;
12364
+ }
12365
+ }
12366
+ drain() {
12367
+ this.draining = true;
12368
+ for (const waiter of this.waiting) {
12369
+ waiter.reject(new Error("Semaphore drained"));
12370
+ }
12371
+ this.waiting = [];
12372
+ }
12373
+ get activeCount() {
12374
+ return this.acquired;
12375
+ }
12376
+ get waitingCount() {
12377
+ return this.waiting.length;
12378
+ }
12379
+ }
12380
+ function createTask(id, command, cwd, timeout, sessionID, metadata) {
12381
+ let resolveCompletion;
12382
+ const completionPromise = new Promise((r) => {
12383
+ resolveCompletion = r;
12384
+ });
12385
+ return {
12386
+ id,
12387
+ sessionID,
12388
+ command,
12389
+ cwd,
12390
+ status: "pending",
12391
+ stdout: `/tmp/${id}-stdout`,
12392
+ stderr: `/tmp/${id}-stderr`,
12393
+ metadata,
12394
+ createdAt: Date.now(),
12395
+ timeout,
12396
+ abortController: new AbortController,
12397
+ completionPromise,
12398
+ resolveCompletion
12399
+ };
12400
+ }
12401
+ function matchMetadata(taskMeta, filter) {
12402
+ if (!taskMeta)
12403
+ return false;
12404
+ for (const [key, value] of Object.entries(filter)) {
12405
+ if (taskMeta[key] !== value)
12406
+ return false;
12407
+ }
12408
+ return true;
12409
+ }
12410
+
12411
+ class TaskHistory {
12412
+ capacity;
12413
+ buffer;
12414
+ head = 0;
12415
+ count = 0;
12416
+ constructor(capacity) {
12417
+ this.capacity = capacity;
12418
+ this.buffer = new Array(capacity);
12419
+ }
12420
+ push(entry) {
12421
+ this.buffer[this.head] = entry;
12422
+ this.head = (this.head + 1) % this.capacity;
12423
+ if (this.count < this.capacity)
12424
+ this.count++;
12425
+ }
12426
+ entries(statusFilter, metadataFilter) {
12427
+ const result = [];
12428
+ const start = this.count < this.capacity ? 0 : this.head;
12429
+ for (let i = 0;i < this.count; i++) {
12430
+ const idx = (start + i) % this.capacity;
12431
+ const entry = this.buffer[idx];
12432
+ if (!entry)
12433
+ continue;
12434
+ if (statusFilter && entry.status !== statusFilter)
12435
+ continue;
12436
+ if (metadataFilter && !matchMetadata(entry.metadata, metadataFilter))
12437
+ continue;
12438
+ result.push({ ...entry });
12439
+ }
12440
+ return result;
12441
+ }
12442
+ get length() {
12443
+ return this.count;
12444
+ }
12445
+ clear() {
12446
+ this.buffer = new Array(this.capacity);
12447
+ this.head = 0;
12448
+ this.count = 0;
12449
+ }
12450
+ }
12451
+
12452
+ class TaskRegistry {
12453
+ tasks = new Map;
12454
+ semaphore;
12455
+ nextId = 0;
12456
+ evictionTimers = new Map;
12457
+ accepting = true;
12458
+ options;
12459
+ client;
12460
+ taskHistory;
12461
+ constructor(options = {}) {
12462
+ this.options = {
12463
+ maxConcurrent: options.maxConcurrent ?? 50,
12464
+ taskTTL: options.taskTTL ?? 30000,
12465
+ maxOutputSize: options.maxOutputSize ?? 5 * 1024 * 1024,
12466
+ maxHistory: options.maxHistory ?? 200
12467
+ };
12468
+ this.client = options.client;
12469
+ this.semaphore = new Semaphore(this.options.maxConcurrent);
12470
+ this.taskHistory = new TaskHistory(this.options.maxHistory);
12471
+ }
12472
+ async spawn(command, options = {}) {
12473
+ if (!this.accepting) {
12474
+ throw new Error("Registry is shutting down");
12475
+ }
12476
+ const id = `task-${this.nextId++}`;
12477
+ const cwd = options.cwd ?? process.cwd();
12478
+ const task = createTask(id, command, cwd, options.timeout, options.sessionID, options.metadata);
12479
+ this.tasks.set(id, task);
12480
+ this.executeTask(task);
12481
+ return id;
12482
+ }
12483
+ async executeTask(task) {
12484
+ try {
12485
+ await this.semaphore.acquire();
12486
+ } catch (e) {
12487
+ task.status = "cancelled";
12488
+ task.error = String(e);
12489
+ task.resolveCompletion();
12490
+ return;
12491
+ }
12492
+ try {
12493
+ task.status = "running";
12494
+ task.startedAt = Date.now();
12495
+ const timeoutId = task.timeout ? setTimeout(() => task.abortController.abort(), task.timeout) : null;
12496
+ try {
12497
+ const proc = Bun.spawn(["sh", "-c", task.command], {
12498
+ cwd: task.cwd,
12499
+ stdout: Bun.file(task.stdout),
12500
+ stderr: Bun.file(task.stderr)
12501
+ });
12502
+ task.proc = proc;
12503
+ task.abortController.signal.addEventListener("abort", () => {
12504
+ proc.kill();
12505
+ });
12506
+ const exitCode = await proc.exited;
12507
+ task.exitCode = exitCode;
12508
+ if (task.abortController.signal.aborted) {
12509
+ task.status = "cancelled";
12510
+ } else {
12511
+ task.status = exitCode === 0 ? "completed" : "failed";
12512
+ }
12513
+ } catch (error45) {
12514
+ const errorMsg = `
12515
+ [Task execution error: ${String(error45)}]
12516
+ `;
12517
+ await appendFile(task.stderr, errorMsg);
12518
+ if (task.abortController.signal.aborted) {
12519
+ task.status = "cancelled";
12520
+ } else {
12521
+ task.status = "failed";
12522
+ task.error = String(error45);
12523
+ }
12524
+ } finally {
12525
+ if (timeoutId)
12526
+ clearTimeout(timeoutId);
12527
+ task.completedAt = Date.now();
12528
+ }
12529
+ } finally {
12530
+ this.semaphore.release();
12531
+ task.resolveCompletion();
12532
+ this.scheduleEviction(task.id);
12533
+ this.notifyCompletion(task);
12534
+ }
12535
+ }
12536
+ notifyCompletion(task) {
12537
+ if (!this.client || !task.sessionID)
12538
+ return;
12539
+ this.client.session.promptAsync({
12540
+ path: { id: task.sessionID },
12541
+ body: {
12542
+ parts: [
12543
+ {
12544
+ type: "text",
12545
+ text: `<task-notification>
12546
+ <task-id>${task.id}</task-id>
12547
+ <status>${task.status}</status>
12548
+ <exit-code>${task.exitCode ?? "N/A"}</exit-code>
12549
+ <stdout-path>${task.stdout}</stdout-path>
12550
+ <stderr-path>${task.stderr}</stderr-path>
12551
+ </task-notification>`
12552
+ }
12553
+ ]
12554
+ }
12555
+ });
12556
+ }
12557
+ scheduleEviction(id) {
12558
+ const existing = this.evictionTimers.get(id);
12559
+ if (existing)
12560
+ clearTimeout(existing);
12561
+ const timer = setTimeout(() => {
12562
+ const task = this.tasks.get(id);
12563
+ if (task) {
12564
+ this.taskHistory.push({
12565
+ id: task.id,
12566
+ command: task.command.slice(0, 200),
12567
+ status: task.status,
12568
+ exitCode: task.exitCode,
12569
+ metadata: task.metadata,
12570
+ stdout: task.stdout,
12571
+ stderr: task.stderr,
12572
+ createdAt: task.createdAt,
12573
+ startedAt: task.startedAt,
12574
+ completedAt: task.completedAt
12575
+ });
12576
+ }
12577
+ this.tasks.delete(id);
12578
+ this.evictionTimers.delete(id);
12579
+ }, this.options.taskTTL);
12580
+ this.evictionTimers.set(id, timer);
12581
+ }
12582
+ get(id) {
12583
+ const task = this.tasks.get(id);
12584
+ if (task?.completedAt) {
12585
+ this.scheduleEviction(id);
12586
+ }
12587
+ return task;
12588
+ }
12589
+ async wait(id, timeoutMs = 30000) {
12590
+ const task = this.tasks.get(id);
12591
+ if (!task)
12592
+ throw new Error(`Task ${id} not found`);
12593
+ if (task.status !== "pending" && task.status !== "running") {
12594
+ return task;
12595
+ }
12596
+ const timeoutPromise = new Promise((_, reject) => {
12597
+ setTimeout(() => reject(new Error(`Task ${id} wait timed out`)), timeoutMs);
12598
+ });
12599
+ await Promise.race([task.completionPromise, timeoutPromise]);
12600
+ return task;
12601
+ }
12602
+ stop(id, signal) {
12603
+ const task = this.tasks.get(id);
12604
+ if (!task)
12605
+ return false;
12606
+ if (task.status === "running" && task.proc) {
12607
+ if (signal === "SIGKILL") {
12608
+ task.proc.kill(9);
12609
+ } else {
12610
+ task.proc.kill();
12611
+ }
12612
+ task.abortController.abort();
12613
+ return true;
12614
+ }
12615
+ if (task.status === "pending") {
12616
+ task.abortController.abort();
12617
+ task.status = "cancelled";
12618
+ task.resolveCompletion();
12619
+ return true;
12620
+ }
12621
+ return false;
12622
+ }
12623
+ list(statusFilter, metadataFilter) {
12624
+ let tasks = Array.from(this.tasks.values());
12625
+ if (statusFilter && statusFilter !== "all") {
12626
+ tasks = tasks.filter((t) => t.status === statusFilter);
12627
+ }
12628
+ if (metadataFilter) {
12629
+ tasks = tasks.filter((t) => matchMetadata(t.metadata, metadataFilter));
12630
+ }
12631
+ return tasks;
12632
+ }
12633
+ async shutdown(gracePeriodMs = 3000) {
12634
+ this.accepting = false;
12635
+ this.semaphore.drain();
12636
+ for (const timer of this.evictionTimers.values()) {
12637
+ clearTimeout(timer);
12638
+ }
12639
+ this.evictionTimers.clear();
12640
+ const runningTasks = [];
12641
+ for (const task of this.tasks.values()) {
12642
+ if (task.status === "running" && task.proc) {
12643
+ task.proc.kill();
12644
+ task.abortController.abort();
12645
+ runningTasks.push(task);
12646
+ }
12647
+ }
12648
+ if (runningTasks.length === 0)
12649
+ return;
12650
+ const graceDeadline = Date.now() + gracePeriodMs;
12651
+ for (const task of runningTasks) {
12652
+ const remaining = graceDeadline - Date.now();
12653
+ if (remaining > 0) {
12654
+ try {
12655
+ await Promise.race([
12656
+ task.completionPromise,
12657
+ new Promise((r) => setTimeout(r, remaining))
12658
+ ]);
12659
+ } catch {}
12660
+ }
12661
+ if (task.status === "running" && task.proc) {
12662
+ task.proc.kill(9);
12663
+ }
12664
+ }
12665
+ }
12666
+ get size() {
12667
+ return this.tasks.size;
12668
+ }
12669
+ get activeCount() {
12670
+ return this.semaphore.activeCount;
12671
+ }
12672
+ get waitingCount() {
12673
+ return this.semaphore.waitingCount;
12674
+ }
12675
+ history(statusFilter, metadataFilter) {
12676
+ return this.taskHistory.entries(statusFilter, metadataFilter);
12677
+ }
12678
+ clear() {
12679
+ for (const timer of this.evictionTimers.values()) {
12680
+ clearTimeout(timer);
12681
+ }
12682
+ this.evictionTimers.clear();
12683
+ this.tasks.clear();
12684
+ this.taskHistory.clear();
12685
+ }
12686
+ }
12687
+
12332
12688
  // index.ts
12333
- var CustomToolPlugin = async () => {
12689
+ var CustomToolPlugin = async (input) => {
12690
+ const registry2 = new TaskRegistry({
12691
+ maxConcurrent: 50,
12692
+ client: input.client
12693
+ });
12334
12694
  return {
12335
12695
  tool: {
12336
- DemoTool: tool({
12337
- description: "A demo tool",
12696
+ Task: tool({
12697
+ description: `Spawn background task.
12698
+
12699
+ Returns:
12700
+ - taskId: unique identifier
12701
+ - stdout/stderr: file paths (LIVE - readable during execution, not just after)
12702
+
12703
+ You will receive a notification when it completes.`,
12704
+ args: {
12705
+ command: tool.schema.string().describe("Shell command to execute"),
12706
+ cwd: tool.schema.string().optional().describe("Working directory"),
12707
+ timeout: tool.schema.number().optional().describe("Timeout in milliseconds"),
12708
+ metadata: tool.schema.record(tool.schema.string(), tool.schema.string()).optional().describe("Key-value metadata tags for filtering")
12709
+ },
12710
+ async execute(args, context) {
12711
+ const id = await registry2.spawn(args.command, {
12712
+ cwd: args.cwd,
12713
+ timeout: args.timeout,
12714
+ sessionID: context.sessionID,
12715
+ metadata: args.metadata
12716
+ });
12717
+ const task = registry2.get(id);
12718
+ return JSON.stringify({
12719
+ taskId: id,
12720
+ stdout: task?.stdout,
12721
+ stderr: task?.stderr,
12722
+ message: `Task ${id} spawned. You will receive a notification when complete.`
12723
+ });
12724
+ }
12725
+ }),
12726
+ TaskStop: tool({
12727
+ description: "Stop/cancel a running background task",
12728
+ args: {
12729
+ taskId: tool.schema.string().describe("Task ID to stop"),
12730
+ signal: tool.schema.enum(["SIGTERM", "SIGKILL"]).optional().describe("Signal to send (default SIGTERM)")
12731
+ },
12732
+ async execute(args) {
12733
+ const stopped = registry2.stop(args.taskId, args.signal);
12734
+ return JSON.stringify({ success: stopped, taskId: args.taskId });
12735
+ }
12736
+ }),
12737
+ TaskList: tool({
12738
+ description: "List background tasks with status, output paths, and metadata. Supports filtering by status and metadata. Use includeHistory to see evicted tasks.",
12338
12739
  args: {
12339
- input: tool.schema.string().describe("Input to process")
12740
+ status: tool.schema.enum([
12741
+ "all",
12742
+ "running",
12743
+ "completed",
12744
+ "failed",
12745
+ "pending",
12746
+ "cancelled"
12747
+ ]).optional().describe("Filter by status"),
12748
+ metadata: tool.schema.record(tool.schema.string(), tool.schema.string()).optional().describe("Filter by metadata key-value pairs (all must match)"),
12749
+ includeHistory: tool.schema.boolean().optional().describe("Include evicted tasks from history buffer (default false)")
12340
12750
  },
12341
12751
  async execute(args) {
12342
- return `Processed: ${args.input}`;
12752
+ const tasks = registry2.list(args.status, args.metadata);
12753
+ const result = {
12754
+ count: tasks.length,
12755
+ tasks: tasks.map((t) => ({
12756
+ id: t.id,
12757
+ status: t.status,
12758
+ command: t.command.slice(0, 100),
12759
+ stdout: t.stdout,
12760
+ stderr: t.stderr,
12761
+ metadata: t.metadata,
12762
+ createdAt: t.createdAt,
12763
+ duration: t.completedAt && t.startedAt ? t.completedAt - t.startedAt : undefined
12764
+ }))
12765
+ };
12766
+ if (args.includeHistory) {
12767
+ const history = registry2.history(args.status, args.metadata);
12768
+ result.history = history;
12769
+ result.historyCount = history.length;
12770
+ }
12771
+ return JSON.stringify(result);
12343
12772
  }
12344
12773
  })
12345
12774
  }
12346
12775
  };
12347
12776
  };
12777
+ var opencode_task_default = CustomToolPlugin;
12348
12778
  export {
12779
+ opencode_task_default as default,
12349
12780
  CustomToolPlugin
12350
12781
  };
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "opencode-task",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "files": ["dist"],
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/cs50victor/opencode-task"
10
+ },
7
11
  "scripts": {
8
12
  "typecheck": "tsc --noEmit",
9
13
  "lint": "biome check .",
@@ -13,6 +17,7 @@
13
17
  "devDependencies": {
14
18
  "@biomejs/biome": "^1.9.0",
15
19
  "@opencode-ai/plugin": "latest",
20
+ "@types/bun": "latest",
16
21
  "typescript": "^5.0.0"
17
22
  }
18
23
  }