@itsaplan/runner 0.3.0 → 0.5.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 (2) hide show
  1. package/dist/cli.js +152 -20
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -25,11 +25,13 @@ class AnswerStream {
25
25
  sawPartialText = false;
26
26
  sawAnyText = false;
27
27
  sending = Promise.resolve();
28
+ usage;
28
29
  constructor(format, threadId, runId, send) {
29
30
  this.format = format;
30
31
  this.threadId = threadId;
31
32
  this.runId = runId;
32
33
  this.send = send;
34
+ this.usage = new UsageReader(format);
33
35
  this.messageId = `msg-${runId}`;
34
36
  this.queued.push({ type: "RUN_STARTED", threadId, runId });
35
37
  }
@@ -48,6 +50,9 @@ class AnswerStream {
48
50
  startedSession() {
49
51
  return this.sessionId;
50
52
  }
53
+ contextUsage() {
54
+ return this.usage.value();
55
+ }
51
56
  async flush() {
52
57
  const sending = this.sending.catch(() => {}).then(() => this.sendQueued());
53
58
  this.sending = sending.catch(() => {});
@@ -127,6 +132,7 @@ class AnswerStream {
127
132
  }
128
133
  if (!parsed || typeof parsed !== "object")
129
134
  return;
135
+ this.usage.readLine(parsed);
130
136
  switch (this.format) {
131
137
  case "claude-stream-json":
132
138
  this.readClaudeLine(parsed);
@@ -275,7 +281,9 @@ class AnswerStream {
275
281
  }
276
282
  }
277
283
  readPartial(event) {
278
- if (event?.type !== "content_block_delta")
284
+ if (!event)
285
+ return;
286
+ if (event.type !== "content_block_delta")
279
287
  return;
280
288
  if (event.delta?.type === "text_delta" && event.delta.text) {
281
289
  this.sawPartialText = true;
@@ -313,6 +321,108 @@ class AnswerStream {
313
321
  });
314
322
  }
315
323
  }
324
+
325
+ class UsageReader {
326
+ format;
327
+ last = null;
328
+ buffered = "";
329
+ constructor(format) {
330
+ this.format = format;
331
+ }
332
+ write(chunk) {
333
+ if (this.format === "text")
334
+ return;
335
+ this.buffered += chunk;
336
+ const lines = this.buffered.split(`
337
+ `);
338
+ this.buffered = lines.pop() ?? "";
339
+ for (const line of lines)
340
+ this.readText(line);
341
+ }
342
+ end() {
343
+ if (!this.buffered)
344
+ return;
345
+ this.readText(this.buffered);
346
+ this.buffered = "";
347
+ }
348
+ value() {
349
+ if (this.format === "copilot-json")
350
+ return null;
351
+ return this.last ?? undefined;
352
+ }
353
+ readText(line) {
354
+ const trimmed = line.trim();
355
+ if (!trimmed)
356
+ return;
357
+ try {
358
+ const parsed = JSON.parse(trimmed);
359
+ if (parsed && typeof parsed === "object")
360
+ this.readLine(parsed);
361
+ } catch {}
362
+ }
363
+ readLine(parsed) {
364
+ switch (this.format) {
365
+ case "claude-stream-json":
366
+ this.readClaude(parsed);
367
+ return;
368
+ case "codex-jsonl":
369
+ this.readCodex(parsed);
370
+ return;
371
+ case "opencode-json":
372
+ this.readOpencode(parsed);
373
+ return;
374
+ case "antigravity-stream-json":
375
+ this.readAntigravity(parsed);
376
+ return;
377
+ }
378
+ }
379
+ readClaude(message) {
380
+ const event = message.type === "stream_event" ? message.event : undefined;
381
+ if (!event)
382
+ return;
383
+ const usage = event.type === "message_start" ? event.message?.usage : event.usage;
384
+ if (!usage)
385
+ return;
386
+ const read = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
387
+ const written = usage.output_tokens ?? 0;
388
+ if (event.type === "message_start") {
389
+ this.last = { inputTokens: read, outputTokens: written };
390
+ return;
391
+ }
392
+ if (event.type !== "message_delta")
393
+ return;
394
+ this.last = { inputTokens: read || (this.last?.inputTokens ?? 0), outputTokens: written };
395
+ }
396
+ readCodex(message) {
397
+ if (message.type !== "turn.completed" || !message.usage)
398
+ return;
399
+ this.last = {
400
+ inputTokens: message.usage.input_tokens ?? 0,
401
+ outputTokens: message.usage.output_tokens ?? 0
402
+ };
403
+ }
404
+ readOpencode(message) {
405
+ const tokens = message.part?.type === "step-finish" ? message.part.tokens : undefined;
406
+ if (!tokens)
407
+ return;
408
+ this.last = {
409
+ inputTokens: (tokens.input ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0),
410
+ outputTokens: (tokens.output ?? 0) + (tokens.reasoning ?? 0)
411
+ };
412
+ }
413
+ readAntigravity(message) {
414
+ const step = message.step_update;
415
+ if (!step)
416
+ return;
417
+ const usage = step.usage ?? message.usage;
418
+ if (!usage)
419
+ return;
420
+ this.last = {
421
+ inputTokens: (usage.input_tokens ?? 0) + (usage.cache_read_tokens ?? 0),
422
+ outputTokens: (usage.output_tokens ?? 0) + (usage.thinking_tokens ?? 0)
423
+ };
424
+ }
425
+ }
316
426
  function tail(text, limit) {
317
427
  return text.length <= limit ? text : `…${text.slice(-limit)}`;
318
428
  }
@@ -620,7 +730,7 @@ function stdinText(preset, task) {
620
730
  return "";
621
731
  return presetPrompt(preset, task.systemPrompt, task.prompt);
622
732
  }
623
- async function execute(config, task, onData) {
733
+ async function execute(config, task, opts = {}) {
624
734
  const preset = presetOf(config);
625
735
  const [bin, args] = spawnArgs(config, preset, task);
626
736
  const child = spawn(bin, args, {
@@ -628,19 +738,23 @@ async function execute(config, task, onData) {
628
738
  env: childEnv(config, task),
629
739
  detached: true
630
740
  });
741
+ const kill = () => {
742
+ if (child.pid !== undefined)
743
+ killGroup(child.pid);
744
+ };
631
745
  let timedOut = false;
632
746
  const timer = setTimeout(() => {
633
747
  timedOut = true;
634
- if (child.pid !== undefined)
635
- killGroup(child.pid);
748
+ kill();
636
749
  }, config.timeoutMs);
750
+ opts.signal?.addEventListener("abort", kill, { once: true });
637
751
  let stdout = "";
638
752
  let stderr = "";
639
753
  child.stdout.setEncoding("utf8");
640
754
  child.stderr.setEncoding("utf8");
641
755
  child.stdout.on("data", (chunk) => {
642
756
  stdout = tail2(stdout + chunk, OUTPUT_LIMIT);
643
- onData?.(chunk);
757
+ opts.onData?.(chunk);
644
758
  });
645
759
  child.stderr.on("data", (chunk) => {
646
760
  stderr = tail2(stderr + chunk, ERROR_LIMIT);
@@ -657,6 +771,7 @@ async function execute(config, task, onData) {
657
771
  ({ code, signal } = await exited);
658
772
  } finally {
659
773
  clearTimeout(timer);
774
+ opts.signal?.removeEventListener("abort", kill);
660
775
  }
661
776
  const output = stdout.trim();
662
777
  if (code === 0)
@@ -672,13 +787,14 @@ async function execute(config, task, onData) {
672
787
 
673
788
  // src/chat.ts
674
789
  var FLUSH_MS = 500;
675
- async function answer(config, client, message) {
790
+ async function answer(config, client, message, stop) {
676
791
  let reported = message.sessionId !== null;
677
- const stream = new AnswerStream(config.outputFormat, message.threadId, String(message.id), (events) => {
792
+ const stream = new AnswerStream(config.outputFormat, message.threadId, String(message.id), async (events) => {
678
793
  const started = reported ? undefined : stream.startedSession() ?? undefined;
679
794
  if (started)
680
795
  reported = true;
681
- return client.chatEvents(message.id, events, started);
796
+ if (await client.chatEvents(message.id, events, started))
797
+ stop.abort();
682
798
  });
683
799
  const flushing = setInterval(() => {
684
800
  stream.flush().catch(() => {});
@@ -694,15 +810,17 @@ async function answer(config, client, message) {
694
810
  ITSAPLAN_MESSAGE_ID: String(message.id),
695
811
  ITSAPLAN_SESSION_ID: message.sessionId ?? ""
696
812
  }
697
- }, (chunk) => stream.write(chunk)).finally(() => clearInterval(flushing));
813
+ }, { onData: (chunk) => stream.write(chunk), signal: stop.signal }).finally(() => clearInterval(flushing));
814
+ if (stop.signal.aborted)
815
+ return;
698
816
  if (outcome.status === "success") {
699
817
  await stream.finish(outcome.output);
700
- await client.chatResult(message.id, { status: "success" });
818
+ await client.chatResult(message.id, { status: "success", usage: stream.contextUsage() });
701
819
  return;
702
820
  }
703
821
  const error = outcome.error ?? "The command failed";
704
822
  await stream.fail(error, outcome.output);
705
- await client.chatResult(message.id, { status: "failed", error });
823
+ await client.chatResult(message.id, { status: "failed", error, usage: stream.contextUsage() });
706
824
  }
707
825
 
708
826
  // src/client.ts
@@ -754,18 +872,23 @@ class Client {
754
872
  return body.message;
755
873
  }
756
874
  async chatEvents(messageId, events, sessionId) {
757
- await this.post(`/agent-chats/${messageId}/events`, {
875
+ const res = await this.post(`/agent-chats/${messageId}/events`, {
758
876
  events,
759
877
  ...sessionId && { sessionId }
760
878
  });
879
+ return canceled(res);
761
880
  }
762
881
  async chatHeartbeat(messageId) {
763
- await this.post(`/agent-chats/${messageId}/heartbeat`);
882
+ return canceled(await this.post(`/agent-chats/${messageId}/heartbeat`));
764
883
  }
765
884
  async chatResult(messageId, result) {
766
885
  await this.post(`/agent-chats/${messageId}/result`, result);
767
886
  }
768
887
  }
888
+ async function canceled(res) {
889
+ const body = await res.json().catch(() => ({}));
890
+ return body.canceled === true;
891
+ }
769
892
 
770
893
  // src/cli.ts
771
894
  var HEARTBEAT_MS = 60000;
@@ -803,8 +926,10 @@ async function handle(config, client, log2, run) {
803
926
  const label = run.issueIdentifier ?? `run ${run.id}`;
804
927
  log2(`${label}: started (${run.trigger})`);
805
928
  try {
806
- const outcome = await withHeartbeat(log2, () => client.heartbeat(run.id), execute(config, taskOf(run)));
807
- await client.report(run.id, outcome);
929
+ const usage = new UsageReader(config.outputFormat);
930
+ const outcome = await withHeartbeat(log2, () => client.heartbeat(run.id), execute(config, taskOf(run), { onData: (chunk) => usage.write(chunk) }));
931
+ usage.end();
932
+ await client.report(run.id, { ...outcome, usage: usage.value() });
808
933
  log2(`${label}: ${outcome.status}${outcome.error ? ` — ${outcome.error}` : ""}`);
809
934
  } catch (err) {
810
935
  const message = err instanceof Error ? err.message : String(err);
@@ -814,14 +939,21 @@ async function handle(config, client, log2, run) {
814
939
  }
815
940
  async function handleChat(config, client, log2, message) {
816
941
  log2(`chat ${message.id}: answering`);
942
+ const stop = new AbortController;
817
943
  try {
818
- await withHeartbeat(log2, () => client.chatHeartbeat(message.id), answer(config, client, message));
819
- log2(`chat ${message.id}: answered`);
944
+ await withHeartbeat(log2, async () => {
945
+ if (await client.chatHeartbeat(message.id))
946
+ stop.abort();
947
+ }, answer(config, client, message, stop));
820
948
  } catch (err) {
821
- const text = err instanceof Error ? err.message : String(err);
822
- log2(`chat ${message.id}: runner error — ${text}`);
823
- await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
949
+ if (!stop.signal.aborted) {
950
+ const text = err instanceof Error ? err.message : String(err);
951
+ log2(`chat ${message.id}: runner error — ${text}`);
952
+ await client.chatResult(message.id, { status: "failed", error: text }).catch(() => {});
953
+ return;
954
+ }
824
955
  }
956
+ log2(`chat ${message.id}: ${stop.signal.aborted ? "stopped" : "answered"}`);
825
957
  }
826
958
  async function drain(state, log2, concurrency, take, run, onEmpty) {
827
959
  const active = new Set;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itsaplan/runner",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Runs an Itsaplan external agent's queued tasks and answers its chat on your own machine",
5
5
  "keywords": [
6
6
  "itsaplan",