@lesscap/baton-cli 0.3.0 → 0.3.2

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/baton.mjs +301 -135
  2. package/package.json +1 -1
package/dist/baton.mjs CHANGED
@@ -949,28 +949,33 @@ function getBaseURL() {
949
949
  // src/client/channel.ts
950
950
  var channelClient = (baseUrl) => {
951
951
  const u = (p) => `${baseUrl}${p}`;
952
- const authed = (token) => ({ authorization: `Bearer ${token}` });
953
- const sendJson = (token) => ({ "content-type": "application/json", ...authed(token) });
952
+ const JSON_CT = { "content-type": "application/json" };
954
953
  const member = (channelId, name) => u(`/channels/${channelId}/members/${encodeURIComponent(name)}`);
955
954
  return {
956
- create: async (title, description) => {
957
- const res = await fetch(u("/channels"), {
955
+ create: async (workspaceId, title, description, token) => {
956
+ const res = await fetch(u(`/workspaces/${workspaceId}/channels`), {
958
957
  method: "POST",
959
- headers: { "content-type": "application/json" },
960
- body: JSON.stringify({ ...title ? { title } : {}, ...description ? { description } : {} })
958
+ headers: { ...JSON_CT, ...token ? { authorization: `Bearer ${token}` } : {} },
959
+ body: JSON.stringify({
960
+ ...title ? { title } : {},
961
+ ...description ? { description } : {}
962
+ })
961
963
  });
962
- if (!res.ok) throw new Error(`POST /channels \u2192 ${res.status}: ${await res.text()}`);
964
+ if (!res.ok)
965
+ throw new Error(
966
+ `POST /workspaces/${workspaceId}/channels \u2192 ${res.status}: ${await res.text()}`
967
+ );
963
968
  return await res.json();
964
969
  },
965
- manifest: async (channelId, token) => {
966
- const res = await fetch(u(`/channels/${channelId}`), { headers: authed(token) });
970
+ manifest: async (channelId) => {
971
+ const res = await fetch(u(`/channels/${channelId}`));
967
972
  if (!res.ok) throw new Error(`GET channel \u2192 ${res.status}: ${await res.text()}`);
968
973
  return await res.json();
969
974
  },
970
- update: async (channelId, token, patch2) => {
975
+ update: async (channelId, patch2) => {
971
976
  const res = await fetch(u(`/channels/${channelId}`), {
972
977
  method: "PATCH",
973
- headers: sendJson(token),
978
+ headers: JSON_CT,
974
979
  body: JSON.stringify(patch2)
975
980
  });
976
981
  if (!res.ok) throw new Error(`PATCH channel \u2192 ${res.status}: ${await res.text()}`);
@@ -981,60 +986,63 @@ var channelClient = (baseUrl) => {
981
986
  if (!res.ok) throw new Error(`GET /channels/help \u2192 ${res.status}: ${await res.text()}`);
982
987
  return await res.text();
983
988
  },
984
- destroy: async (channelId, token) => {
985
- const res = await fetch(u(`/channels/${channelId}`), {
986
- method: "DELETE",
987
- headers: authed(token)
988
- });
989
+ destroy: async (channelId) => {
990
+ const res = await fetch(u(`/channels/${channelId}`), { method: "DELETE" });
989
991
  if (!res.ok) throw new Error(`DELETE channel \u2192 ${res.status}: ${await res.text()}`);
990
992
  },
991
- join: async (channelId, token, name, kind) => {
993
+ join: async (channelId, name, kind) => {
992
994
  const res = await fetch(member(channelId, name), {
993
995
  method: "PUT",
994
- headers: sendJson(token),
996
+ headers: JSON_CT,
995
997
  body: JSON.stringify({ kind: kind ?? "agent" })
996
998
  });
997
999
  if (!res.ok) throw new Error(`join channel \u2192 ${res.status}: ${await res.text()}`);
998
1000
  return (await res.json()).members;
999
1001
  },
1000
- leave: async (channelId, token, name) => {
1001
- const res = await fetch(member(channelId, name), { method: "DELETE", headers: authed(token) });
1002
+ leave: async (channelId, name) => {
1003
+ const res = await fetch(member(channelId, name), { method: "DELETE" });
1002
1004
  if (!res.ok) throw new Error(`leave channel \u2192 ${res.status}: ${await res.text()}`);
1003
1005
  },
1004
- members: async (channelId, token) => {
1005
- const res = await fetch(u(`/channels/${channelId}/members`), { headers: authed(token) });
1006
+ members: async (channelId) => {
1007
+ const res = await fetch(u(`/channels/${channelId}/members`));
1006
1008
  if (!res.ok) throw new Error(`GET members \u2192 ${res.status}: ${await res.text()}`);
1007
1009
  return (await res.json()).members;
1008
1010
  },
1009
- send: async (channelId, token, msg) => {
1011
+ send: async (channelId, msg) => {
1010
1012
  const res = await fetch(u(`/channels/${channelId}/messages`), {
1011
1013
  method: "POST",
1012
- headers: sendJson(token),
1014
+ headers: JSON_CT,
1013
1015
  body: JSON.stringify(msg)
1014
1016
  });
1015
1017
  if (!res.ok) throw new Error(`POST channel message \u2192 ${res.status}: ${await res.text()}`);
1016
1018
  return await res.json();
1017
1019
  },
1018
- read: async (channelId, token, opts) => {
1020
+ uploadAttachment: async (channelId, input) => {
1021
+ const res = await fetch(
1022
+ u(`/channels/${channelId}/attachments?filename=${encodeURIComponent(input.filename)}`),
1023
+ { method: "POST", headers: { "content-type": input.contentType }, body: input.body }
1024
+ );
1025
+ if (!res.ok) throw new Error(`upload attachment \u2192 ${res.status}: ${await res.text()}`);
1026
+ return await res.json();
1027
+ },
1028
+ downloadAttachment: async (channelId, attId) => {
1029
+ const res = await fetch(u(`/channels/${channelId}/attachments/${attId}`));
1030
+ if (!res.ok) throw new Error(`download attachment \u2192 ${res.status}: ${await res.text()}`);
1031
+ return res;
1032
+ },
1033
+ read: async (channelId, opts) => {
1019
1034
  const params = new URLSearchParams({ since: String(opts?.since ?? 0) });
1020
1035
  if (opts?.for) params.set("for", opts.for);
1021
- const res = await fetch(u(`/channels/${channelId}/messages?${params}`), {
1022
- headers: authed(token)
1023
- });
1036
+ const res = await fetch(u(`/channels/${channelId}/messages?${params}`));
1024
1037
  if (!res.ok) throw new Error(`GET messages \u2192 ${res.status}: ${await res.text()}`);
1025
1038
  return (await res.json()).messages;
1026
1039
  },
1027
- listen: (channelId, token, opts) => {
1040
+ listen: (channelId, opts) => {
1028
1041
  let cursor = opts.since ?? 0;
1029
1042
  const params = new URLSearchParams({ since: String(cursor) });
1030
1043
  if (opts.for) params.set("for", opts.for);
1031
1044
  if (opts.as) params.set("as", opts.as);
1032
- const es = new EventSource(u(`/channels/${channelId}/stream?${params}`), {
1033
- fetch: (url, init2) => fetch(url, {
1034
- ...init2,
1035
- headers: { ...init2?.headers, ...authed(token) }
1036
- })
1037
- });
1045
+ const es = new EventSource(u(`/channels/${channelId}/stream?${params}`));
1038
1046
  es.onmessage = (e) => {
1039
1047
  try {
1040
1048
  const m = JSON.parse(e.data);
@@ -1126,7 +1134,7 @@ var fmtTask = (t) => `${t.code} [${t.status}] ${t.title}`;
1126
1134
  var fmtComment = (c) => `${c.workerId !== void 0 ? `worker#${c.workerId}` : "you"} ${c.body}`;
1127
1135
  var fmtSession = (s) => `${s.id} ${s.name} ${s.attached ? "[active]" : "[inactive]"} ${s.worktreePath ?? "(pending)"}`;
1128
1136
  var fmtWorker = (w) => {
1129
- const offline = w.alive === false ? " [offline]" : "";
1137
+ const offline = w.connected === false ? " [offline]" : "";
1130
1138
  return `W-${w.id} ${w.name} ${w.hostname}${offline}`;
1131
1139
  };
1132
1140
  var renderOne = (item, fmt, json) => json ? toJson(item) : fmt(item);
@@ -1221,6 +1229,25 @@ var requirementClient = (baseUrl) => {
1221
1229
  };
1222
1230
  };
1223
1231
 
1232
+ // src/client/retry.ts
1233
+ var sleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
1234
+ var withRetry = async (fn, opts = {}) => {
1235
+ const tries = Math.max(1, opts.tries ?? 4);
1236
+ const baseMs = opts.baseMs ?? 300;
1237
+ let lastErr;
1238
+ for (let i = 0; i < tries; i++) {
1239
+ try {
1240
+ return await fn();
1241
+ } catch (err) {
1242
+ lastErr = err;
1243
+ if (i === tries - 1) break;
1244
+ const backoff = baseMs * 2 ** i;
1245
+ await sleep(backoff + Math.random() * baseMs);
1246
+ }
1247
+ }
1248
+ throw lastErr;
1249
+ };
1250
+
1224
1251
  // src/client/sessions.ts
1225
1252
  var sessionsClient = (baseUrl) => {
1226
1253
  const u = (p) => `${baseUrl}${p}`;
@@ -1343,15 +1370,24 @@ var clientFromBase = (baseUrl) => ({
1343
1370
  sessions: sessionsClient(baseUrl),
1344
1371
  workers: workersClient(baseUrl)
1345
1372
  });
1373
+ var BOUNDARY_EVENTS = /* @__PURE__ */ new Set([
1374
+ "turn_start",
1375
+ "turn_complete",
1376
+ "turn_error"
1377
+ ]);
1378
+ var emitRetries = () => Number(process.env.BATON_EMIT_RETRIES) || 4;
1346
1379
  var createWorkerClient = (baseUrl, workerToken, sessionId) => {
1347
1380
  const u = (p) => `${baseUrl}${p}`;
1348
1381
  const auth = { authorization: `Bearer ${workerToken}` };
1349
1382
  return {
1350
- emitEvent: (type, payload) => request(u(`/sessions/${sessionId}/events`), {
1351
- method: "POST",
1352
- body: { type, payload },
1353
- headers: auth
1354
- }),
1383
+ emitEvent: (type, payload) => {
1384
+ const post = () => request(u(`/sessions/${sessionId}/events`), {
1385
+ method: "POST",
1386
+ body: { type, payload },
1387
+ headers: auth
1388
+ });
1389
+ return BOUNDARY_EVENTS.has(type) ? withRetry(post, { tries: emitRetries() }) : post();
1390
+ },
1355
1391
  setActive: (active) => request(u(`/sessions/${sessionId}/status`), {
1356
1392
  method: "POST",
1357
1393
  body: { active },
@@ -1381,6 +1417,11 @@ var clientFor = (args) => {
1381
1417
  const auth = resolveAuth(process.env, fileToken);
1382
1418
  return createClient(baseUrl, auth && auth !== "cookie" ? auth : void 0);
1383
1419
  };
1420
+ var resolveBearer = () => {
1421
+ const fileToken = loadProjectConfigOrNull(projectConfigPath())?.worker?.apiToken;
1422
+ const auth = resolveAuth(process.env, fileToken);
1423
+ return auth && auth !== "cookie" ? auth.bearer : void 0;
1424
+ };
1384
1425
  var resolveProjectId = (args) => {
1385
1426
  if (args.project !== void 0 && args.project !== "") return Number(args.project);
1386
1427
  const cfg = loadProjectConfigOrNull(projectConfigPath());
@@ -1406,11 +1447,10 @@ var closeCommand = defineCommand({
1406
1447
  meta: { name: "close", description: "delete a channel (removes the room and all its messages)" },
1407
1448
  args: {
1408
1449
  channel: { type: "positional", required: true, description: "channel id" },
1409
- token: { type: "string", required: true, description: "channel token" },
1410
1450
  ...common
1411
1451
  },
1412
1452
  run: async ({ args }) => {
1413
- await channelClient(resolveBaseUrl(args.url)).destroy(args.channel, args.token);
1453
+ await channelClient(resolveBaseUrl(args.url)).destroy(args.channel);
1414
1454
  console.log(args.json ? toJson({ ok: true, deleted: args.channel }) : `closed ${args.channel}`);
1415
1455
  }
1416
1456
  });
@@ -1422,35 +1462,77 @@ var readStdin = async () => {
1422
1462
  return Buffer.concat(chunks).toString("utf8");
1423
1463
  };
1424
1464
  var renderRoster = (members) => members.length ? members.map((m) => ` ${m.name} (${m.kind})`).join("\n") : " (nobody online)";
1425
- var inviteBlock = (url, channelId, token) => [
1426
- "\u2500\u2500 Share this to invite anyone into the room \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
1427
- "You're invited to a baton channel. Three steps to get going:",
1428
- ` 1) see the room: curl -sS -H "authorization: Bearer ${token}" "${url}/channels/${channelId}"`,
1429
- ` 2) read protocol: curl -sS "${url}/channels/help"`,
1430
- " 3) follow it to join / listen / send (pick your own NAME).",
1431
- "",
1432
- `connection: url=${url} channel=${channelId} token=${token}`,
1433
- "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
1434
- ].join("\n");
1465
+ var webLink = (url, channelId) => {
1466
+ const origin = url.replace(/\/api\/?$/, "");
1467
+ return origin === url ? null : `${origin}/channel/${channelId}`;
1468
+ };
1469
+ var inviteBlock = (url, channelId) => {
1470
+ const web = webLink(url, channelId);
1471
+ return [
1472
+ "\u2500\u2500 Share this to invite anyone into the room \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500",
1473
+ ...web ? [`Humans \u2014 open in a browser: ${web}`, ""] : [],
1474
+ "Agents \u2014 two curl steps:",
1475
+ ` 1) see the room: curl -sS "${url}/channels/${channelId}"`,
1476
+ ` 2) read protocol: curl -sS "${url}/channels/help" (then join / listen / send, pick your own NAME)`,
1477
+ "",
1478
+ `connection: url=${url} channel=${channelId}`,
1479
+ "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
1480
+ ].join("\n");
1481
+ };
1435
1482
 
1436
1483
  // src/commands/channel/create.ts
1437
1484
  var createCommand = defineCommand({
1438
- meta: { name: "create", description: "open a channel (chat room) and print an invite" },
1485
+ meta: {
1486
+ name: "create",
1487
+ description: "open a channel (chat room) in a workspace and print an invite"
1488
+ },
1439
1489
  args: {
1490
+ workspace: {
1491
+ type: "string",
1492
+ description: "owning workspace id (or the cwd .baton.json workspace)"
1493
+ },
1440
1494
  name: { type: "string", description: "room title (optional)" },
1441
- desc: { type: "string", description: "room self-description: purpose / topic / how to participate" },
1495
+ desc: {
1496
+ type: "string",
1497
+ description: "room self-description: purpose / topic / how to participate"
1498
+ },
1442
1499
  ...common
1443
1500
  },
1444
1501
  run: async ({ args }) => {
1445
1502
  const url = resolveBaseUrl(args.url);
1446
- const { channelId, token } = await channelClient(url).create(args.name, args.desc);
1503
+ const workspaceId = resolveWorkspaceId(args);
1504
+ const { channelId } = await channelClient(url).create(
1505
+ workspaceId,
1506
+ args.name,
1507
+ args.desc,
1508
+ resolveBearer()
1509
+ );
1447
1510
  if (args.json) {
1448
- console.log(toJson({ channelId, token, url, name: args.name, description: args.desc }));
1511
+ console.log(toJson({ channelId, url, workspaceId, name: args.name, description: args.desc }));
1449
1512
  return;
1450
1513
  }
1451
- console.log(`channel ${channelId} opened
1514
+ console.log(`channel ${channelId} opened in workspace ${workspaceId}
1452
1515
  `);
1453
- console.log(inviteBlock(url, channelId, token));
1516
+ console.log(inviteBlock(url, channelId));
1517
+ }
1518
+ });
1519
+
1520
+ // src/commands/channel/download.ts
1521
+ import { writeFileSync as writeFileSync2 } from "node:fs";
1522
+ var downloadCommand = defineCommand({
1523
+ meta: { name: "download", description: "download a channel attachment to a file" },
1524
+ args: {
1525
+ channel: { type: "positional", required: true, description: "channel id" },
1526
+ att: { type: "positional", required: true, description: "attachment id (from a message ref)" },
1527
+ out: { type: "string", alias: "o", description: "output file path (default: ./<attId>)" },
1528
+ ...common
1529
+ },
1530
+ run: async ({ args }) => {
1531
+ const url = resolveBaseUrl(args.url);
1532
+ const res = await channelClient(url).downloadAttachment(args.channel, args.att);
1533
+ const out = args.out ?? args.att;
1534
+ writeFileSync2(out, Buffer.from(await res.arrayBuffer()));
1535
+ console.log(`saved \u2192 ${out}`);
1454
1536
  }
1455
1537
  });
1456
1538
 
@@ -1459,12 +1541,11 @@ var aboutCommand = defineCommand({
1459
1541
  meta: { name: "about", description: "show a channel's manifest (description + online roster)" },
1460
1542
  args: {
1461
1543
  channel: { type: "positional", required: true, description: "channel id" },
1462
- token: { type: "string", required: true, description: "channel token" },
1463
1544
  ...common
1464
1545
  },
1465
1546
  run: async ({ args }) => {
1466
1547
  const url = resolveBaseUrl(args.url);
1467
- const m = await channelClient(url).manifest(args.channel, args.token);
1548
+ const m = await channelClient(url).manifest(args.channel);
1468
1549
  if (args.json) {
1469
1550
  console.log(toJson(m));
1470
1551
  return;
@@ -1479,7 +1560,7 @@ protocol: curl -sS "${url}${m.help}"`);
1479
1560
  }
1480
1561
  });
1481
1562
  var helpCommand = defineCommand({
1482
- meta: { name: "help", description: "print the channel protocol doc (no token needed)" },
1563
+ meta: { name: "help", description: "print the channel protocol doc" },
1483
1564
  args: { ...common },
1484
1565
  run: async ({ args }) => {
1485
1566
  console.log(await channelClient(resolveBaseUrl(args.url)).help());
@@ -1489,12 +1570,11 @@ var membersCommand = defineCommand({
1489
1570
  meta: { name: "members", description: "list the channel's online roster" },
1490
1571
  args: {
1491
1572
  channel: { type: "positional", required: true, description: "channel id" },
1492
- token: { type: "string", required: true, description: "channel token" },
1493
1573
  ...common
1494
1574
  },
1495
1575
  run: async ({ args }) => {
1496
1576
  const url = resolveBaseUrl(args.url);
1497
- const members = await channelClient(url).members(args.channel, args.token);
1577
+ const members = await channelClient(url).members(args.channel);
1498
1578
  console.log(args.json ? toJson(members) : renderRoster(members));
1499
1579
  }
1500
1580
  });
@@ -1502,7 +1582,6 @@ var updateCommand = defineCommand({
1502
1582
  meta: { name: "update", description: "update a channel's title / description (topic + rules)" },
1503
1583
  args: {
1504
1584
  channel: { type: "positional", required: true, description: "channel id" },
1505
- token: { type: "string", required: true, description: "channel token" },
1506
1585
  title: { type: "string", description: "new title" },
1507
1586
  desc: { type: "string", description: "new description (purpose / topic / rules)" },
1508
1587
  ...common
@@ -1511,7 +1590,7 @@ var updateCommand = defineCommand({
1511
1590
  if (args.title === void 0 && args.desc === void 0)
1512
1591
  throw new Error("nothing to update: pass --title and/or --desc");
1513
1592
  const url = resolveBaseUrl(args.url);
1514
- const ch = await channelClient(url).update(args.channel, args.token, {
1593
+ const ch = await channelClient(url).update(args.channel, {
1515
1594
  title: args.title,
1516
1595
  description: args.desc
1517
1596
  });
@@ -1520,7 +1599,7 @@ var updateCommand = defineCommand({
1520
1599
  });
1521
1600
 
1522
1601
  // src/commands/channel/listen.ts
1523
- import { mkdirSync, writeFileSync as writeFileSync2 } from "node:fs";
1602
+ import { mkdirSync, writeFileSync as writeFileSync3 } from "node:fs";
1524
1603
  import { tmpdir } from "node:os";
1525
1604
  import { join as join2 } from "node:path";
1526
1605
 
@@ -1552,6 +1631,12 @@ var unstartedUserMessages = (events) => {
1552
1631
  const started = startedMessageIds(events);
1553
1632
  return events.filter((e) => e.type === "user_message" && !started.has(e.id));
1554
1633
  };
1634
+ var opensTurn = (e) => e.type === "user_message" || e.type === "turn_start";
1635
+ var closesTurn = (e) => e.type === "turn_complete" || e.type === "turn_error";
1636
+ var isAgentWorking = (events) => {
1637
+ const last = events.findLast((e) => opensTurn(e) || closesTurn(e));
1638
+ return last ? opensTurn(last) : false;
1639
+ };
1555
1640
 
1556
1641
  // src/commands/channel/listen.ts
1557
1642
  var PREVIEW = 280;
@@ -1569,7 +1654,7 @@ var runListen = async (o) => {
1569
1654
  const surface = (m) => {
1570
1655
  const text = typeof m.text === "string" ? m.text : "";
1571
1656
  const file = join2(spillDir, `${m.seq}.txt`);
1572
- writeFileSync2(file, text);
1657
+ writeFileSync3(file, text);
1573
1658
  const truncated = text.length > PREVIEW;
1574
1659
  emit({
1575
1660
  type: "channel.message",
@@ -1582,10 +1667,17 @@ var runListen = async (o) => {
1582
1667
  chars: text.length,
1583
1668
  truncated,
1584
1669
  preview: truncated ? `${text.slice(0, PREVIEW)} \u2026` : text,
1670
+ // BASE-prefixed urls so the subscribing agent can fetch directly.
1671
+ attachments: m.attachments?.length ? m.attachments.map((a) => ({
1672
+ filename: a.filename,
1673
+ contentType: a.contentType,
1674
+ size: a.size,
1675
+ url: `${o.url}${a.url}`
1676
+ })) : void 0,
1585
1677
  file
1586
1678
  });
1587
1679
  };
1588
- const close = channelClient(o.url).listen(o.channel, o.token, {
1680
+ const close = channelClient(o.url).listen(o.channel, {
1589
1681
  since: o.since,
1590
1682
  for: o.for,
1591
1683
  as: o.from,
@@ -1612,8 +1704,10 @@ var listenCommand = defineCommand({
1612
1704
  },
1613
1705
  args: {
1614
1706
  channel: { type: "positional", required: true, description: "channel id" },
1615
- token: { type: "string", required: true, description: "channel token" },
1616
- from: { type: "string", description: "your name; your own messages skipped, presence kept fresh" },
1707
+ from: {
1708
+ type: "string",
1709
+ description: "your name; your own messages skipped, presence kept fresh"
1710
+ },
1617
1711
  since: { type: "string", description: "replay messages after this seq (default 0 = full)" },
1618
1712
  for: { type: "string", description: "only broadcasts + messages addressed to this name" },
1619
1713
  ...common
@@ -1622,7 +1716,6 @@ var listenCommand = defineCommand({
1622
1716
  await runListen({
1623
1717
  url: resolveBaseUrl(args.url),
1624
1718
  channel: args.channel,
1625
- token: args.token,
1626
1719
  from: args.from ?? "",
1627
1720
  since: args.since ? Number(args.since) : 0,
1628
1721
  for: args.for
@@ -1632,30 +1725,35 @@ var listenCommand = defineCommand({
1632
1725
 
1633
1726
  // src/commands/channel/join.ts
1634
1727
  var joinCommand = defineCommand({
1635
- meta: { name: "join", description: "join a channel (register presence); --listen to also stream" },
1728
+ meta: {
1729
+ name: "join",
1730
+ description: "join a channel (register presence); --listen to also stream"
1731
+ },
1636
1732
  args: {
1637
1733
  channel: { type: "positional", required: true, description: "channel id" },
1638
- token: { type: "string", required: true, description: "channel token" },
1639
1734
  from: { type: "string", required: true, description: "your participant name" },
1640
1735
  kind: { type: "string", description: "your kind: agent (default) | human" },
1641
- listen: { type: "boolean", description: "after joining, stream messages (background subscriber)" },
1736
+ listen: {
1737
+ type: "boolean",
1738
+ description: "after joining, stream messages (background subscriber)"
1739
+ },
1642
1740
  since: { type: "string", description: "with --listen: replay after this seq (default 0)" },
1643
1741
  ...common
1644
1742
  },
1645
1743
  run: async ({ args }) => {
1646
1744
  const url = resolveBaseUrl(args.url);
1647
1745
  const kind = args.kind === "human" ? "human" : "agent";
1648
- const members = await channelClient(url).join(args.channel, args.token, args.from, kind);
1746
+ const members = await channelClient(url).join(args.channel, args.from, kind);
1649
1747
  if (!args.listen) {
1650
1748
  if (args.json) console.log(toJson({ joined: args.from, members }));
1651
- else console.log(`joined as ${args.from} (${members.length} online)
1749
+ else
1750
+ console.log(`joined as ${args.from} (${members.length} online)
1652
1751
  ${renderRoster(members)}`);
1653
1752
  return;
1654
1753
  }
1655
1754
  await runListen({
1656
1755
  url,
1657
1756
  channel: args.channel,
1658
- token: args.token,
1659
1757
  from: args.from,
1660
1758
  since: args.since ? Number(args.since) : 0
1661
1759
  });
@@ -1663,11 +1761,16 @@ ${renderRoster(members)}`);
1663
1761
  });
1664
1762
 
1665
1763
  // src/commands/channel/read.ts
1764
+ var fmtMessage = (m, base) => {
1765
+ const head = `${m.seq} ${m.from} ${m.text}`;
1766
+ const atts = (m.attachments ?? []).map((a) => `
1767
+ \u{1F4CE} ${a.filename} \u2192 ${base}${a.url}`).join("");
1768
+ return head + atts;
1769
+ };
1666
1770
  var readCommand = defineCommand({
1667
1771
  meta: { name: "read", description: "poll channel messages once (curl-style, since a cursor)" },
1668
1772
  args: {
1669
1773
  channel: { type: "positional", required: true, description: "channel id" },
1670
- token: { type: "string", required: true, description: "channel token" },
1671
1774
  since: { type: "string", description: "only messages after this seq (default 0)" },
1672
1775
  from: { type: "string", description: "skip messages from this name (no echo)" },
1673
1776
  for: { type: "string", description: "only broadcasts + messages addressed to this name" },
@@ -1675,40 +1778,99 @@ var readCommand = defineCommand({
1675
1778
  },
1676
1779
  run: async ({ args }) => {
1677
1780
  const url = resolveBaseUrl(args.url);
1678
- const msgs = await channelClient(url).read(args.channel, args.token, {
1781
+ const msgs = await channelClient(url).read(args.channel, {
1679
1782
  since: args.since ? Number(args.since) : 0,
1680
1783
  for: args.for
1681
1784
  });
1682
1785
  const out = args.from ? msgs.filter((m) => m.from !== args.from) : msgs;
1683
1786
  if (args.json) console.log(toJson(out));
1684
- else console.log(out.length ? out.map((m) => `${m.seq} ${m.from} ${m.text}`).join("\n") : "(none)");
1787
+ else console.log(out.length ? out.map((m) => fmtMessage(m, url)).join("\n") : "(none)");
1685
1788
  }
1686
1789
  });
1687
1790
 
1688
1791
  // src/commands/channel/send.ts
1689
- import { readFileSync as readFileSync2 } from "node:fs";
1792
+ import { openAsBlob as openAsBlob2, readFileSync as readFileSync2 } from "node:fs";
1793
+ import { basename as basename2 } from "node:path";
1794
+
1795
+ // src/mime.ts
1796
+ import { extname } from "node:path";
1797
+ var BY_EXT = {
1798
+ ".png": "image/png",
1799
+ ".jpg": "image/jpeg",
1800
+ ".jpeg": "image/jpeg",
1801
+ ".gif": "image/gif",
1802
+ ".webp": "image/webp",
1803
+ ".svg": "image/svg+xml",
1804
+ ".pdf": "application/pdf",
1805
+ ".txt": "text/plain",
1806
+ ".md": "text/markdown",
1807
+ ".json": "application/json",
1808
+ ".csv": "text/csv"
1809
+ };
1810
+ var contentTypeForPath = (path) => BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
1811
+
1812
+ // src/commands/attach.ts
1813
+ import { openAsBlob } from "node:fs";
1814
+ import { basename } from "node:path";
1815
+ var attachPaths = (raw) => (raw === void 0 ? [] : Array.isArray(raw) ? raw : [raw]).flatMap((p) => p.split(",")).map((p) => p.trim()).filter(Boolean);
1816
+ var uploadAttachments = async (client, sessionId, paths) => {
1817
+ const out = [];
1818
+ for (const path of paths) {
1819
+ const body = await openAsBlob(path, { type: contentTypeForPath(path) });
1820
+ out.push(
1821
+ await client.sessions.uploadAttachment(sessionId, {
1822
+ filename: basename(path),
1823
+ contentType: contentTypeForPath(path),
1824
+ body
1825
+ })
1826
+ );
1827
+ }
1828
+ return out;
1829
+ };
1830
+
1831
+ // src/commands/channel/send.ts
1690
1832
  var sendCommand = defineCommand({
1691
1833
  meta: { name: "send", description: "post one message to a channel" },
1692
1834
  args: {
1693
1835
  channel: { type: "positional", required: true, description: "channel id" },
1694
1836
  text: { type: "positional", required: false, description: "message text (short one-liner)" },
1695
- token: { type: "string", required: true, description: "channel token" },
1696
1837
  from: { type: "string", description: "your participant name (default: peer)" },
1697
- to: { type: "string", description: "direct it at these names (comma-separated); omit = broadcast" },
1838
+ to: {
1839
+ type: "string",
1840
+ description: "direct it at these names (comma-separated); omit = broadcast"
1841
+ },
1698
1842
  file: { type: "string", description: "read message body from this file (for large content)" },
1843
+ attach: {
1844
+ type: "string",
1845
+ description: "attach file(s) (comma-separated paths); uploaded + carried on the message"
1846
+ },
1699
1847
  ...common
1700
1848
  },
1701
1849
  run: async ({ args }) => {
1702
1850
  const url = resolveBaseUrl(args.url);
1851
+ const client = channelClient(url);
1703
1852
  let text = args.text;
1704
1853
  if (text === void 0 && args.file) text = readFileSync2(args.file, "utf8");
1705
1854
  if (text === void 0 && !process.stdin.isTTY) text = await readStdin();
1706
- if (text === void 0 || text === "")
1707
- throw new Error("nothing to send: provide text, --file <path>, or pipe stdin");
1708
- const msg = await channelClient(url).send(args.channel, args.token, {
1855
+ const attachments = [];
1856
+ for (const path of attachPaths(args.attach)) {
1857
+ attachments.push(
1858
+ await client.uploadAttachment(args.channel, {
1859
+ filename: basename2(path),
1860
+ contentType: contentTypeForPath(path),
1861
+ body: await openAsBlob2(path, { type: contentTypeForPath(path) })
1862
+ })
1863
+ );
1864
+ }
1865
+ if (!text && attachments.length === 0)
1866
+ throw new Error(
1867
+ "nothing to send: provide text, --file <path>, --attach <file>, or pipe stdin"
1868
+ );
1869
+ const msg = await client.send(args.channel, {
1709
1870
  from: args.from ?? "peer",
1710
- text,
1711
- to: splitCsv(args.to)
1871
+ text: text ?? "",
1872
+ to: splitCsv(args.to),
1873
+ attachments: attachments.length ? attachments : void 0
1712
1874
  });
1713
1875
  if (args.json) console.log(toJson(msg));
1714
1876
  else console.log(`sent (seq ${msg.seq})`);
@@ -1719,7 +1881,7 @@ var sendCommand = defineCommand({
1719
1881
  var channel = defineCommand({
1720
1882
  meta: {
1721
1883
  name: "channel",
1722
- description: "multi-agent chat room: create / about / update / help / join / members / send / read / listen / close"
1884
+ description: "multi-agent chat room: create / about / update / help / join / members / send / read / listen / download / close"
1723
1885
  },
1724
1886
  subCommands: {
1725
1887
  create: createCommand,
@@ -1731,6 +1893,7 @@ var channel = defineCommand({
1731
1893
  send: sendCommand,
1732
1894
  read: readCommand,
1733
1895
  listen: listenCommand,
1896
+ download: downloadCommand,
1734
1897
  close: closeCommand
1735
1898
  }
1736
1899
  });
@@ -1874,7 +2037,7 @@ var project = defineCommand({
1874
2037
  });
1875
2038
 
1876
2039
  // src/commands/relay.ts
1877
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
2040
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1878
2041
  import { tmpdir as tmpdir2 } from "node:os";
1879
2042
  import { join as join3 } from "node:path";
1880
2043
 
@@ -2009,7 +2172,7 @@ var listenCommand2 = defineCommand({
2009
2172
  const surface = (m) => {
2010
2173
  const text = typeof m.text === "string" ? m.text : "";
2011
2174
  const file = join3(spillDir, `${m.seq}.txt`);
2012
- writeFileSync3(file, text);
2175
+ writeFileSync4(file, text);
2013
2176
  const truncated = text.length > PREVIEW2;
2014
2177
  emit({
2015
2178
  type: "relay.message",
@@ -2506,6 +2669,7 @@ var toTurnResult = (message) => {
2506
2669
 
2507
2670
  // src/session/runner/turn.ts
2508
2671
  var turnTimeoutMs = () => Number(process.env.BATON_TURN_TIMEOUT_MS) || 30 * 6e4;
2672
+ var heartbeatMs = () => Number(process.env.BATON_TURN_HEARTBEAT_MS) || 3e4;
2509
2673
  var TIMEOUT = /* @__PURE__ */ Symbol("turn-timeout");
2510
2674
  var ABORTED = /* @__PURE__ */ Symbol("turn-aborted");
2511
2675
  var finalizeTurn = async (result, worker2, log) => {
@@ -2558,6 +2722,7 @@ var runTurn = async (config, worker2, msg, resuming, queryFn, log = (m) => conso
2558
2722
  const abort = new AbortController();
2559
2723
  const ceiling = turnTimeoutMs();
2560
2724
  let timer;
2725
+ let heartbeat;
2561
2726
  const timeout = new Promise((resolve2) => {
2562
2727
  timer = setTimeout(() => resolve2(TIMEOUT), ceiling);
2563
2728
  });
@@ -2566,6 +2731,9 @@ var runTurn = async (config, worker2, msg, resuming, queryFn, log = (m) => conso
2566
2731
  else externalSignal?.addEventListener("abort", () => resolve2(ABORTED), { once: true });
2567
2732
  });
2568
2733
  try {
2734
+ heartbeat = setInterval(() => {
2735
+ void worker2.emitEvent("turn_heartbeat", {}).catch((e) => log(`[heartbeat] ${String(e)}`));
2736
+ }, heartbeatMs());
2569
2737
  const messages = startQuery(config, text, resuming, queryFn, abort, log, {
2570
2738
  envOverlay,
2571
2739
  planMode,
@@ -2600,6 +2768,7 @@ var runTurn = async (config, worker2, msg, resuming, queryFn, log = (m) => conso
2600
2768
  return -1;
2601
2769
  } finally {
2602
2770
  clearTimeout(timer);
2771
+ clearInterval(heartbeat);
2603
2772
  }
2604
2773
  };
2605
2774
 
@@ -2680,10 +2849,17 @@ var runDaemon = async (config, deps, signal) => {
2680
2849
  busy = false;
2681
2850
  }
2682
2851
  };
2852
+ const closeOrphanedTurn = async (reason) => {
2853
+ const events = await deps.worker.listEvents();
2854
+ if (!isAgentWorking(events)) return;
2855
+ await deps.worker.emitEvent("turn_error", { message: reason, synthetic: true });
2856
+ log(`closed orphaned open turn (${reason})`);
2857
+ };
2683
2858
  const reconcile = async () => {
2684
2859
  reconciling = true;
2685
2860
  try {
2686
- const pending = unstartedUserMessages(await deps.worker.listEvents());
2861
+ const events = await deps.worker.listEvents();
2862
+ const pending = unstartedUserMessages(events);
2687
2863
  let added = 0;
2688
2864
  for (const ev of pending) {
2689
2865
  if (state.seen.has(ev.sequence)) continue;
@@ -2695,6 +2871,12 @@ var runDaemon = async (config, deps, signal) => {
2695
2871
  log(`reconciled ${added} queued message(s) from transcript`);
2696
2872
  lastActivity = Date.now();
2697
2873
  void drain();
2874
+ } else if (pendingQueue.length === 0 && !busy && !currentTurn && isAgentWorking(events)) {
2875
+ await deps.worker.emitEvent("turn_error", {
2876
+ message: "turn abandoned \u2014 runner restarted",
2877
+ synthetic: true
2878
+ });
2879
+ log("reconcile: closed orphaned open turn from a prior child");
2698
2880
  }
2699
2881
  } catch (e) {
2700
2882
  log(`reconcile failed: ${String(e)}`);
@@ -2719,6 +2901,11 @@ var runDaemon = async (config, deps, signal) => {
2719
2901
  if (currentTurn) {
2720
2902
  log("interrupt \u2014 aborting current turn");
2721
2903
  currentTurn.abort();
2904
+ } else {
2905
+ log("interrupt \u2014 no live turn; closing any orphaned open turn");
2906
+ void closeOrphanedTurn("interrupted by user").catch(
2907
+ (e) => log(`orphan close failed: ${String(e)}`)
2908
+ );
2722
2909
  }
2723
2910
  },
2724
2911
  // Report active only now that we're subscribed — before this, a message
@@ -2811,44 +2998,6 @@ var sessionRunCommand = defineCommand({
2811
2998
  }
2812
2999
  });
2813
3000
 
2814
- // src/commands/attach.ts
2815
- import { openAsBlob } from "node:fs";
2816
- import { basename } from "node:path";
2817
-
2818
- // src/mime.ts
2819
- import { extname } from "node:path";
2820
- var BY_EXT = {
2821
- ".png": "image/png",
2822
- ".jpg": "image/jpeg",
2823
- ".jpeg": "image/jpeg",
2824
- ".gif": "image/gif",
2825
- ".webp": "image/webp",
2826
- ".svg": "image/svg+xml",
2827
- ".pdf": "application/pdf",
2828
- ".txt": "text/plain",
2829
- ".md": "text/markdown",
2830
- ".json": "application/json",
2831
- ".csv": "text/csv"
2832
- };
2833
- var contentTypeForPath = (path) => BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
2834
-
2835
- // src/commands/attach.ts
2836
- var attachPaths = (raw) => (raw === void 0 ? [] : Array.isArray(raw) ? raw : [raw]).flatMap((p) => p.split(",")).map((p) => p.trim()).filter(Boolean);
2837
- var uploadAttachments = async (client, sessionId, paths) => {
2838
- const out = [];
2839
- for (const path of paths) {
2840
- const body = await openAsBlob(path, { type: contentTypeForPath(path) });
2841
- out.push(
2842
- await client.sessions.uploadAttachment(sessionId, {
2843
- filename: basename(path),
2844
- contentType: contentTypeForPath(path),
2845
- body
2846
- })
2847
- );
2848
- }
2849
- return out;
2850
- };
2851
-
2852
3001
  // src/commands/session/send.ts
2853
3002
  var sessionSendCommand = defineCommand({
2854
3003
  meta: { name: "send", description: "post a chat message into a session (no streaming reply)" },
@@ -3229,6 +3378,17 @@ var removeWorktree = (repo, worktreePath) => {
3229
3378
  } catch {
3230
3379
  }
3231
3380
  };
3381
+ var restoreWorktree = (repo, worktreePath, sessionCode) => {
3382
+ spawnSync2("git", ["-C", repo, "worktree", "prune"], { stdio: "pipe" });
3383
+ mkdirSync3(dirname(worktreePath), { recursive: true });
3384
+ const branch = `baton/${sessionCode}`;
3385
+ const reuse = spawnSync2("git", ["-C", repo, "worktree", "add", worktreePath, branch], {
3386
+ stdio: "pipe",
3387
+ encoding: "utf8"
3388
+ });
3389
+ if (reuse.status === 0) return;
3390
+ createWorktree({ repo, worktreePath, sessionCode, base: repoHeadBranch(repo) });
3391
+ };
3232
3392
 
3233
3393
  // src/worker/watchdog.ts
3234
3394
  var stale = (okAt, now, thresholdMs) => now - okAt > thresholdMs;
@@ -3316,6 +3476,9 @@ var runWorkerDaemon = async (cfg, client, signal) => {
3316
3476
  });
3317
3477
  await client.sessions.materialize(sessionId, { agentSessionId, worktreePath }, cfg.apiToken);
3318
3478
  log(`materialized session #${sessionId} \u2192 ${worktreePath}`);
3479
+ } else if (!existsSync5(worktreePath)) {
3480
+ restoreWorktree(repo, worktreePath, session2.agentSessionId.slice(0, 8));
3481
+ log(`recreated session #${sessionId} worktree (was missing) \u2192 ${worktreePath}`);
3319
3482
  }
3320
3483
  ensureExcluded(repo, PROJECT_CONFIG_NAME);
3321
3484
  saveProjectConfig(join7(worktreePath, PROJECT_CONFIG_NAME), worktreeConfig(cfg));
@@ -3421,7 +3584,7 @@ var runWorkerDaemon = async (cfg, client, signal) => {
3421
3584
 
3422
3585
  // src/worker/machine-id.ts
3423
3586
  import { randomUUID as randomUUID2 } from "node:crypto";
3424
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
3587
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
3425
3588
  import { homedir as homedir3 } from "node:os";
3426
3589
  import { dirname as dirname3, join as join8 } from "node:path";
3427
3590
  var machineIdPath = (env = process.env) => join8(env.XDG_DATA_HOME ?? join8(env.HOME ?? homedir3(), ".local/share"), "baton", "machine-id");
@@ -3432,7 +3595,7 @@ var readOrCreateMachineId = (path = machineIdPath()) => {
3432
3595
  }
3433
3596
  const id = randomUUID2();
3434
3597
  mkdirSync4(dirname3(path), { recursive: true });
3435
- writeFileSync4(path, `${id}
3598
+ writeFileSync5(path, `${id}
3436
3599
  `, "utf8");
3437
3600
  return id;
3438
3601
  };
@@ -3492,7 +3655,10 @@ var worker = defineCommand({
3492
3655
  "no baton server \u2014 pass --url <url> (e.g. https://baton.fmap.dev/api), set BATON_URL, or run `baton init`"
3493
3656
  );
3494
3657
  if (!args.json) console.log(`registering against ${server}`);
3495
- const c = createClient(server);
3658
+ const cfgPath = configPathFromArgs(args);
3659
+ const fileToken = loadProjectConfigOrNull(cfgPath)?.worker?.apiToken;
3660
+ const auth = resolveAuth(process.env, fileToken);
3661
+ const c = createClient(server, auth && auth !== "cookie" ? auth : void 0);
3496
3662
  const hostname = osHostname();
3497
3663
  const machineId = readOrCreateMachineId();
3498
3664
  const name = args.name ?? hostname;
@@ -3505,7 +3671,7 @@ var worker = defineCommand({
3505
3671
  hostname,
3506
3672
  machineId
3507
3673
  },
3508
- configPathFromArgs(args)
3674
+ cfgPath
3509
3675
  );
3510
3676
  if (args.json) {
3511
3677
  console.log(toJson({ ...out, configPath, machineIdPath: machineIdPath() }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lesscap/baton-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "baton CLI — register/run a worker, manage projects/requirements/tasks/sessions",