@hua-labs/tap 0.2.6 → 0.3.1

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
  }
@@ -303,7 +307,7 @@ function normalizeAgentToken(value) {
303
307
  if (!normalized || PLACEHOLDER_AGENT_VALUES.has(normalized)) {
304
308
  return null;
305
309
  }
306
- return normalized;
310
+ return normalized.replace(/-/g, "_");
307
311
  }
308
312
  function resolveAgentId(preferredAgentName) {
309
313
  return normalizeAgentToken(process.env.TAP_AGENT_ID) ?? normalizeAgentToken(preferredAgentName) ?? "unknown";
@@ -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,
@@ -408,14 +479,18 @@ function recipientMatchesAgent(recipient, agentId, agentName) {
408
479
  if (!normalizedRecipient) {
409
480
  return false;
410
481
  }
411
- return normalizedRecipient === "\uC804\uCCB4" || normalizedRecipient === "all" || normalizedRecipient === agentId || normalizedRecipient === agentName;
482
+ const canonicalRecipient = normalizedRecipient.replace(/-/g, "_");
483
+ const canonicalAgentId = agentId.trim().replace(/-/g, "_");
484
+ return normalizedRecipient === "\uC804\uCCB4" || normalizedRecipient === "all" || canonicalRecipient === canonicalAgentId || normalizedRecipient === agentName;
412
485
  }
413
486
  function isOwnMessageSender(sender, agentId, agentName) {
414
487
  const normalizedSender = sender.trim();
415
488
  if (!normalizedSender) {
416
489
  return false;
417
490
  }
418
- return normalizedSender === agentId || normalizedSender === agentName;
491
+ const canonicalSender = normalizedSender.replace(/-/g, "_");
492
+ const canonicalAgentId = agentId.trim().replace(/-/g, "_");
493
+ return canonicalSender === canonicalAgentId || normalizedSender === agentName;
419
494
  }
420
495
  function getInboxRoute(fileName) {
421
496
  const stem = fileName.replace(/\.md$/i, "");
@@ -741,6 +816,7 @@ var AppServerClient = class {
741
816
  threadId = null;
742
817
  currentThreadCwd = null;
743
818
  activeTurnId = null;
819
+ turnStartedAt = null;
744
820
  lastTurnStatus = null;
745
821
  lastNotificationMethod = null;
746
822
  lastNotificationAt = null;
@@ -781,6 +857,7 @@ var AppServerClient = class {
781
857
  "open",
782
858
  () => {
783
859
  this.connected = true;
860
+ this.logger(`connected to app-server at ${this.url}`);
784
861
  resolveOnce();
785
862
  },
786
863
  { once: true }
@@ -796,6 +873,8 @@ var AppServerClient = class {
796
873
  this.connected = false;
797
874
  this.initialized = false;
798
875
  this.activeTurnId = null;
876
+ this.turnStartedAt = null;
877
+ this.logger("disconnected from app-server");
799
878
  this.rejectPending(new Error("App Server connection closed"));
800
879
  });
801
880
  this.socket?.addEventListener("message", (event) => {
@@ -866,6 +945,7 @@ var AppServerClient = class {
866
945
  this.threadId = null;
867
946
  this.currentThreadCwd = null;
868
947
  this.activeTurnId = null;
948
+ this.turnStartedAt = null;
869
949
  this.lastTurnStatus = null;
870
950
  } else {
871
951
  this.logger(
@@ -958,6 +1038,7 @@ var AppServerClient = class {
958
1038
  const turnId = response?.turn?.id ?? null;
959
1039
  if (turnId) {
960
1040
  this.activeTurnId = turnId;
1041
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
961
1042
  }
962
1043
  return turnId;
963
1044
  }
@@ -1021,6 +1102,11 @@ var AppServerClient = class {
1021
1102
  activeTurnId = turn.id;
1022
1103
  }
1023
1104
  }
1105
+ if (activeTurnId && activeTurnId !== this.activeTurnId) {
1106
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1107
+ } else if (!activeTurnId) {
1108
+ this.turnStartedAt = null;
1109
+ }
1024
1110
  this.activeTurnId = activeTurnId;
1025
1111
  this.lastTurnStatus = lastTurnStatus;
1026
1112
  }
@@ -1071,14 +1157,22 @@ var AppServerClient = class {
1071
1157
  case "turn/started":
1072
1158
  if (params?.turn?.id) {
1073
1159
  this.activeTurnId = params.turn.id;
1160
+ this.turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
1074
1161
  this.logger(`turn started ${params.turn.id}`);
1075
1162
  }
1076
1163
  break;
1077
- case "turn/completed":
1164
+ case "turn/completed": {
1078
1165
  this.lastTurnStatus = params?.turn?.status ?? null;
1166
+ const prevTurnStartedAt = this.turnStartedAt;
1079
1167
  this.activeTurnId = null;
1080
- this.logger(`turn completed (${this.lastTurnStatus ?? "unknown"})`);
1168
+ this.turnStartedAt = null;
1169
+ const elapsedMs = prevTurnStartedAt ? Date.now() - new Date(prevTurnStartedAt).getTime() : null;
1170
+ const elapsedSuffix = elapsedMs !== null ? ` \u2014 ${Math.round(elapsedMs / 1e3)}s elapsed` : "";
1171
+ this.logger(
1172
+ `turn completed (${this.lastTurnStatus ?? "unknown"})${elapsedSuffix}`
1173
+ );
1081
1174
  break;
1175
+ }
1082
1176
  case "error":
1083
1177
  this.lastError = JSON.stringify(params ?? {}, null, 2);
1084
1178
  this.logger(`app-server error notification: ${this.lastError}`);
@@ -1115,6 +1209,7 @@ var AppServerClient = class {
1115
1209
  this.pending.clear();
1116
1210
  }
1117
1211
  };
1212
+ var heartbeatCount = 0;
1118
1213
  function writeHeartbeat(options, client, health) {
1119
1214
  if (client?.threadId) {
1120
1215
  const savedThread = readThreadState(options.stateDir);
@@ -1137,10 +1232,11 @@ function writeHeartbeat(options, client, health) {
1137
1232
  threadId: client?.threadId ?? null,
1138
1233
  threadCwd: client?.currentThreadCwd ?? null,
1139
1234
  activeTurnId: client?.activeTurnId ?? null,
1235
+ turnStartedAt: client?.turnStartedAt ?? null,
1140
1236
  lastTurnStatus: client?.lastTurnStatus ?? null,
1141
1237
  lastNotificationMethod: client?.lastNotificationMethod ?? null,
1142
1238
  lastNotificationAt: client?.lastNotificationAt ?? null,
1143
- lastError: client?.lastError ?? null,
1239
+ lastError: sanitizeErrorForPersistence(client?.lastError ?? null),
1144
1240
  lastSuccessfulAppServerAt: client?.lastSuccessfulAppServerAt ?? null,
1145
1241
  lastSuccessfulAppServerMethod: client?.lastSuccessfulAppServerMethod ?? null,
1146
1242
  consecutiveFailureCount: health.consecutiveFailureCount,
@@ -1152,9 +1248,84 @@ function writeHeartbeat(options, client, health) {
1152
1248
  `,
1153
1249
  "utf8"
1154
1250
  );
1251
+ heartbeatCount += 1;
1252
+ if (heartbeatCount % 5 === 0) {
1253
+ logStatus(
1254
+ `heartbeat: connected=${payload.connected}, thread=${payload.threadId ?? "null"}, turns=${payload.activeTurnId ? "active" : "0"}`
1255
+ );
1256
+ }
1257
+ const status = client?.connected ? "active" : "idle";
1258
+ updateCommsHeartbeat(options, status);
1259
+ }
1260
+ var COMMS_HEARTBEAT_LOCK_TIMEOUT_MS = 2e3;
1261
+ var COMMS_LOCK_STALE_AGE_MS = 1e4;
1262
+ function acquireCommsLock(lockPath) {
1263
+ const deadline = Date.now() + COMMS_HEARTBEAT_LOCK_TIMEOUT_MS;
1264
+ while (Date.now() < deadline) {
1265
+ try {
1266
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
1267
+ return true;
1268
+ } catch {
1269
+ try {
1270
+ const lockAge = Date.now() - statSync(lockPath).mtimeMs;
1271
+ if (lockAge > COMMS_LOCK_STALE_AGE_MS) {
1272
+ unlinkSync(lockPath);
1273
+ try {
1274
+ writeFileSync(lockPath, String(process.pid), { flag: "wx" });
1275
+ return true;
1276
+ } catch {
1277
+ }
1278
+ }
1279
+ } catch {
1280
+ }
1281
+ const start = Date.now();
1282
+ while (Date.now() - start < 50) {
1283
+ }
1284
+ }
1285
+ }
1286
+ return false;
1287
+ }
1288
+ function releaseCommsLock(lockPath) {
1289
+ try {
1290
+ unlinkSync(lockPath);
1291
+ } catch {
1292
+ }
1293
+ }
1294
+ function updateCommsHeartbeat(options, status) {
1295
+ const heartbeatsPath = join(options.commsDir, "heartbeats.json");
1296
+ const lockPath = join(options.commsDir, ".heartbeats.lock");
1297
+ if (!acquireCommsLock(lockPath)) {
1298
+ return;
1299
+ }
1300
+ try {
1301
+ let store = {};
1302
+ try {
1303
+ store = JSON.parse(readFileSync(heartbeatsPath, "utf-8"));
1304
+ } catch {
1305
+ }
1306
+ const key = options.agentId;
1307
+ const existing = store[key];
1308
+ store[key] = {
1309
+ id: options.agentId,
1310
+ agent: options.agentName,
1311
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1312
+ lastActivity: (/* @__PURE__ */ new Date()).toISOString(),
1313
+ joinedAt: existing?.joinedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
1314
+ status
1315
+ };
1316
+ const tmpPath = heartbeatsPath + ".tmp." + process.pid;
1317
+ writeFileSync(tmpPath, JSON.stringify(store, null, 2), "utf-8");
1318
+ renameSync(tmpPath, heartbeatsPath);
1319
+ } catch {
1320
+ } finally {
1321
+ releaseCommsLock(lockPath);
1322
+ }
1155
1323
  }
1156
1324
  async function dispatchCandidate(client, options, candidate, heartbeats) {
1157
1325
  const input = buildUserInput(candidate, options.agentName, heartbeats);
1326
+ logStatus(
1327
+ `dispatching from ${candidate.sender || "unknown"}: ${candidate.subject || "(none)"}`
1328
+ );
1158
1329
  if (client.isBusy()) {
1159
1330
  if (options.busyMode !== "steer") {
1160
1331
  return false;
@@ -1184,6 +1355,7 @@ async function dispatchCandidate(client, options, candidate, heartbeats) {
1184
1355
  }
1185
1356
  if (shouldRetrySteerAsStart(error)) {
1186
1357
  client.activeTurnId = null;
1358
+ client.turnStartedAt = null;
1187
1359
  logStatus(
1188
1360
  `steer fallback -> start for ${candidate.fileName} (${String(error)})`
1189
1361
  );
@@ -1287,7 +1459,10 @@ async function main() {
1287
1459
  options.messageLookbackMinutes,
1288
1460
  options.processExistingMessages
1289
1461
  );
1290
- const initialSavedThread = readThreadState(options.stateDir);
1462
+ const initialSavedThread = loadResumableThreadState(
1463
+ options.stateDir,
1464
+ options.appServerUrl
1465
+ );
1291
1466
  logStatus("codex app-server bridge ready");
1292
1467
  console.log(` repo: ${options.repoRoot}`);
1293
1468
  console.log(` comms: ${options.commsDir}`);
@@ -1325,7 +1500,10 @@ async function main() {
1325
1500
  options.gatewayToken
1326
1501
  );
1327
1502
  await client.connect();
1328
- const savedThread = readThreadState(options.stateDir);
1503
+ const savedThread = loadResumableThreadState(
1504
+ options.stateDir,
1505
+ options.appServerUrl
1506
+ );
1329
1507
  const threadId = await client.ensureThread(
1330
1508
  options.threadId,
1331
1509
  savedThread,
@@ -1373,6 +1551,7 @@ async function main() {
1373
1551
  }
1374
1552
  client?.disconnect().catch(() => void 0);
1375
1553
  client = null;
1554
+ logStatus(`reconnecting in ${options.reconnectSeconds}s...`);
1376
1555
  await delay(options.reconnectSeconds * 1e3);
1377
1556
  }
1378
1557
  }
@@ -1412,6 +1591,7 @@ export {
1412
1591
  buildUserInput,
1413
1592
  chooseLoadedThreadForCwd,
1414
1593
  isOwnMessageSender,
1594
+ loadResumableThreadState,
1415
1595
  main,
1416
1596
  maybeBootstrapHeadlessTurn,
1417
1597
  recipientMatchesAgent,