@harmonyos-arkts/opencode-acp 0.0.6 → 0.0.8

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.cjs CHANGED
@@ -374,15 +374,15 @@ var require_readShebang = __commonJS({
374
374
  var shebangCommand = require_shebang_command();
375
375
  function readShebang(command) {
376
376
  const size = 150;
377
- const buffer = Buffer.alloc(size);
377
+ const buffer2 = Buffer.alloc(size);
378
378
  let fd;
379
379
  try {
380
380
  fd = fs.openSync(command, "r");
381
- fs.readSync(fd, buffer, 0, size, 0);
381
+ fs.readSync(fd, buffer2, 0, size, 0);
382
382
  fs.closeSync(fd);
383
383
  } catch (e) {
384
384
  }
385
- return shebangCommand(buffer.toString());
385
+ return shebangCommand(buffer2.toString());
386
386
  }
387
387
  module2.exports = readShebang;
388
388
  }
@@ -15846,7 +15846,7 @@ var createSseClient = ({ onRequest, onSseError, onSseEvent, responseTransformer,
15846
15846
  if (!response.body)
15847
15847
  throw new Error("No body in SSE response");
15848
15848
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
15849
- let buffer = "";
15849
+ let buffer2 = "";
15850
15850
  const abortHandler = () => {
15851
15851
  try {
15852
15852
  reader.cancel();
@@ -15859,10 +15859,10 @@ var createSseClient = ({ onRequest, onSseError, onSseEvent, responseTransformer,
15859
15859
  const { done, value } = await reader.read();
15860
15860
  if (done)
15861
15861
  break;
15862
- buffer += value;
15863
- buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15864
- const chunks = buffer.split("\n\n");
15865
- buffer = chunks.pop() ?? "";
15862
+ buffer2 += value;
15863
+ buffer2 = buffer2.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
15864
+ const chunks = buffer2.split("\n\n");
15865
+ buffer2 = chunks.pop() ?? "";
15866
15866
  for (const chunk of chunks) {
15867
15867
  const lines = chunk.split("\n");
15868
15868
  const dataLines = [];
@@ -19704,6 +19704,14 @@ var SessionManager = class {
19704
19704
  getChildren(parentId) {
19705
19705
  return [...this.children.get(parentId) ?? []];
19706
19706
  }
19707
+ /** All registered session IDs (parent + discovered children). */
19708
+ listSessionIds() {
19709
+ return [...this.sessions.keys()];
19710
+ }
19711
+ /** Top-level sessions only (no parentID) — used for global error broadcast. */
19712
+ listTopLevelSessionIds() {
19713
+ return [...this.sessions.values()].filter((s) => !s.parentID).map((s) => s.id);
19714
+ }
19707
19715
  // Model and mode management
19708
19716
  getModel(sessionId) {
19709
19717
  return this.get(sessionId).model;
@@ -20290,9 +20298,15 @@ var import_fs = require("fs");
20290
20298
  var import_path = require("path");
20291
20299
  var import_os = require("os");
20292
20300
  var LOG_DIR = (0, import_path.join)((0, import_os.homedir)(), ".harmony-acp", "logs");
20301
+ var MAX_LOG_SIZE_BYTES = 5 * 1024 * 1024;
20302
+ var MAX_LOG_AGE_DAYS = 7;
20293
20303
  var MAX_LOG_FILES = 30;
20304
+ var FLUSH_INTERVAL_MS = 500;
20305
+ var BUFFER_SIZE_LIMIT = 50;
20294
20306
  var logFile = "";
20295
20307
  var enabled = false;
20308
+ var buffer = [];
20309
+ var flushTimer = null;
20296
20310
  function initLogger() {
20297
20311
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
20298
20312
  logFile = (0, import_path.join)(LOG_DIR, `${ts}.log`);
@@ -20302,24 +20316,72 @@ function initLogger() {
20302
20316
  }
20303
20317
  function setLogEnabled(v) {
20304
20318
  enabled = v;
20319
+ if (!v) flush();
20305
20320
  }
20306
20321
  function cleanOldLogs() {
20307
20322
  try {
20308
- const files = (0, import_fs.readdirSync)(LOG_DIR).filter((f) => f.endsWith(".log")).map((f) => ({
20309
- name: f,
20310
- path: (0, import_path.join)(LOG_DIR, f),
20311
- mtime: (0, import_fs.statSync)((0, import_path.join)(LOG_DIR, f)).mtime.getTime()
20312
- })).sort((a, b) => b.mtime - a.mtime);
20313
- for (let i = MAX_LOG_FILES; i < files.length; i++) {
20323
+ const now = Date.now();
20324
+ const maxAgeMs = MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1e3;
20325
+ const files = (0, import_fs.readdirSync)(LOG_DIR).filter((f) => f.endsWith(".log")).map((f) => {
20326
+ const full = (0, import_path.join)(LOG_DIR, f);
20327
+ try {
20328
+ return { name: f, path: full, mtime: (0, import_fs.statSync)(full).mtime.getTime() };
20329
+ } catch {
20330
+ return null;
20331
+ }
20332
+ }).filter((e) => e !== null);
20333
+ for (const f of files) {
20334
+ if (now - f.mtime > maxAgeMs) {
20335
+ try {
20336
+ (0, import_fs.unlinkSync)(f.path);
20337
+ } catch {
20338
+ }
20339
+ }
20340
+ }
20341
+ const surviving = files.filter((f) => now - f.mtime <= maxAgeMs).sort((a, b) => b.mtime - a.mtime);
20342
+ for (let i = MAX_LOG_FILES; i < surviving.length; i++) {
20314
20343
  try {
20315
- const { unlinkSync } = require("fs");
20316
- unlinkSync(files[i].path);
20344
+ (0, import_fs.unlinkSync)(surviving[i].path);
20317
20345
  } catch {
20318
20346
  }
20319
20347
  }
20320
20348
  } catch {
20321
20349
  }
20322
20350
  }
20351
+ function rotateIfNeeded() {
20352
+ try {
20353
+ const stat = (0, import_fs.statSync)(logFile);
20354
+ if (stat.size >= MAX_LOG_SIZE_BYTES) {
20355
+ const now = /* @__PURE__ */ new Date();
20356
+ const pad = (n) => String(n).padStart(2, "0");
20357
+ const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
20358
+ const baseName = logFile.replace(/\.log$/, "");
20359
+ (0, import_fs.renameSync)(logFile, `${baseName}.${ts}.log`);
20360
+ }
20361
+ } catch {
20362
+ }
20363
+ }
20364
+ function flush() {
20365
+ if (flushTimer) {
20366
+ clearTimeout(flushTimer);
20367
+ flushTimer = null;
20368
+ }
20369
+ if (buffer.length === 0) return;
20370
+ const data = buffer.join("");
20371
+ buffer = [];
20372
+ try {
20373
+ (0, import_fs.appendFileSync)(logFile, data);
20374
+ rotateIfNeeded();
20375
+ } catch {
20376
+ }
20377
+ }
20378
+ function scheduleFlush() {
20379
+ if (flushTimer) return;
20380
+ flushTimer = setTimeout(() => {
20381
+ flushTimer = null;
20382
+ flush();
20383
+ }, FLUSH_INTERVAL_MS);
20384
+ }
20323
20385
  function write(category, action, data) {
20324
20386
  if (!enabled) return;
20325
20387
  const entry = {
@@ -20328,9 +20390,11 @@ function write(category, action, data) {
20328
20390
  action,
20329
20391
  ...data && { data }
20330
20392
  };
20331
- try {
20332
- (0, import_fs.appendFileSync)(logFile, JSON.stringify(entry) + "\n");
20333
- } catch {
20393
+ buffer.push(JSON.stringify(entry) + "\n");
20394
+ if (buffer.length >= BUFFER_SIZE_LIMIT) {
20395
+ flush();
20396
+ } else {
20397
+ scheduleFlush();
20334
20398
  }
20335
20399
  }
20336
20400
  function acpIn(method, params) {
@@ -20433,13 +20497,105 @@ async function sendToClient(connection, params) {
20433
20497
  const update = params.update;
20434
20498
  acpOut(`sessionUpdate.${updateType}`, {
20435
20499
  sessionId: params.sessionId.slice(0, 12),
20436
- ...updateType === "agent_message_chunk" || updateType === "agent_thought_chunk" ? { messageId: update.messageId?.slice(0, 12), delta: update.content?.text?.slice(0, 200) } : updateType === "tool_call" || updateType === "tool_call_update" ? { toolCallId: update.toolCallId, tool: update.title, kind: update.kind, status: update.status } : updateType === "session_info_update" ? { title: update.title, _meta: update._meta } : updateType === "usage_update" ? { used: update.used, size: update.size, cost: update.cost, _meta: update._meta } : {}
20500
+ ...updateType === "agent_message_chunk" || updateType === "agent_thought_chunk" ? { messageId: update.messageId?.slice(0, 12), delta: update.content?.text?.slice(0, 200) } : updateType === "tool_call" || updateType === "tool_call_update" ? { toolCallId: update.toolCallId, tool: update.title, kind: update.kind, status: update.status } : updateType === "session_info_update" ? { title: update.title, _meta: update._meta } : updateType === "usage_update" ? {
20501
+ used: update.used,
20502
+ size: update.size,
20503
+ cost: update.cost,
20504
+ _meta: update._meta,
20505
+ error: update._meta?.error?.message
20506
+ } : update._meta?.error ? { error: update._meta.error?.message ?? update._meta.error } : {}
20437
20507
  });
20438
20508
  return connection.sessionUpdate(params);
20439
20509
  }
20440
20510
 
20511
+ // src/acp-error.ts
20512
+ function inferCode(err) {
20513
+ const name = typeof err.name === "string" ? err.name : "";
20514
+ if (name === "AI_LoadAPIKeyError" || /auth/i.test(name)) return "auth_required";
20515
+ if (/provider|api|model/i.test(name)) return "provider_error";
20516
+ return "turn_error";
20517
+ }
20518
+ function extractTurnError(raw) {
20519
+ return normalizeError(raw.responseError) ?? normalizeError(raw.messageError);
20520
+ }
20521
+ function normalizeError(err) {
20522
+ if (err == null || err === false) return void 0;
20523
+ if (typeof err === "string" && err.trim()) {
20524
+ return { code: "turn_error", message: err.trim(), display: "inline" };
20525
+ }
20526
+ if (err instanceof Error && err.message.trim()) {
20527
+ return {
20528
+ code: inferCode({ name: err.name }),
20529
+ message: err.message.trim(),
20530
+ name: err.name,
20531
+ cause: err.cause instanceof Error ? err.cause.message : void 0,
20532
+ display: "inline"
20533
+ };
20534
+ }
20535
+ if (typeof err === "object") {
20536
+ const o = err;
20537
+ const message = typeof o.message === "string" && o.message.trim() ? o.message.trim() : typeof o.error === "string" && o.error.trim() ? o.error.trim() : void 0;
20538
+ if (!message) return void 0;
20539
+ const cause = o.cause instanceof Error ? o.cause.message : typeof o.cause === "object" && o.cause !== null && typeof o.cause.message === "string" ? o.cause.message : void 0;
20540
+ return {
20541
+ code: inferCode(o),
20542
+ message,
20543
+ name: typeof o.name === "string" ? o.name : void 0,
20544
+ cause,
20545
+ display: "inline",
20546
+ details: o
20547
+ };
20548
+ }
20549
+ return void 0;
20550
+ }
20551
+ function subscriptionErrorFrom(err) {
20552
+ const normalized = normalizeError(err);
20553
+ if (normalized) {
20554
+ return { ...normalized, code: "subscription_error", display: "banner" };
20555
+ }
20556
+ const message = err instanceof Error ? err.message : "OpenCode event stream disconnected";
20557
+ return {
20558
+ code: "subscription_error",
20559
+ message: message.trim() || "OpenCode event stream disconnected",
20560
+ display: "banner"
20561
+ };
20562
+ }
20563
+ function stopReasonForTurn(error48, finish) {
20564
+ if (!error48) return "end_turn";
20565
+ if (finish === "refusal" || finish === "content_filter") return "refusal";
20566
+ if (/refus/i.test(error48.message) || error48.code === "auth_required") return "refusal";
20567
+ return "end_turn";
20568
+ }
20569
+ async function emitSessionError(connection, sessionId, payload, options) {
20570
+ const display = payload.display ?? "inline";
20571
+ const messageId = options?.messageId ?? payload.messageId ?? `err-${Date.now()}`;
20572
+ if (display !== "silent") {
20573
+ await sendToClient(connection, {
20574
+ sessionId,
20575
+ update: {
20576
+ sessionUpdate: "agent_message_chunk",
20577
+ messageId,
20578
+ content: { type: "text", text: payload.message },
20579
+ _meta: { error: payload }
20580
+ }
20581
+ }).catch(() => {
20582
+ });
20583
+ }
20584
+ if (display === "banner" || display === "inline") {
20585
+ await sendToClient(connection, {
20586
+ sessionId,
20587
+ update: {
20588
+ sessionUpdate: "session_info_update",
20589
+ _meta: { error: payload }
20590
+ }
20591
+ }).catch(() => {
20592
+ });
20593
+ }
20594
+ }
20595
+
20441
20596
  // src/event-handler.ts
20442
- var QUESTION_TIMEOUT_MS = 3e5;
20597
+ var SUBSCRIPTION_RETRY_MS = 3e3;
20598
+ var SUBSCRIPTION_ERROR_BROADCAST_MS = 3e4;
20443
20599
  var permissionOptions = [
20444
20600
  { optionId: "once", kind: "allow_once", name: "Allow once" },
20445
20601
  { optionId: "always", kind: "allow_always", name: "Always allow" },
@@ -20463,38 +20619,65 @@ var EventHandler = class {
20463
20619
  fileDiffStats = /* @__PURE__ */ new Map();
20464
20620
  /** AI code change stats per session, keyed by sessionId → filePath → { additions, deletions }. */
20465
20621
  aiCodeChangeStats = /* @__PURE__ */ new Map();
20622
+ lastSubscriptionErrorBroadcastAt = 0;
20623
+ turnTimeoutTracker;
20466
20624
  constructor(deps) {
20467
20625
  this.connection = deps.connection;
20468
20626
  this.sdk = deps.sdk;
20469
20627
  this.sessionManager = deps.sessionManager;
20628
+ this.turnTimeoutTracker = deps.turnTimeoutTracker;
20470
20629
  }
20471
20630
  start() {
20472
20631
  if (this.started) return;
20473
20632
  this.started = true;
20474
- this.runSubscription().catch((err) => {
20475
- if (this.abort.signal.aborted) return;
20476
- console.error("[event-handler] subscription failed:", err);
20477
- });
20633
+ this.turnTimeoutTracker?.start();
20634
+ void this.runSubscription();
20478
20635
  }
20479
20636
  stop() {
20480
20637
  this.abort.abort();
20638
+ this.turnTimeoutTracker?.stop();
20481
20639
  }
20482
20640
  async runSubscription() {
20483
20641
  while (true) {
20484
20642
  if (this.abort.signal.aborted) return;
20485
- const events = await this.sdk.global.event({
20486
- signal: this.abort.signal
20487
- });
20488
- for await (const event of events.stream) {
20489
- if (this.abort.signal.aborted) return;
20490
- const payload = event?.payload;
20491
- if (!payload) continue;
20492
- await this.handleEvent(payload).catch((err) => {
20493
- console.error("[event-handler] failed to handle event:", err);
20643
+ try {
20644
+ const events = await this.sdk.global.event({
20645
+ signal: this.abort.signal
20494
20646
  });
20647
+ for await (const event of events.stream) {
20648
+ if (this.abort.signal.aborted) return;
20649
+ const payload = event?.payload;
20650
+ if (!payload) continue;
20651
+ await this.handleEvent(payload).catch((err) => {
20652
+ console.error("[event-handler] failed to handle event:", err);
20653
+ });
20654
+ }
20655
+ } catch (err) {
20656
+ if (this.abort.signal.aborted) return;
20657
+ console.error("[event-handler] subscription failed:", err);
20658
+ await this.broadcastSubscriptionError(err);
20659
+ await new Promise((r) => setTimeout(r, SUBSCRIPTION_RETRY_MS));
20495
20660
  }
20496
20661
  }
20497
20662
  }
20663
+ /** Notify all top-level sessions that the OpenCode SSE stream dropped. */
20664
+ async broadcastSubscriptionError(err) {
20665
+ const now = Date.now();
20666
+ if (now - this.lastSubscriptionErrorBroadcastAt < SUBSCRIPTION_ERROR_BROADCAST_MS) {
20667
+ return;
20668
+ }
20669
+ this.lastSubscriptionErrorBroadcastAt = now;
20670
+ const payload = subscriptionErrorFrom(err);
20671
+ sysLog("subscription.error", { message: payload.message });
20672
+ const sessionIds = this.sessionManager.listTopLevelSessionIds();
20673
+ if (sessionIds.length === 0) {
20674
+ return;
20675
+ }
20676
+ for (const sessionId of sessionIds) {
20677
+ await emitSessionError(this.connection, sessionId, payload).catch(() => {
20678
+ });
20679
+ }
20680
+ }
20498
20681
  // ─── Resolve session for routing ────────────────────────────────
20499
20682
  /**
20500
20683
  * Resolve the sessionId to use for ACP routing.
@@ -20511,6 +20694,7 @@ var EventHandler = class {
20511
20694
  if (event.type === "server.heartbeat") {
20512
20695
  return;
20513
20696
  }
20697
+ this.turnTimeoutTracker?.noteEvent(event);
20514
20698
  switch (event.type) {
20515
20699
  case "session.created":
20516
20700
  case "session.updated": {
@@ -20627,17 +20811,12 @@ var EventHandler = class {
20627
20811
  const sessionId = resolved.sessionId;
20628
20812
  const prev = this.questionQueues.get(q.sessionID) ?? Promise.resolve();
20629
20813
  const next = prev.then(async () => {
20630
- const extResult = await Promise.race([
20631
- this.connection.extMethod("questionAsked", {
20632
- sessionId,
20633
- questionId: q.id,
20634
- questions: q.questions,
20635
- ...q.tool && { tool: q.tool }
20636
- }),
20637
- new Promise(
20638
- (_, reject) => setTimeout(() => reject(new Error("questionAsked timeout")), QUESTION_TIMEOUT_MS)
20639
- )
20640
- ]).catch((err) => {
20814
+ const extResult = await this.connection.extMethod("questionAsked", {
20815
+ sessionId,
20816
+ questionId: q.id,
20817
+ questions: q.questions,
20818
+ ...q.tool && { tool: q.tool }
20819
+ }).catch((err) => {
20641
20820
  console.error("[event-handler] extMethod questionAsked failed:", err?.message ?? err);
20642
20821
  return void 0;
20643
20822
  });
@@ -20781,6 +20960,7 @@ var EventHandler = class {
20781
20960
  async handleToolPart(sessionId, part) {
20782
20961
  if (!this.toolStarts.has(part.callID)) {
20783
20962
  this.toolStarts.add(part.callID);
20963
+ this.turnTimeoutTracker?.onToolCallStart(sessionId);
20784
20964
  const session = this.sessionManager.tryGet(sessionId);
20785
20965
  if (session?.parentID) {
20786
20966
  const tracker = this.childToolCounts.get(sessionId);
@@ -20843,15 +21023,18 @@ var EventHandler = class {
20843
21023
  return;
20844
21024
  }
20845
21025
  case "completed": {
20846
- this.toolStarts.delete(part.callID);
21026
+ if (this.toolStarts.delete(part.callID)) {
21027
+ this.turnTimeoutTracker?.onToolCallEnd(sessionId);
21028
+ }
20847
21029
  this.bashSnapshots.delete(part.callID);
20848
21030
  if (part.tool === "task" && part.state.metadata) {
20849
21031
  const childSessionId = typeof part.state.metadata["sessionId"] === "string" ? part.state.metadata["sessionId"] : void 0;
20850
21032
  if (childSessionId && this.sessionManager.tryGet(childSessionId)) {
20851
21033
  const tracker = this.childToolCounts.get(childSessionId);
20852
- await this.sendChildSessionCompleted(
21034
+ await this.sendChildSessionFinished(
20853
21035
  childSessionId,
20854
21036
  sessionId,
21037
+ "completed",
20855
21038
  tracker?.completed ?? 0,
20856
21039
  tracker ? Date.now() - tracker.startTime : 0
20857
21040
  ).catch(() => {
@@ -20921,8 +21104,32 @@ var EventHandler = class {
20921
21104
  return;
20922
21105
  }
20923
21106
  case "error": {
20924
- this.toolStarts.delete(part.callID);
21107
+ if (this.toolStarts.delete(part.callID)) {
21108
+ this.turnTimeoutTracker?.onToolCallEnd(sessionId);
21109
+ }
20925
21110
  this.bashSnapshots.delete(part.callID);
21111
+ if (part.tool === "task" && part.state.metadata) {
21112
+ const childSessionId = typeof part.state.metadata["sessionId"] === "string" ? part.state.metadata["sessionId"] : void 0;
21113
+ if (childSessionId && this.sessionManager.tryGet(childSessionId)) {
21114
+ const tracker = this.childToolCounts.get(childSessionId);
21115
+ const errText = typeof part.state.error === "string" && part.state.error.trim() ? part.state.error.trim() : "Sub-agent task failed";
21116
+ const payload = {
21117
+ code: "session_error",
21118
+ message: errText,
21119
+ display: "inline"
21120
+ };
21121
+ await this.sendChildSessionFinished(
21122
+ childSessionId,
21123
+ sessionId,
21124
+ "failed",
21125
+ tracker?.completed ?? 0,
21126
+ tracker ? Date.now() - tracker.startTime : 0,
21127
+ payload
21128
+ ).catch(() => {
21129
+ });
21130
+ this.childToolCounts.delete(childSessionId);
21131
+ }
21132
+ }
20926
21133
  await sendToClient(this.connection, {
20927
21134
  sessionId,
20928
21135
  update: {
@@ -20950,9 +21157,9 @@ var EventHandler = class {
20950
21157
  }
20951
21158
  }
20952
21159
  /**
20953
- * Notify the ACP client that a child session has completed.
21160
+ * Notify the ACP client that a child session finished (success or failure).
20954
21161
  */
20955
- async sendChildSessionCompleted(childSessionId, parentSessionId, toolCallCount, durationMs) {
21162
+ async sendChildSessionFinished(childSessionId, parentSessionId, status, toolCallCount, durationMs, error48) {
20956
21163
  const child = this.sessionManager.tryGet(childSessionId);
20957
21164
  const title = child?.title ?? "Subagent";
20958
21165
  const agentMatch = title.match(/@(\w+)\s+subagent/);
@@ -20963,14 +21170,15 @@ var EventHandler = class {
20963
21170
  update: {
20964
21171
  sessionUpdate: "session_info_update",
20965
21172
  title,
20966
- status: "completed",
21173
+ status,
20967
21174
  _meta: {
20968
21175
  parentSessionId,
20969
21176
  isSubagent: true,
20970
21177
  toolCallCount,
20971
21178
  durationMs,
20972
21179
  ...agentType && { agentType },
20973
- ...description && { description }
21180
+ ...description && { description },
21181
+ ...error48 && { error: error48 }
20974
21182
  }
20975
21183
  }
20976
21184
  });
@@ -21111,6 +21319,140 @@ var AuthProviderManager = class {
21111
21319
  }
21112
21320
  };
21113
21321
 
21322
+ // src/turn-timeout-tracker.ts
21323
+ var TURN_IDLE_LOG_MS = 9e4;
21324
+ var TURN_IDLE_LOG_MS_WITH_TOOL = 10 * 6e4;
21325
+ var TURN_IDLE_CHECK_INTERVAL_MS = 3e4;
21326
+ function parsePositiveIntEnv(name, fallback) {
21327
+ const raw = process.env[name];
21328
+ if (!raw) return fallback;
21329
+ const n = Number.parseInt(raw, 10);
21330
+ return Number.isFinite(n) && n > 0 ? n : fallback;
21331
+ }
21332
+ function turnIdleLogThresholdMs(hasActiveTool) {
21333
+ const base = parsePositiveIntEnv("HARMONY_ACP_TURN_IDLE_LOG_MS", TURN_IDLE_LOG_MS);
21334
+ const withTool = parsePositiveIntEnv("HARMONY_ACP_TURN_IDLE_LOG_MS_WITH_TOOL", TURN_IDLE_LOG_MS_WITH_TOOL);
21335
+ return hasActiveTool ? withTool : base;
21336
+ }
21337
+ function sessionIdFromEvent(event) {
21338
+ const props = event.properties;
21339
+ if (!props) return void 0;
21340
+ switch (event.type) {
21341
+ case "message.part.updated": {
21342
+ const part = props.part;
21343
+ return typeof part?.sessionID === "string" ? part.sessionID : void 0;
21344
+ }
21345
+ case "message.part.delta":
21346
+ return typeof props.sessionID === "string" ? props.sessionID : void 0;
21347
+ case "session.diff":
21348
+ case "session.updated":
21349
+ case "session.created":
21350
+ return typeof props.sessionID === "string" ? props.sessionID : typeof props.info?.id === "string" ? props.info.id : void 0;
21351
+ case "permission.asked": {
21352
+ const permission = props;
21353
+ return typeof permission.sessionID === "string" ? permission.sessionID : void 0;
21354
+ }
21355
+ }
21356
+ return void 0;
21357
+ }
21358
+ var TurnTimeoutTracker = class {
21359
+ constructor(sessionManager) {
21360
+ this.sessionManager = sessionManager;
21361
+ }
21362
+ turns = /* @__PURE__ */ new Map();
21363
+ timer;
21364
+ start() {
21365
+ if (this.timer) return;
21366
+ const interval = parsePositiveIntEnv(
21367
+ "HARMONY_ACP_TURN_IDLE_CHECK_MS",
21368
+ TURN_IDLE_CHECK_INTERVAL_MS
21369
+ );
21370
+ this.timer = setInterval(() => this.checkIdleTurns(), interval);
21371
+ }
21372
+ stop() {
21373
+ if (this.timer) {
21374
+ clearInterval(this.timer);
21375
+ this.timer = void 0;
21376
+ }
21377
+ }
21378
+ rootSessionId(sessionId) {
21379
+ return this.sessionManager.findRootSession(sessionId)?.id ?? sessionId;
21380
+ }
21381
+ onPromptStart(sessionId, meta3) {
21382
+ const root = this.rootSessionId(sessionId);
21383
+ const now = Date.now();
21384
+ this.turns.set(root, {
21385
+ rootSessionId: root,
21386
+ promptStartedAt: now,
21387
+ lastActivityAt: now,
21388
+ activeToolCalls: 0,
21389
+ idleLogged: false,
21390
+ model: meta3?.model
21391
+ });
21392
+ sysLog("turn.prompt_start", { sessionId: root, model: meta3?.model });
21393
+ }
21394
+ onPromptEnd(sessionId) {
21395
+ const root = this.rootSessionId(sessionId);
21396
+ const state = this.turns.get(root);
21397
+ if (state) {
21398
+ sysLog("turn.prompt_end", {
21399
+ sessionId: root,
21400
+ durationMs: Date.now() - state.promptStartedAt
21401
+ });
21402
+ }
21403
+ this.turns.delete(root);
21404
+ }
21405
+ /** SSE / OpenCode activity for a session (child events bump the root turn). */
21406
+ noteActivity(sessionId) {
21407
+ const root = this.rootSessionId(sessionId);
21408
+ const state = this.turns.get(root);
21409
+ if (!state) return;
21410
+ state.lastActivityAt = Date.now();
21411
+ state.idleLogged = false;
21412
+ }
21413
+ onToolCallStart(sessionId) {
21414
+ const root = this.rootSessionId(sessionId);
21415
+ const state = this.turns.get(root);
21416
+ if (!state) return;
21417
+ state.activeToolCalls++;
21418
+ state.lastActivityAt = Date.now();
21419
+ state.idleLogged = false;
21420
+ }
21421
+ onToolCallEnd(sessionId) {
21422
+ const root = this.rootSessionId(sessionId);
21423
+ const state = this.turns.get(root);
21424
+ if (!state) return;
21425
+ state.activeToolCalls = Math.max(0, state.activeToolCalls - 1);
21426
+ state.lastActivityAt = Date.now();
21427
+ state.idleLogged = false;
21428
+ }
21429
+ noteEvent(event) {
21430
+ const sid = sessionIdFromEvent(event);
21431
+ if (sid) {
21432
+ this.noteActivity(sid);
21433
+ }
21434
+ }
21435
+ checkIdleTurns() {
21436
+ const now = Date.now();
21437
+ for (const state of this.turns.values()) {
21438
+ const idleMs = now - state.lastActivityAt;
21439
+ const thresholdMs = turnIdleLogThresholdMs(state.activeToolCalls > 0);
21440
+ if (idleMs < thresholdMs || state.idleLogged) {
21441
+ continue;
21442
+ }
21443
+ state.idleLogged = true;
21444
+ sysLog("turn.idle_timeout", {
21445
+ sessionId: state.rootSessionId,
21446
+ idleMs,
21447
+ thresholdMs,
21448
+ promptAgeMs: now - state.promptStartedAt,
21449
+ activeToolCalls: state.activeToolCalls,
21450
+ model: state.model
21451
+ });
21452
+ }
21453
+ }
21454
+ };
21455
+
21114
21456
  // src/agent.ts
21115
21457
  function isApiKeyError(err) {
21116
21458
  return err instanceof Error && err.name === "AI_LoadAPIKeyError";
@@ -21123,19 +21465,22 @@ var Agent = class {
21123
21465
  eventHandler;
21124
21466
  authProvider;
21125
21467
  mcpManager;
21468
+ turnTimeoutTracker;
21126
21469
  constructor(config2) {
21127
21470
  this.config = config2;
21128
21471
  this.sdk = config2.sdk;
21129
21472
  this.sessionManager = new SessionManager(config2.sdk);
21130
21473
  this.authProvider = new AuthProviderManager(config2.sdk);
21131
21474
  this.mcpManager = new McpManager(config2.sdk);
21475
+ this.turnTimeoutTracker = new TurnTimeoutTracker(this.sessionManager);
21132
21476
  }
21133
21477
  init(connection) {
21134
21478
  this.connection = connection;
21135
21479
  this.eventHandler = new EventHandler({
21136
21480
  connection,
21137
21481
  sdk: this.sdk,
21138
- sessionManager: this.sessionManager
21482
+ sessionManager: this.sessionManager,
21483
+ turnTimeoutTracker: this.turnTimeoutTracker
21139
21484
  });
21140
21485
  this.eventHandler.start();
21141
21486
  }
@@ -21478,102 +21823,114 @@ var Agent = class {
21478
21823
  const agent = session.modeId ?? await this.defaultAgent(directory);
21479
21824
  const parts = this.convertPromptParts(params.prompt);
21480
21825
  const cmd = this.parseCommand(parts);
21481
- ocCall("session.prompt", { sessionID, agent, model: `${model.providerID}/${model.modelID}` });
21482
- if (!cmd) {
21483
- const response2 = await this.sdk.session.prompt({
21826
+ const modelLabel = `${model.providerID}/${model.modelID}`;
21827
+ ocCall("session.prompt", { sessionID, agent, model: modelLabel });
21828
+ this.turnTimeoutTracker.onPromptStart(sessionID, { model: modelLabel });
21829
+ try {
21830
+ if (!cmd) {
21831
+ const response2 = await this.sdk.session.prompt({
21832
+ sessionID,
21833
+ model: {
21834
+ providerID: model.providerID,
21835
+ modelID: model.modelID
21836
+ },
21837
+ variant: this.sessionManager.getVariant(sessionID),
21838
+ parts,
21839
+ agent,
21840
+ directory
21841
+ });
21842
+ const rawErr2 = response2.error;
21843
+ ocCall("session.prompt.raw", {
21844
+ hasData: !!response2.data,
21845
+ hasError: !!rawErr2,
21846
+ errorName: rawErr2?.name,
21847
+ errorMessage: rawErr2?.message,
21848
+ errorCode: rawErr2?.code ?? rawErr2?.cause?.code,
21849
+ errorCauseName: rawErr2?.cause?.name,
21850
+ errorCauseMessage: rawErr2?.cause?.message
21851
+ });
21852
+ const msg2 = response2.data?.info;
21853
+ return await this.finishPromptTurn(sessionID, directory, { rawErr: rawErr2, msg: msg2 });
21854
+ }
21855
+ const command = await this.sdk.command.list({ directory }).then((x) => x.data?.find((c) => c.name === cmd.name));
21856
+ if (command) {
21857
+ const response2 = await this.sdk.session.command({
21858
+ sessionID,
21859
+ command: command.name,
21860
+ arguments: cmd.args,
21861
+ model: modelLabel,
21862
+ agent,
21863
+ directory
21864
+ });
21865
+ const rawErr2 = response2.error;
21866
+ const msg2 = response2.data?.info;
21867
+ return await this.finishPromptTurn(sessionID, directory, { rawErr: rawErr2, msg: msg2 });
21868
+ }
21869
+ if (cmd.name === "compact") {
21870
+ await this.sdk.session.summarize(
21871
+ {
21872
+ sessionID,
21873
+ directory,
21874
+ providerID: model.providerID,
21875
+ modelID: model.modelID
21876
+ },
21877
+ { throwOnError: true }
21878
+ ).catch(() => {
21879
+ });
21880
+ await this.sendUsageUpdate(sessionID, directory);
21881
+ return {
21882
+ stopReason: "end_turn",
21883
+ _meta: {}
21884
+ };
21885
+ }
21886
+ const response = await this.sdk.session.prompt({
21484
21887
  sessionID,
21485
- model: {
21486
- providerID: model.providerID,
21487
- modelID: model.modelID
21488
- },
21888
+ model: { providerID: model.providerID, modelID: model.modelID },
21489
21889
  variant: this.sessionManager.getVariant(sessionID),
21490
21890
  parts,
21491
21891
  agent,
21492
21892
  directory
21493
21893
  });
21494
- const rawErr = response2.error;
21495
- ocCall("session.prompt.raw", {
21496
- hasData: !!response2.data,
21497
- hasError: !!rawErr,
21498
- errorName: rawErr?.name,
21499
- errorMessage: rawErr?.message,
21500
- errorCode: rawErr?.code ?? rawErr?.cause?.code,
21501
- errorCauseName: rawErr?.cause?.name,
21502
- errorCauseMessage: rawErr?.cause?.message
21503
- });
21504
- const msg2 = response2.data?.info;
21505
- ocCall("session.prompt.response", {
21506
- messageId: msg2?.id,
21507
- role: msg2?.role,
21508
- model: msg2 ? `${msg2.providerID}/${msg2.modelID}` : void 0,
21509
- tokens: msg2?.tokens,
21510
- cost: msg2?.cost,
21511
- finish: msg2?.finish,
21512
- error: msg2?.error
21513
- });
21514
- await this.logMessageContent(sessionID, msg2?.id, directory);
21515
- await this.sendUsageUpdate(sessionID, directory);
21516
- acpOut("prompt.response", {
21517
- stopReason: "end_turn",
21518
- usage: msg2 ? this.buildUsage(msg2) : void 0
21519
- });
21520
- return {
21521
- stopReason: "end_turn",
21522
- usage: msg2 ? this.buildUsage(msg2) : void 0,
21523
- _meta: {}
21524
- };
21525
- }
21526
- const command = await this.sdk.command.list({ directory }).then((x) => x.data?.find((c) => c.name === cmd.name));
21527
- if (command) {
21528
- const response2 = await this.sdk.session.command({
21529
- sessionID,
21530
- command: command.name,
21531
- arguments: cmd.args,
21532
- model: model.providerID + "/" + model.modelID,
21533
- agent,
21534
- directory
21535
- });
21536
- const msg2 = response2.data?.info;
21537
- await this.logMessageContent(sessionID, msg2?.id, directory);
21538
- await this.sendUsageUpdate(sessionID, directory);
21539
- return {
21540
- stopReason: "end_turn",
21541
- usage: msg2 ? this.buildUsage(msg2) : void 0,
21542
- _meta: {}
21543
- };
21544
- }
21545
- if (cmd.name === "compact") {
21546
- await this.sdk.session.summarize(
21547
- {
21548
- sessionID,
21549
- directory,
21550
- providerID: model.providerID,
21551
- modelID: model.modelID
21552
- },
21553
- { throwOnError: true }
21554
- ).catch(() => {
21555
- });
21556
- await this.sendUsageUpdate(sessionID, directory);
21557
- return {
21558
- stopReason: "end_turn",
21559
- _meta: {}
21560
- };
21894
+ const rawErr = response.error;
21895
+ const msg = response.data?.info;
21896
+ return await this.finishPromptTurn(sessionID, directory, { rawErr, msg });
21897
+ } finally {
21898
+ this.turnTimeoutTracker.onPromptEnd(sessionID);
21561
21899
  }
21562
- const response = await this.sdk.session.prompt({
21563
- sessionID,
21564
- model: { providerID: model.providerID, modelID: model.modelID },
21565
- variant: this.sessionManager.getVariant(sessionID),
21566
- parts,
21567
- agent,
21568
- directory
21900
+ }
21901
+ /**
21902
+ * Complete a prompt turn: emit streaming errors, usage, and PromptResponse._meta.error.
21903
+ */
21904
+ async finishPromptTurn(sessionID, directory, opts) {
21905
+ const turnError = extractTurnError({
21906
+ responseError: opts.rawErr,
21907
+ messageError: opts.msg?.error
21908
+ });
21909
+ ocCall("session.prompt.response", {
21910
+ messageId: opts.msg?.id,
21911
+ role: opts.msg?.role,
21912
+ model: opts.msg ? `${opts.msg.providerID}/${opts.msg.modelID}` : void 0,
21913
+ tokens: opts.msg?.tokens,
21914
+ cost: opts.msg?.cost,
21915
+ finish: opts.msg?.finish,
21916
+ error: turnError?.message
21917
+ });
21918
+ if (turnError) {
21919
+ await emitSessionError(this.connection, sessionID, turnError, { messageId: opts.msg?.id });
21920
+ }
21921
+ await this.logMessageContent(sessionID, opts.msg?.id, directory);
21922
+ await this.sendUsageUpdate(sessionID, directory, turnError);
21923
+ const stopReason = stopReasonForTurn(turnError, opts.msg?.finish);
21924
+ const usage = opts.msg ? this.buildUsage(opts.msg) : void 0;
21925
+ acpOut("prompt.response", {
21926
+ stopReason,
21927
+ usage,
21928
+ error: turnError?.message
21569
21929
  });
21570
- const msg = response.data?.info;
21571
- await this.logMessageContent(sessionID, msg?.id, directory);
21572
- await this.sendUsageUpdate(sessionID, directory);
21573
21930
  return {
21574
- stopReason: "end_turn",
21575
- usage: msg ? this.buildUsage(msg) : void 0,
21576
- _meta: {}
21931
+ stopReason,
21932
+ usage,
21933
+ _meta: turnError ? { error: turnError } : {}
21577
21934
  };
21578
21935
  }
21579
21936
  async cancel(params) {
@@ -21584,6 +21941,7 @@ var Agent = class {
21584
21941
  sessionID: params.sessionId,
21585
21942
  directory: session.cwd
21586
21943
  });
21944
+ this.turnTimeoutTracker.onPromptEnd(params.sessionId);
21587
21945
  }
21588
21946
  // ─── Configuration ────────────────────────────────────────────────
21589
21947
  async setSessionMode(params) {
@@ -22028,7 +22386,7 @@ var Agent = class {
22028
22386
  break;
22029
22387
  }
22030
22388
  }
22031
- async sendUsageUpdate(sessionId, directory) {
22389
+ async sendUsageUpdate(sessionId, directory, turnError) {
22032
22390
  const messages = await this.sdk.session.messages({ sessionID: sessionId, directory }).then((x) => x.data).catch(() => void 0);
22033
22391
  if (!messages) return;
22034
22392
  const assistantMessages = messages.filter(
@@ -22059,6 +22417,9 @@ var Agent = class {
22059
22417
  }
22060
22418
  this.aggregateFileDiffStats(sessionId, children, _meta);
22061
22419
  this.aggregateAICodeChangeStats(sessionId, children, _meta);
22420
+ if (turnError) {
22421
+ _meta.error = turnError;
22422
+ }
22062
22423
  await sendToClient(this.connection, {
22063
22424
  sessionId,
22064
22425
  update: {
@@ -22202,13 +22563,15 @@ var Agent = class {
22202
22563
  }
22203
22564
  }
22204
22565
  buildUsage(msg) {
22566
+ const tokens = msg.tokens;
22567
+ if (!tokens) return void 0;
22205
22568
  return {
22206
- totalTokens: msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + (msg.tokens.cache?.read ?? 0) + (msg.tokens.cache?.write ?? 0),
22207
- inputTokens: msg.tokens.input,
22208
- outputTokens: msg.tokens.output,
22209
- thoughtTokens: msg.tokens.reasoning || void 0,
22210
- cachedReadTokens: msg.tokens.cache?.read || void 0,
22211
- cachedWriteTokens: msg.tokens.cache?.write || void 0
22569
+ totalTokens: tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0),
22570
+ inputTokens: tokens.input,
22571
+ outputTokens: tokens.output,
22572
+ thoughtTokens: tokens.reasoning || void 0,
22573
+ cachedReadTokens: tokens.cache?.read || void 0,
22574
+ cachedWriteTokens: tokens.cache?.write || void 0
22212
22575
  };
22213
22576
  }
22214
22577
  };