@hua-labs/tap 0.2.6 → 0.3.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.
@@ -9,7 +9,9 @@ import {
9
9
  mkdirSync,
10
10
  readdirSync,
11
11
  readFileSync,
12
+ renameSync,
12
13
  statSync,
14
+ unlinkSync,
13
15
  writeFileSync
14
16
  } from "fs";
15
17
  import { isAbsolute, join, resolve } from "path";
@@ -41,7 +43,9 @@ function threadCwdMatches(expectedCwd, actualCwd) {
41
43
  return normalizeThreadCwd(expectedCwd) === normalizeThreadCwd(actualCwd);
42
44
  }
43
45
  function chooseLoadedThreadForCwd(cwd, threads) {
44
- const matching = threads.filter((thread) => threadCwdMatches(cwd, thread.cwd));
46
+ const matching = threads.filter(
47
+ (thread) => threadCwdMatches(cwd, thread.cwd)
48
+ );
45
49
  if (matching.length === 0) {
46
50
  return null;
47
51
  }
@@ -340,6 +344,10 @@ function persistAgentName(stateDir, agentName) {
340
344
  writeFileSync(join(stateDir, "agent-name.txt"), `${agentName}
341
345
  `, "utf8");
342
346
  }
347
+ function sanitizeErrorForPersistence(error) {
348
+ if (!error) return null;
349
+ return error.replace(/([?&])tap_token=[^\s&)"'}]+/gi, "$1tap_token=***").replace(/"tap_token"\s*:\s*"[^"]*"/g, '"tap_token":"***"').replace(/tap-auth-[A-Za-z0-9_-]+/g, "tap-auth-***").replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***");
350
+ }
343
351
  function readGatewayTokenFile(tokenFile) {
344
352
  const token = readFileSync(tokenFile, "utf8").trim();
345
353
  if (!token) {
@@ -368,6 +376,69 @@ function readThreadState(stateDir) {
368
376
  }
369
377
  return null;
370
378
  }
379
+ function readHeartbeatState(stateDir) {
380
+ const heartbeatPath = join(stateDir, "heartbeat.json");
381
+ if (!existsSync(heartbeatPath)) {
382
+ return null;
383
+ }
384
+ try {
385
+ return JSON.parse(readFileSync(heartbeatPath, "utf8"));
386
+ } catch {
387
+ return null;
388
+ }
389
+ }
390
+ function parseUpdatedAt(value) {
391
+ if (!value) {
392
+ return 0;
393
+ }
394
+ const parsed = Date.parse(value);
395
+ return Number.isFinite(parsed) ? parsed : 0;
396
+ }
397
+ function appServerUrlMatches(expectedAppServerUrl, actualAppServerUrl) {
398
+ return actualAppServerUrl?.trim() === expectedAppServerUrl;
399
+ }
400
+ function hasValidHeartbeatThreadCwd(threadCwd) {
401
+ const normalized = threadCwd?.trim();
402
+ if (!normalized) {
403
+ return false;
404
+ }
405
+ return isAbsolute(normalized) || /^[A-Za-z]:[\\/]/.test(normalized) || normalized.startsWith("\\\\");
406
+ }
407
+ function loadResumableThreadState(stateDir, fallbackAppServerUrl) {
408
+ const savedThread = readThreadState(stateDir);
409
+ const heartbeat = readHeartbeatState(stateDir);
410
+ const heartbeatThreadId = heartbeat?.threadId?.trim();
411
+ if (!heartbeatThreadId) {
412
+ return savedThread;
413
+ }
414
+ if (!appServerUrlMatches(fallbackAppServerUrl, heartbeat?.appServerUrl)) {
415
+ return savedThread;
416
+ }
417
+ if (!hasValidHeartbeatThreadCwd(heartbeat?.threadCwd)) {
418
+ return savedThread;
419
+ }
420
+ const heartbeatBackedThread = {
421
+ threadId: heartbeatThreadId,
422
+ updatedAt: heartbeat?.updatedAt ?? savedThread?.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
423
+ appServerUrl: heartbeat?.appServerUrl || savedThread?.appServerUrl || fallbackAppServerUrl,
424
+ ephemeral: savedThread?.ephemeral ?? false,
425
+ cwd: heartbeat?.threadCwd ?? (savedThread?.threadId === heartbeatThreadId ? savedThread.cwd ?? null : null)
426
+ };
427
+ let preferred = savedThread;
428
+ if (!savedThread?.threadId) {
429
+ preferred = heartbeatBackedThread;
430
+ } else if (savedThread.threadId === heartbeatThreadId) {
431
+ preferred = {
432
+ ...savedThread,
433
+ updatedAt: heartbeatBackedThread.updatedAt ?? savedThread.updatedAt,
434
+ appServerUrl: heartbeatBackedThread.appServerUrl,
435
+ cwd: heartbeatBackedThread.cwd ?? savedThread.cwd ?? null
436
+ };
437
+ } else if (parseUpdatedAt(heartbeat?.updatedAt) > parseUpdatedAt(savedThread.updatedAt)) {
438
+ preferred = heartbeatBackedThread;
439
+ }
440
+ return preferred;
441
+ }
371
442
  function persistThreadState(stateDir, threadId, appServerUrl, ephemeral, cwd) {
372
443
  const payload = {
373
444
  threadId,
@@ -741,6 +812,7 @@ var AppServerClient = class {
741
812
  threadId = null;
742
813
  currentThreadCwd = null;
743
814
  activeTurnId = null;
815
+ turnStartedAt = null;
744
816
  lastTurnStatus = null;
745
817
  lastNotificationMethod = null;
746
818
  lastNotificationAt = null;
@@ -781,6 +853,7 @@ var AppServerClient = class {
781
853
  "open",
782
854
  () => {
783
855
  this.connected = true;
856
+ this.logger(`connected to app-server at ${this.url}`);
784
857
  resolveOnce();
785
858
  },
786
859
  { once: true }
@@ -796,6 +869,8 @@ var AppServerClient = class {
796
869
  this.connected = false;
797
870
  this.initialized = false;
798
871
  this.activeTurnId = null;
872
+ this.turnStartedAt = null;
873
+ this.logger("disconnected from app-server");
799
874
  this.rejectPending(new Error("App Server connection closed"));
800
875
  });
801
876
  this.socket?.addEventListener("message", (event) => {
@@ -866,6 +941,7 @@ var AppServerClient = class {
866
941
  this.threadId = null;
867
942
  this.currentThreadCwd = null;
868
943
  this.activeTurnId = null;
944
+ this.turnStartedAt = null;
869
945
  this.lastTurnStatus = null;
870
946
  } else {
871
947
  this.logger(
@@ -958,6 +1034,7 @@ var AppServerClient = class {
958
1034
  const turnId = response?.turn?.id ?? null;
959
1035
  if (turnId) {
960
1036
  this.activeTurnId = turnId;
1037
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
961
1038
  }
962
1039
  return turnId;
963
1040
  }
@@ -1021,6 +1098,11 @@ var AppServerClient = class {
1021
1098
  activeTurnId = turn.id;
1022
1099
  }
1023
1100
  }
1101
+ if (activeTurnId && activeTurnId !== this.activeTurnId) {
1102
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1103
+ } else if (!activeTurnId) {
1104
+ this.turnStartedAt = null;
1105
+ }
1024
1106
  this.activeTurnId = activeTurnId;
1025
1107
  this.lastTurnStatus = lastTurnStatus;
1026
1108
  }
@@ -1071,14 +1153,22 @@ var AppServerClient = class {
1071
1153
  case "turn/started":
1072
1154
  if (params?.turn?.id) {
1073
1155
  this.activeTurnId = params.turn.id;
1156
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1074
1157
  this.logger(`turn started ${params.turn.id}`);
1075
1158
  }
1076
1159
  break;
1077
- case "turn/completed":
1160
+ case "turn/completed": {
1078
1161
  this.lastTurnStatus = params?.turn?.status ?? null;
1162
+ const prevTurnStartedAt = this.turnStartedAt;
1079
1163
  this.activeTurnId = null;
1080
- this.logger(`turn completed (${this.lastTurnStatus ?? "unknown"})`);
1164
+ this.turnStartedAt = null;
1165
+ const elapsedMs = prevTurnStartedAt ? Date.now() - new Date(prevTurnStartedAt).getTime() : null;
1166
+ const elapsedSuffix = elapsedMs !== null ? ` \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed` : "";
1167
+ this.logger(
1168
+ `turn completed (${this.lastTurnStatus ?? "unknown"})${elapsedSuffix}`
1169
+ );
1081
1170
  break;
1171
+ }
1082
1172
  case "error":
1083
1173
  this.lastError = JSON.stringify(params ?? {}, null, 2);
1084
1174
  this.logger(`app-server error notification: ${this.lastError}`);
@@ -1115,6 +1205,7 @@ var AppServerClient = class {
1115
1205
  this.pending.clear();
1116
1206
  }
1117
1207
  };
1208
+ var heartbeatCount = 0;
1118
1209
  function writeHeartbeat(options, client, health) {
1119
1210
  if (client?.threadId) {
1120
1211
  const savedThread = readThreadState(options.stateDir);
@@ -1137,10 +1228,11 @@ function writeHeartbeat(options, client, health) {
1137
1228
  threadId: client?.threadId ?? null,
1138
1229
  threadCwd: client?.currentThreadCwd ?? null,
1139
1230
  activeTurnId: client?.activeTurnId ?? null,
1231
+ turnStartedAt: client?.turnStartedAt ?? null,
1140
1232
  lastTurnStatus: client?.lastTurnStatus ?? null,
1141
1233
  lastNotificationMethod: client?.lastNotificationMethod ?? null,
1142
1234
  lastNotificationAt: client?.lastNotificationAt ?? null,
1143
- lastError: client?.lastError ?? null,
1235
+ lastError: sanitizeErrorForPersistence(client?.lastError ?? null),
1144
1236
  lastSuccessfulAppServerAt: client?.lastSuccessfulAppServerAt ?? null,
1145
1237
  lastSuccessfulAppServerMethod: client?.lastSuccessfulAppServerMethod ?? null,
1146
1238
  consecutiveFailureCount: health.consecutiveFailureCount,
@@ -1152,9 +1244,84 @@ function writeHeartbeat(options, client, health) {
1152
1244
  `,
1153
1245
  "utf8"
1154
1246
  );
1247
+ heartbeatCount += 1;
1248
+ if (heartbeatCount % 5 === 0) {
1249
+ logStatus(
1250
+ `heartbeat: connected=${payload.connected}, thread=${payload.threadId ?? "null"}, turns=${payload.activeTurnId ? "active" : "0"}`
1251
+ );
1252
+ }
1253
+ const status = client?.connected ? "active" : "idle";
1254
+ updateCommsHeartbeat(options, status);
1255
+ }
1256
+ var COMMS_HEARTBEAT_LOCK_TIMEOUT_MS = 2e3;
1257
+ var COMMS_LOCK_STALE_AGE_MS = 1e4;
1258
+ function acquireCommsLock(lockPath) {
1259
+ const deadline = Date.now() + COMMS_HEARTBEAT_LOCK_TIMEOUT_MS;
1260
+ while (Date.now() < deadline) {
1261
+ try {
1262
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
1263
+ return true;
1264
+ } catch {
1265
+ try {
1266
+ const lockAge = Date.now() - statSync(lockPath).mtimeMs;
1267
+ if (lockAge > COMMS_LOCK_STALE_AGE_MS) {
1268
+ unlinkSync(lockPath);
1269
+ try {
1270
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
1271
+ return true;
1272
+ } catch {
1273
+ }
1274
+ }
1275
+ } catch {
1276
+ }
1277
+ const start = Date.now();
1278
+ while (Date.now() - start < 50) {
1279
+ }
1280
+ }
1281
+ }
1282
+ return false;
1283
+ }
1284
+ function releaseCommsLock(lockPath) {
1285
+ try {
1286
+ unlinkSync(lockPath);
1287
+ } catch {
1288
+ }
1289
+ }
1290
+ function updateCommsHeartbeat(options, status) {
1291
+ const heartbeatsPath = join(options.commsDir, "heartbeats.json");
1292
+ const lockPath = join(options.commsDir, ".heartbeats.lock");
1293
+ if (!acquireCommsLock(lockPath)) {
1294
+ return;
1295
+ }
1296
+ try {
1297
+ let store = {};
1298
+ try {
1299
+ store = JSON.parse(readFileSync(heartbeatsPath, "utf-8"));
1300
+ } catch {
1301
+ }
1302
+ const key = options.agentId;
1303
+ const existing = store[key];
1304
+ store[key] = {
1305
+ id: options.agentId,
1306
+ agent: options.agentName,
1307
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1308
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1309
+ joinedAt: existing?.joinedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1310
+ status
1311
+ };
1312
+ const tmpPath = heartbeatsPath + ".tmp." + process.pid;
1313
+ writeFileSync(tmpPath, JSON.stringify(store, null, 2), "utf-8");
1314
+ renameSync(tmpPath, heartbeatsPath);
1315
+ } catch {
1316
+ } finally {
1317
+ releaseCommsLock(lockPath);
1318
+ }
1155
1319
  }
1156
1320
  async function dispatchCandidate(client, options, candidate, heartbeats) {
1157
1321
  const input = buildUserInput(candidate, options.agentName, heartbeats);
1322
+ logStatus(
1323
+ `dispatching from ${candidate.sender || "unknown"}: ${candidate.subject || "(none)"}`
1324
+ );
1158
1325
  if (client.isBusy()) {
1159
1326
  if (options.busyMode !== "steer") {
1160
1327
  return false;
@@ -1184,6 +1351,7 @@ async function dispatchCandidate(client, options, candidate, heartbeats) {
1184
1351
  }
1185
1352
  if (shouldRetrySteerAsStart(error)) {
1186
1353
  client.activeTurnId = null;
1354
+ client.turnStartedAt = null;
1187
1355
  logStatus(
1188
1356
  `steer fallback -> start for ${candidate.fileName} (${String(error)})`
1189
1357
  );
@@ -1287,7 +1455,10 @@ async function main() {
1287
1455
  options.messageLookbackMinutes,
1288
1456
  options.processExistingMessages
1289
1457
  );
1290
- const initialSavedThread = readThreadState(options.stateDir);
1458
+ const initialSavedThread = loadResumableThreadState(
1459
+ options.stateDir,
1460
+ options.appServerUrl
1461
+ );
1291
1462
  logStatus("codex app-server bridge ready");
1292
1463
  console.log(` repo: ${options.repoRoot}`);
1293
1464
  console.log(` comms: ${options.commsDir}`);
@@ -1325,7 +1496,10 @@ async function main() {
1325
1496
  options.gatewayToken
1326
1497
  );
1327
1498
  await client.connect();
1328
- const savedThread = readThreadState(options.stateDir);
1499
+ const savedThread = loadResumableThreadState(
1500
+ options.stateDir,
1501
+ options.appServerUrl
1502
+ );
1329
1503
  const threadId = await client.ensureThread(
1330
1504
  options.threadId,
1331
1505
  savedThread,
@@ -1373,6 +1547,7 @@ async function main() {
1373
1547
  }
1374
1548
  client?.disconnect().catch(() => void 0);
1375
1549
  client = null;
1550
+ logStatus(`reconnecting in ${options.reconnectSeconds}s...`);
1376
1551
  await delay(options.reconnectSeconds * 1e3);
1377
1552
  }
1378
1553
  }
@@ -1412,6 +1587,7 @@ export {
1412
1587
  buildUserInput,
1413
1588
  chooseLoadedThreadForCwd,
1414
1589
  isOwnMessageSender,
1590
+ loadResumableThreadState,
1415
1591
  main,
1416
1592
  maybeBootstrapHeadlessTurn,
1417
1593
  recipientMatchesAgent,