@snowyroad/arp 0.3.5 → 0.3.7

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 +221 -27
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1439,7 +1439,27 @@ var RelayClient = class {
1439
1439
  return [];
1440
1440
  }
1441
1441
  }
1442
- async postMessage(channelId, content) {
1442
+ /** Spread optional usage into the outbound body. Omits undefined fields so a no-usage
1443
+ * call produces a body byte-identical to the pre-billing shape. */
1444
+ usageBody(u) {
1445
+ if (!u) return {};
1446
+ const b = { costReported: u.costReported };
1447
+ if (u.model !== void 0) b.model = u.model;
1448
+ if (u.provider !== void 0) b.provider = u.provider;
1449
+ if (u.authMode !== void 0) b.authMode = u.authMode;
1450
+ if (u.costUsed !== void 0) b.costUsed = u.costUsed;
1451
+ if (u.costCurrency !== void 0) b.costCurrency = u.costCurrency;
1452
+ if (u.contextUsed !== void 0) b.contextUsed = u.contextUsed;
1453
+ if (u.contextSize !== void 0) b.contextSize = u.contextSize;
1454
+ if (u.tokensUsed !== void 0) b.tokensUsed = u.tokensUsed;
1455
+ if (u.inputTokens !== void 0) b.inputTokens = u.inputTokens;
1456
+ if (u.outputTokens !== void 0) b.outputTokens = u.outputTokens;
1457
+ if (u.cachedReadTokens !== void 0) b.cachedReadTokens = u.cachedReadTokens;
1458
+ if (u.cachedWriteTokens !== void 0) b.cachedWriteTokens = u.cachedWriteTokens;
1459
+ if (u.thoughtTokens !== void 0) b.thoughtTokens = u.thoughtTokens;
1460
+ return b;
1461
+ }
1462
+ async postMessage(channelId, content, usage) {
1443
1463
  const ch = this.pathId(channelId, "channelId");
1444
1464
  if (!ch) return;
1445
1465
  const url = `${this.cfg.relayHttpUrl}/channels/${ch}/messages`;
@@ -1449,7 +1469,8 @@ var RelayClient = class {
1449
1469
  content,
1450
1470
  agentId: this.cfg.agentUuid,
1451
1471
  agentName: this.cfg.agentName,
1452
- messageType: "agent"
1472
+ messageType: "agent",
1473
+ ...this.usageBody(usage)
1453
1474
  });
1454
1475
  try {
1455
1476
  const res = await this.deps.fetchFn(url, {
@@ -1467,7 +1488,7 @@ var RelayClient = class {
1467
1488
  /** Post a bounded-flow reply (turn or synthesis) to the flow-scoped endpoint.
1468
1489
  * agentId MUST be the agent NAME — the relay's flow gate resolves turn ownership and
1469
1490
  * synthesis role via resolveAgentUUID (a name lookup); a UUID resolves to uuid.Nil -> 403. */
1470
- async postFlowMessage(channelId, flowId, content) {
1491
+ async postFlowMessage(channelId, flowId, content, usage) {
1471
1492
  const ch = this.pathId(channelId, "channelId");
1472
1493
  const fl = this.pathId(flowId, "flowId");
1473
1494
  if (!ch || !fl) return;
@@ -1478,7 +1499,8 @@ var RelayClient = class {
1478
1499
  // The relay's flow gate resolves turn ownership + synthesis role by NAME
1479
1500
  // (resolveAgentUUID is a name lookup), so the flow reply must carry the agent name.
1480
1501
  agentId: this.cfg.agentName,
1481
- agentName: this.cfg.agentName
1502
+ agentName: this.cfg.agentName,
1503
+ ...this.usageBody(usage)
1482
1504
  });
1483
1505
  try {
1484
1506
  const res = await this.deps.fetchFn(url, {
@@ -1697,10 +1719,10 @@ ${toolStatusLine(this.toolMode)}
1697
1719
  }
1698
1720
  async start(opts) {
1699
1721
  this.session = await this.adapter.start(opts);
1700
- this.session.onTurn((full) => {
1722
+ this.session.onTurn((full, usage) => {
1701
1723
  this.beacon?.end();
1702
1724
  if (full.replace(/<<silent>>/gi, "").trim() === "") return;
1703
- this.onReply(full.replace(/^\s*(?:<<silent>>\s*)+/i, "").trim());
1725
+ this.onReply(full.replace(/^\s*(?:<<silent>>\s*)+/i, "").trim(), usage);
1704
1726
  });
1705
1727
  }
1706
1728
  /**
@@ -1867,7 +1889,7 @@ var ActivityBeacon = class {
1867
1889
  // src/adapter.ts
1868
1890
  import { query } from "@anthropic-ai/claude-agent-sdk";
1869
1891
  import { accessSync, constants, existsSync as existsSync2, statSync } from "fs";
1870
- import { delimiter, dirname as dirname2, join as join3, resolve as resolve2 } from "path";
1892
+ import { delimiter, dirname as dirname3, join as join4, resolve as resolve2 } from "path";
1871
1893
 
1872
1894
  // src/acp/client.ts
1873
1895
  import { spawn } from "child_process";
@@ -1967,6 +1989,7 @@ var AcpClient = class {
1967
1989
  child = null;
1968
1990
  conn = null;
1969
1991
  _sessionId = null;
1992
+ loadSupported = false;
1970
1993
  /**
1971
1994
  * The currently-running turn's reply accumulator. Set for the duration of one
1972
1995
  * turn so agent_message_chunk text lands in THIS turn's buffer only. Because
@@ -2054,6 +2077,15 @@ var AcpClient = class {
2054
2077
  if (u.sessionUpdate === "agent_message_chunk" && u.content?.type === "text" && this.activeTurnBuffer) {
2055
2078
  this.activeTurnBuffer.text += u.content.text;
2056
2079
  }
2080
+ if (u.sessionUpdate === "usage_update" && this.activeTurnBuffer) {
2081
+ this.activeTurnBuffer.usage ??= {};
2082
+ this.activeTurnBuffer.usage.contextUsed = u.used;
2083
+ this.activeTurnBuffer.usage.contextSize = u.size;
2084
+ if (u.cost) {
2085
+ this.activeTurnBuffer.usage.costCumulative = u.cost.amount;
2086
+ this.activeTurnBuffer.usage.costCurrency = u.cost.currency;
2087
+ }
2088
+ }
2057
2089
  },
2058
2090
  requestPermission: async (req) => {
2059
2091
  const verdict = evaluateAcpPermission(this.policy.mode, this.policy.configDirAbs, req);
@@ -2075,7 +2107,7 @@ var AcpClient = class {
2075
2107
  }
2076
2108
  };
2077
2109
  this.conn = new ClientSideConnection(() => client, stream);
2078
- await this.guard(
2110
+ const init = await this.guard(
2079
2111
  this.conn.initialize({
2080
2112
  protocolVersion: 1,
2081
2113
  clientCapabilities: {
@@ -2085,10 +2117,29 @@ var AcpClient = class {
2085
2117
  clientInfo: { name: "arp-bridge", version: "0.1.0" }
2086
2118
  })
2087
2119
  );
2088
- const session = await this.guard(
2089
- this.conn.newSession({ cwd: this.launch.cwd, mcpServers: [] })
2090
- );
2091
- this._sessionId = session.sessionId;
2120
+ this.loadSupported = init.agentCapabilities?.loadSession === true;
2121
+ const candidateId = this._sessionId ?? this.launch.session?.persistedId ?? null;
2122
+ let liveId = null;
2123
+ if (candidateId && this.loadSupported) {
2124
+ try {
2125
+ await this.guard(
2126
+ this.conn.loadSession({ sessionId: candidateId, cwd: this.launch.cwd, mcpServers: [] })
2127
+ );
2128
+ liveId = candidateId;
2129
+ } catch (err) {
2130
+ console.warn(
2131
+ `[arp-bridge] session/load failed; starting a fresh session: ${sanitizeForTty(String(err?.message ?? err))}`
2132
+ );
2133
+ }
2134
+ }
2135
+ if (!liveId) {
2136
+ const session = await this.guard(
2137
+ this.conn.newSession({ cwd: this.launch.cwd, mcpServers: [] })
2138
+ );
2139
+ liveId = session.sessionId;
2140
+ }
2141
+ this._sessionId = liveId;
2142
+ this.launch.session?.save(liveId);
2092
2143
  }
2093
2144
  /**
2094
2145
  * Send one user turn. Resolves with the full assembled reply text once the
@@ -2119,13 +2170,27 @@ var AcpClient = class {
2119
2170
  const buffer = { text: "" };
2120
2171
  this.activeTurnBuffer = buffer;
2121
2172
  try {
2122
- await this.guard(
2173
+ const resp = await this.guard(
2123
2174
  this.conn.prompt({
2124
2175
  sessionId: this._sessionId,
2125
2176
  prompt: [{ type: "text", text }]
2126
2177
  })
2127
2178
  );
2128
- return buffer.text;
2179
+ const u = resp?.usage;
2180
+ if (u) {
2181
+ buffer.usage = {
2182
+ ...buffer.usage ?? {},
2183
+ tokenBreakdown: {
2184
+ inputTokens: u.inputTokens,
2185
+ outputTokens: u.outputTokens,
2186
+ cachedReadTokens: u.cachedReadTokens ?? void 0,
2187
+ cachedWriteTokens: u.cachedWriteTokens ?? void 0,
2188
+ thoughtTokens: u.thoughtTokens ?? void 0,
2189
+ totalTokens: u.totalTokens
2190
+ }
2191
+ };
2192
+ }
2193
+ return { text: buffer.text, usage: buffer.usage };
2129
2194
  } finally {
2130
2195
  if (this.activeTurnBuffer === buffer) this.activeTurnBuffer = null;
2131
2196
  }
@@ -2189,6 +2254,94 @@ var AcpClient = class {
2189
2254
  }
2190
2255
  };
2191
2256
 
2257
+ // src/sessionStore.ts
2258
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync as renameSync2, chmodSync as chmodSync2 } from "fs";
2259
+ import { join as join3, dirname as dirname2 } from "path";
2260
+ function safe(s) {
2261
+ return s.replace(/[^a-zA-Z0-9._-]/g, "_");
2262
+ }
2263
+ function sessionFilePath(dir, relayUrl, agentName, channelId) {
2264
+ return join3(dir, "sessions", relayHostSegment(relayUrl), `${safe(agentName)}__${safe(channelId)}.json`);
2265
+ }
2266
+ function loadStoredSession(dir, relayUrl, agentName, channelId) {
2267
+ const file = sessionFilePath(dir, relayUrl, agentName, channelId);
2268
+ let raw;
2269
+ try {
2270
+ raw = readFileSync2(file, "utf8");
2271
+ } catch {
2272
+ return null;
2273
+ }
2274
+ try {
2275
+ const p = JSON.parse(raw);
2276
+ if (typeof p.sessionId === "string" && p.sessionId.trim() !== "" && typeof p.cwd === "string" && p.cwd.trim() !== "") {
2277
+ const rec = { sessionId: p.sessionId, cwd: p.cwd };
2278
+ if (typeof p.costCumulativeLastSeen === "number") rec.costCumulativeLastSeen = p.costCumulativeLastSeen;
2279
+ if (typeof p.costCurrency === "string") rec.costCurrency = p.costCurrency;
2280
+ return rec;
2281
+ }
2282
+ return null;
2283
+ } catch {
2284
+ return null;
2285
+ }
2286
+ }
2287
+ function saveStoredSession(dir, relayUrl, agentName, channelId, rec) {
2288
+ const file = sessionFilePath(dir, relayUrl, agentName, channelId);
2289
+ mkdirSync2(dirname2(file), { recursive: true, mode: 448 });
2290
+ const tmp = `${file}.tmp-${process.pid}`;
2291
+ writeFileSync2(tmp, JSON.stringify(rec, null, 2) + "\n", { mode: 384 });
2292
+ renameSync2(tmp, file);
2293
+ try {
2294
+ chmodSync2(file, 384);
2295
+ } catch {
2296
+ }
2297
+ }
2298
+ function updateStoredCost(dir, relayUrl, agentName, channelId, cumulative, currency) {
2299
+ const cur = loadStoredSession(dir, relayUrl, agentName, channelId);
2300
+ if (!cur) return;
2301
+ cur.costCumulativeLastSeen = cumulative;
2302
+ if (currency) cur.costCurrency = currency;
2303
+ saveStoredSession(dir, relayUrl, agentName, channelId, cur);
2304
+ }
2305
+
2306
+ // src/usage/source.ts
2307
+ var AcpUsageSource = class {
2308
+ constructor(deps) {
2309
+ this.deps = deps;
2310
+ }
2311
+ deps;
2312
+ forTurn(raw) {
2313
+ if (!raw || raw.contextUsed === void 0 && raw.contextSize === void 0 && raw.costCumulative === void 0 && raw.tokenBreakdown === void 0) {
2314
+ return void 0;
2315
+ }
2316
+ const out = {
2317
+ costReported: false,
2318
+ model: this.deps.model,
2319
+ provider: this.deps.provider,
2320
+ authMode: this.deps.authMode
2321
+ };
2322
+ if (raw.contextUsed !== void 0) out.contextUsed = raw.contextUsed;
2323
+ if (raw.contextSize !== void 0) out.contextSize = raw.contextSize;
2324
+ if (raw.costCumulative !== void 0) {
2325
+ out.costReported = true;
2326
+ out.costCurrency = raw.costCurrency;
2327
+ const prior = this.deps.loadBaseline() ?? 0;
2328
+ const delta = Math.max(0, raw.costCumulative - prior);
2329
+ out.costUsed = delta;
2330
+ this.deps.saveBaseline(raw.costCumulative, raw.costCurrency);
2331
+ }
2332
+ if (raw.tokenBreakdown) {
2333
+ const b = raw.tokenBreakdown;
2334
+ out.inputTokens = b.inputTokens;
2335
+ out.outputTokens = b.outputTokens;
2336
+ out.cachedReadTokens = b.cachedReadTokens;
2337
+ out.cachedWriteTokens = b.cachedWriteTokens;
2338
+ out.thoughtTokens = b.thoughtTokens;
2339
+ out.tokensUsed = b.totalTokens;
2340
+ }
2341
+ return out;
2342
+ }
2343
+ };
2344
+
2192
2345
  // src/adapter.ts
2193
2346
  function defaultToolPolicy() {
2194
2347
  return { mode: "readonly", configDirAbs: resolve2(configDir(process.env)) };
@@ -2205,9 +2358,9 @@ function pinned(pkg) {
2205
2358
  var npxBinaryAbs = null;
2206
2359
  function resolveNpxBinary() {
2207
2360
  if (npxBinaryAbs) return npxBinaryAbs;
2208
- const nodeDir = dirname2(process.execPath);
2361
+ const nodeDir = dirname3(process.execPath);
2209
2362
  for (const name of ["npx", "npx.cmd"]) {
2210
- const candidate = join3(nodeDir, name);
2363
+ const candidate = join4(nodeDir, name);
2211
2364
  if (existsSync2(candidate)) {
2212
2365
  npxBinaryAbs = candidate;
2213
2366
  return candidate;
@@ -2220,7 +2373,7 @@ function resolveNpxBinary() {
2220
2373
  function which(cmd, pathEnv = process.env.PATH ?? "") {
2221
2374
  for (const dir of pathEnv.split(delimiter)) {
2222
2375
  if (!dir) continue;
2223
- const candidate = join3(dir, cmd);
2376
+ const candidate = join4(dir, cmd);
2224
2377
  try {
2225
2378
  accessSync(candidate, constants.X_OK);
2226
2379
  if (statSync(candidate).isFile()) return resolve2(candidate);
@@ -2274,13 +2427,17 @@ var defaultAcpClientFactory = (launch) => new AcpClient(launch);
2274
2427
  var MAX_CONSECUTIVE_RESTARTS = 3;
2275
2428
  var RESTART_BACKOFF_MS = 250;
2276
2429
  var AcpAdapter = class {
2277
- constructor(agent, makeClient = defaultAcpClientFactory, backoffMs = RESTART_BACKOFF_MS, policy = defaultToolPolicy()) {
2430
+ constructor(agent, makeClient = defaultAcpClientFactory, backoffMs = RESTART_BACKOFF_MS, policy = defaultToolPolicy(), session, usageSource) {
2278
2431
  this.makeClient = makeClient;
2279
2432
  this.backoffMs = backoffMs;
2433
+ this.session = session;
2434
+ this.usageSource = usageSource;
2280
2435
  this.launch = { ...launchSpecFor(agent), policy };
2281
2436
  }
2282
2437
  makeClient;
2283
2438
  backoffMs;
2439
+ session;
2440
+ usageSource;
2284
2441
  launch;
2285
2442
  // --- supervised live state (set in start()) ---
2286
2443
  client = null;
@@ -2294,7 +2451,17 @@ var AcpAdapter = class {
2294
2451
  /** Latched once the loop guard trips, so we stop trying (and stop retrying turns). */
2295
2452
  gaveUp = false;
2296
2453
  async start(_opts) {
2297
- this.client = this.makeClient(this.launch);
2454
+ const rec = this.session?.load() ?? null;
2455
+ const cwd = rec?.cwd ?? this.launch.cwd;
2456
+ const launch = {
2457
+ ...this.launch,
2458
+ cwd,
2459
+ session: this.session ? {
2460
+ persistedId: rec?.sessionId ?? null,
2461
+ save: (id) => this.session.save({ sessionId: id, cwd })
2462
+ } : void 0
2463
+ };
2464
+ this.client = this.makeClient(launch);
2298
2465
  await this.client.start();
2299
2466
  return {
2300
2467
  submit: (text) => {
@@ -2322,6 +2489,12 @@ var AcpAdapter = class {
2322
2489
  * never posted to the channel. A local aside on a dead/gave-up client returns a
2323
2490
  * clear "agent unavailable" string rather than restarting (restart supervision is
2324
2491
  * reserved for channel turns; a local REPL caller sees the message inline).
2492
+ *
2493
+ * BILLING NOTE: local asides deliberately bypass handleTurn, so they do NOT advance
2494
+ * the cost baseline. Any cost they accrue in the warm session surfaces in the NEXT
2495
+ * channel turn's cumulative-delta. Do NOT add a usageSource.forTurn call here — that
2496
+ * would double-count (the same cumulative gets billed once locally and again on the
2497
+ * next channel turn).
2325
2498
  */
2326
2499
  async converseLocal(text) {
2327
2500
  const client = this.client;
@@ -2329,7 +2502,7 @@ var AcpAdapter = class {
2329
2502
  return "[arp-bridge] agent unavailable for local conversation";
2330
2503
  }
2331
2504
  try {
2332
- return await client.submit(text);
2505
+ return (await client.submit(text)).text;
2333
2506
  } catch (err) {
2334
2507
  return `[arp-bridge] local turn failed: ${err?.message ?? String(err)}`;
2335
2508
  }
@@ -2347,9 +2520,10 @@ var AcpAdapter = class {
2347
2520
  const client = this.client;
2348
2521
  if (!client) return false;
2349
2522
  try {
2350
- const reply = await client.submit(text);
2523
+ const result = await client.submit(text);
2351
2524
  this.consecutiveRestarts = 0;
2352
- this.turnCbs.forEach((cb) => cb(reply));
2525
+ const usage = this.usageSource?.forTurn(result.usage);
2526
+ this.turnCbs.forEach((cb) => cb(result.text, usage));
2353
2527
  return true;
2354
2528
  } catch (err) {
2355
2529
  if (this.stopped) {
@@ -2520,14 +2694,34 @@ var ClaudeAdapter = class {
2520
2694
  };
2521
2695
  }
2522
2696
  };
2523
- function createAdapter(cfg) {
2697
+ function createAdapter(cfg, channelId) {
2524
2698
  const policy = {
2525
2699
  mode: cfg.toolMode,
2526
2700
  configDirAbs: resolve2(configDir(process.env)),
2527
2701
  agentName: cfg.agentName
2528
2702
  // for the once-per-process "arp tools full <name>" denial hint
2529
2703
  };
2530
- return cfg.agentMode === "acp" ? new AcpAdapter(cfg.agent, void 0, void 0, policy) : new ClaudeAdapter(policy);
2704
+ if (cfg.agentMode !== "acp") return new ClaudeAdapter(policy);
2705
+ const session = channelId ? makeSessionPersistence(cfg, channelId) : void 0;
2706
+ const usageSource = channelId ? makeUsageSource(cfg, channelId) : void 0;
2707
+ return new AcpAdapter(cfg.agent, void 0, void 0, policy, session, usageSource);
2708
+ }
2709
+ function makeSessionPersistence(cfg, channelId) {
2710
+ const dir = configDir(process.env);
2711
+ return {
2712
+ load: () => loadStoredSession(dir, cfg.relayWsUrl, cfg.agentName, channelId),
2713
+ save: (rec) => saveStoredSession(dir, cfg.relayWsUrl, cfg.agentName, channelId, rec)
2714
+ };
2715
+ }
2716
+ function makeUsageSource(cfg, channelId) {
2717
+ const dir = configDir(process.env);
2718
+ return new AcpUsageSource({
2719
+ model: cfg.model,
2720
+ provider: cfg.agent,
2721
+ authMode: cfg.agentMode === "acp" ? "oauth" : "apikey",
2722
+ loadBaseline: () => loadStoredSession(dir, cfg.relayWsUrl, cfg.agentName, channelId)?.costCumulativeLastSeen,
2723
+ saveBaseline: (cumulative, currency) => updateStoredCost(dir, cfg.relayWsUrl, cfg.agentName, channelId, cumulative, currency)
2724
+ });
2531
2725
  }
2532
2726
 
2533
2727
  // src/elicit.ts
@@ -2612,15 +2806,15 @@ async function startBridge(cfg, relay, deps) {
2612
2806
  const inFlight = pending.get(channelId);
2613
2807
  if (inFlight) return inFlight;
2614
2808
  const p = (async () => {
2615
- const adapter = deps.makeAdapter(cfg);
2809
+ const adapter = deps.makeAdapter(cfg, channelId);
2616
2810
  const beacon = new ActivityBeacon((state) => relay.sendActivity(channelId, state));
2617
2811
  const session = new ChannelSession(
2618
2812
  adapter,
2619
- (text) => void relay.postMessage(channelId, text),
2813
+ (text, usage) => void relay.postMessage(channelId, text, usage),
2620
2814
  cfg.agentName,
2621
2815
  channelId,
2622
2816
  {
2623
- postReply: (flowId, content) => relay.postFlowMessage(channelId, flowId, content),
2817
+ postReply: (flowId, content, usage) => relay.postFlowMessage(channelId, flowId, content, usage),
2624
2818
  fetchHistory: (flowId) => relay.fetchFlowMessages(channelId, flowId)
2625
2819
  },
2626
2820
  () => relay.fetchChannelContext(channelId),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/arp",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Connect your own coding agent (Claude Code, Codex, Gemini, Grok) to an Agent Relay Protocol channel and collaborate with other agents and humans.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "SnowyRoad",