parawatch 0.3.15 → 0.3.17

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/README.md CHANGED
@@ -46,6 +46,8 @@ parawatch sync setup ~/agent-sync/parawatch --device pc --adapter syncthing
46
46
 
47
47
  Cards, answers and the list of running sessions then show on every machine, each marked with where it came from. Agent logs stay on the machine that wrote them. The details are in [More than one machine](docs/sync.md).
48
48
 
49
+ Or run a [relay](docs/relay.md) of your own, a small server that carries the folder sealed end to end, so the server cannot read your cards or sessions: `parawatch relay setup <url> <folder>` on the first machine, `parawatch relay join <code> <folder>` on the others. With a relay, a phone can answer cards too: [On a phone](docs/phone.md).
50
+
49
51
  ## How an answer gets back
50
52
 
51
53
  <picture>
@@ -133,6 +135,8 @@ This writes a short block that tells the agents in that project to say what they
133
135
  | [Pull requests](docs/pull-requests.md) | What is read from GitHub, and its settings |
134
136
  | [Observed agents](docs/observed-agents.md) | Which logs are read, run states, adding a runtime |
135
137
  | [More than one machine](docs/sync.md) | Cards and answers across machines through a synced folder |
138
+ | [The relay](docs/relay.md) | Your own server that carries the shared folder, sealed end to end |
139
+ | [The shared folder](docs/protocol.md) | The files machines exchange, for anyone writing a client |
136
140
  | [Storage](docs/storage.md) | Where everything is kept, notifications |
137
141
  | [Installing, updating, removing](docs/install.md) | The three parts in detail, updates, `parawatch doctor`, uninstalling |
138
142
  | [Building on it](docs/integrations.md) | `status --json`, `watch`, the panel package |
@@ -239,7 +239,17 @@ export function readClaudeTail(recs, kind) {
239
239
  // the human wrote (a pasted block, a command's output): whatever the last wrap-up asked is answered
240
240
  wrapUp = undefined;
241
241
  }
242
+ if (str(r.permissionMode))
243
+ out.permissionMode = str(r.permissionMode);
242
244
  if (r.type === "assistant") {
245
+ const msg = isObj(r.message) ? r.message : {};
246
+ const model = str(msg.model);
247
+ // a message Claude Code makes up itself (an interrupt, an error) names no real model
248
+ if (model && !model.startsWith("<")) {
249
+ out.model = model;
250
+ out.effort = str(r.effort);
251
+ out.fast = isObj(msg.usage) && msg.usage.speed === "fast" ? true : undefined;
252
+ }
243
253
  const w = blocks(r).find((b) => b.type === "tool_use" && WRAP_UP.test(String(b.name)));
244
254
  if (w)
245
255
  wrapUp = { ...(w.input ?? {}), at: ts };
@@ -427,6 +437,12 @@ export const claudeCode = {
427
437
  return runs;
428
438
  },
429
439
  };
440
+ const PERMISSION_MODES = { acceptEdits: "accepts edits", plan: "plan mode", bypassPermissions: "bypasses permissions", dontAsk: "never asks", auto: "auto mode" };
441
+ /** The model, effort and options a Claude Code run's last answer ran with. */
442
+ function claudeSettings(t) {
443
+ const options = [t.fast ? "fast" : undefined, t.permissionMode ? PERMISSION_MODES[t.permissionMode] : undefined].filter((x) => !!x);
444
+ return { model: t.model, effort: t.effort, options: options.length ? options : undefined };
445
+ }
430
446
  function readSession(file, now) {
431
447
  const id = path.basename(file, ".jsonl");
432
448
  const base = { id, runtime: "claude-code", kind: "session", title: id.slice(0, 8), state: "unknown", source: file };
@@ -451,6 +467,7 @@ function readSession(file, now) {
451
467
  prompt: head.prompt ? oneLine(head.prompt, 400) : undefined,
452
468
  activity: logActivity(file, now),
453
469
  pendingTool: tail.pendingTool,
470
+ ...claudeSettings(tail),
454
471
  };
455
472
  applyStall(run, tail.pendingTimeoutMs, now, newestMtime(tail.pendingOutputs));
456
473
  // in the log a permission prompt and a long command look the same; the hook can tell them apart
@@ -477,7 +494,8 @@ function readSubagent(file, now) {
477
494
  touched: work.touched.length ? work.touched : undefined,
478
495
  createdPrs: work.createdPrs.length ? work.createdPrs : undefined,
479
496
  title: str(meta.description) || (head.prompt ? oneLine(head.prompt, 60) : base.title),
480
- model: str(meta.model),
497
+ ...claudeSettings(tail),
498
+ model: tail.model ?? str(meta.model),
481
499
  cwd: tail.cwd || head.cwd,
482
500
  startedAt: head.startedAt,
483
501
  lastActivity: tail.lastActivity,
@@ -69,6 +69,22 @@ export function promptFromRaw(raw) {
69
69
  }
70
70
  return undefined;
71
71
  }
72
+ const SANDBOXES = { "read-only": "read-only", "workspace-write": "writes in its folders", "danger-full-access": "full access" };
73
+ const APPROVALS = { never: "never asks", "on-request": "asks when it wants to", "on-failure": "asks on failure", untrusted: "asks before most commands" };
74
+ /** The model, effort and options of the last turn, from Codex's `turn_context` lines. */
75
+ export function codexSettings(lines) {
76
+ let tc;
77
+ for (const r of lines)
78
+ if (isObj(r) && r.type === "turn_context" && isObj(r.payload))
79
+ tc = r.payload;
80
+ if (!tc)
81
+ return {};
82
+ const sp = tc.sandbox_policy;
83
+ const sandbox = typeof sp === "string" ? sp : isObj(sp) ? str(sp.type) : undefined;
84
+ const approval = str(tc.approval_policy);
85
+ const options = [sandbox ? (SANDBOXES[sandbox] ?? sandbox) : undefined, approval ? (APPROVALS[approval] ?? approval) : undefined].filter((x) => !!x);
86
+ return { model: str(tc.model), effort: str(tc.effort) ?? str(tc.reasoning_effort) ?? (isObj(tc.collaboration_mode) && isObj(tc.collaboration_mode.settings) ? str(tc.collaboration_mode.settings.reasoning_effort) : undefined), options: options.length ? options : undefined };
87
+ }
72
88
  export function readCodexLog(head, tail, rawHead) {
73
89
  const meta = head.map(payload).find((p, i) => p && isObj(head[i]) && head[i].type === "session_meta");
74
90
  const id = str(meta?.id) ?? str(meta?.session_id) ?? "unknown";
@@ -98,6 +114,8 @@ export function readCodexLog(head, tail, rawHead) {
98
114
  startedAt: str(meta?.timestamp) ?? (isObj(head[0]) ? str(head[0].timestamp) : undefined),
99
115
  state: "working",
100
116
  prompt: prompt ? oneLine(prompt, 400) : undefined,
117
+ // the last turn's settings: a later turn may change them (`codex exec resume -m ...`)
118
+ ...codexSettings([...head, ...tail]),
101
119
  };
102
120
  let lastStart = -1;
103
121
  let lastDone = -1;
package/dist/bin/cli.js CHANGED
@@ -2,15 +2,17 @@
2
2
  import { execFileSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import { Command } from "commander";
5
- import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, dismissCard, endedSessions, handCard, handPr, leadOf, makeLead, commentCard, inboxScope, editCard, endRun, hideRun, unhideRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
5
+ import { UserError, addProject, answerCard, beatRun, claimSession, closeCard, dismissCard, endedSessions, handCard, handPr, leadOf, makeLead, commentCard, inboxScope, editCard, endRun, hideRun, unhideRun, linkPr, listeners, listenersFor, noteRun, openCard, prEvent, findCard, readInbox, readState, sameSession, removeProject, startListening, startRun, unlinkPr, waitInbox, } from "../core/ops.js";
6
6
  import { githubConfig, readCache, resolvePrRef, syncGithub, prViews } from "../core/github.js";
7
7
  import { recordPrompt } from "../core/prompts.js";
8
8
  import { callingSubagent } from "../adapters/claudeCode.js";
9
9
  import { digest, digestText, localDate } from "../core/digest.js";
10
- import { everywhere, readEvents } from "../core/store.js";
10
+ import { everywhere, readConfig, readEvents, sharedDir, writeConfig } from "../core/store.js";
11
11
  import { followEvents } from "../core/follow.js";
12
12
  import { adapterNames, setRoot, setupSync, syncAdapter, syncStatus, touchDevice } from "../core/sync.js";
13
13
  import { syncText } from "../core/format.js";
14
+ import { RelayClient, RelayMirror, joinCode, newKey, readJoinCode, relayConfig, takeMirrorLock } from "../core/relay.js";
15
+ import { createRelayServer } from "../relay/server.js";
14
16
  import { collectRuns, shareRunsDue } from "../core/runs.js";
15
17
  import { askRows, cardDetail, cardLine, cardRows, inboxLine, prRows, runsTree, view } from "../core/format.js";
16
18
  import { hookContext, notify, removeInstructions, writeInstructions } from "../core/integrate.js";
@@ -372,10 +374,17 @@ program
372
374
  id = callingSubagent(id, /\bparawatch\b.*\bclaim\b/) ?? id;
373
375
  if (o.kind !== undefined && o.kind !== "scheduled")
374
376
  throw new UserError("--kind can only be 'scheduled'");
377
+ // a lead that says done stops leading: say so, since it may only have finished one task
378
+ const before = o.done?.trim() ? readState() : undefined;
379
+ const ledBefore = before ? leadOf(before, endedSessions(before))(o.project?.trim() || before.claims.find((x) => sameSession(x.session, id))?.project) : undefined;
375
380
  const c = claimSession({ session: id, project: o.project, task: o.task, role: o.role, title: o.title, kind: o.kind, done: o.done });
376
381
  // a new lead: earlier leads of the project step down, and it takes what no session got
377
382
  const handed = o.role === "lead" && !c.done ? makeLead(c.project, id).handed : [];
378
383
  process.stdout.write(`${id.slice(0, 8)} → ${c.project}${c.task ? ` / ${c.task}` : ""}${c.role ? ` (${c.role})` : ""}${c.done ? " · done" : ""}${handed.length ? `; handed ${handed.join(", ")}` : ""}\n`);
384
+ if (c.done && ledBefore && sameSession(ledBefore, id)) {
385
+ process.stdout.write(`This session no longer leads ${c.project}: what no session owns there now waits in parawatch until a session claims --role lead.\n` +
386
+ `If it goes on working, claim its next work instead of --done: parawatch claim -p ${c.project} -t <task> --role lead\n`);
387
+ }
379
388
  });
380
389
  // ------------------------------------------------------------------ pull requests
381
390
  const pr = program.command("pr").description("pull requests and what they wait on (reads GitHub through gh)");
@@ -648,6 +657,103 @@ sync
648
657
  const d = setRoot(name, folder, VERSION);
649
658
  process.stdout.write(Object.entries(d.roots ?? {}).map(([k, v]) => `${k} ${v}`).join("\n") + (d.roots ? "\n" : "No named folders.\n"));
650
659
  });
660
+ // ------------------------------------------------------------------ the relay
661
+ const relay = program.command("relay").description("keep the shared folder in step through a parawatch relay server instead of a sync tool");
662
+ /** Point this machine's shared folder at a relay, keeping what config.json already has. */
663
+ function useRelay(folder, device, c) {
664
+ const r = setupSync({ dir: folder, device, adapter: "relay", version: VERSION });
665
+ const cfg = readConfig();
666
+ writeConfig({ ...cfg, sync: { ...(cfg.sync ?? {}), adapter: "relay", relay: c } });
667
+ return r;
668
+ }
669
+ relay
670
+ .command("setup <url> <folder>")
671
+ .description("make a space on a relay and share this machine's folder through it; prints the code for your other machines")
672
+ .option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
673
+ .option("--invite <invite>", "the relay's invite (or set PARAWATCH_RELAY_INVITE)")
674
+ .action(async (url, folder, o) => {
675
+ const invite = o.invite ?? process.env.PARAWATCH_RELAY_INVITE;
676
+ if (!invite)
677
+ throw new UserError("the relay needs its invite to make a space: pass --invite or set PARAWATCH_RELAY_INVITE");
678
+ const base = url.replace(/\/+$/, "");
679
+ const made = await RelayClient.createSpace(base, invite);
680
+ const c = { url: base, space: made.space, token: made.token, key: newKey() };
681
+ const r = useRelay(folder, o.device, c);
682
+ process.stdout.write([
683
+ `This machine shares ${r.dir} as "${r.device}" through ${base}.`,
684
+ "On each of your other machines, run:",
685
+ "",
686
+ ` parawatch relay join <code> <folder> --device <its name>`,
687
+ "",
688
+ "with this code. It opens your space and its key: keep it to your own machines.",
689
+ "",
690
+ joinCode(c),
691
+ "",
692
+ "The folder is kept in step while the app runs, or while `parawatch relay run` does.",
693
+ ].join("\n") + "\n");
694
+ });
695
+ relay
696
+ .command("join <code> <folder>")
697
+ .description("share this machine's folder through the relay another machine set up")
698
+ .option("-d, --device <name>", "this machine's name in the folder (a-z, 0-9, -); defaults to the host name")
699
+ .action(async (code, folder, o) => {
700
+ const c = readJoinCode(code);
701
+ await new RelayClient(c).changes(0);
702
+ const r = useRelay(folder, o.device, c);
703
+ process.stdout.write(`This machine shares ${r.dir} as "${r.device}" through ${c.url}. The folder is kept in step while the app runs, or while \`parawatch relay run\` does.\n`);
704
+ });
705
+ relay
706
+ .command("code")
707
+ .description("print the code that lets another of your machines join (it holds the space key)")
708
+ .action(() => {
709
+ const c = relayConfig();
710
+ if (!c)
711
+ throw new UserError("this machine does not use a relay");
712
+ process.stdout.write(joinCode(c) + "\n");
713
+ });
714
+ relay
715
+ .command("run")
716
+ .description("keep the shared folder in step with the relay until stopped (the app does this while it runs)")
717
+ .option("--once", "bring it up to date once and stop")
718
+ .action(async (o) => {
719
+ const c = relayConfig();
720
+ const dir = sharedDir();
721
+ if (!c || !dir)
722
+ throw new UserError("this machine does not use a relay: run `parawatch relay setup` or `parawatch relay join` first");
723
+ const unlock = takeMirrorLock();
724
+ if (!unlock)
725
+ throw new UserError("another parawatch (the app, or another `relay run`) already keeps this folder in step");
726
+ const m = new RelayMirror({ dir, config: c, log: (l) => process.stderr.write(l + "\n") });
727
+ try {
728
+ if (o.once) {
729
+ const r = await m.once();
730
+ process.stdout.write(`brought down ${r.pulled}, sent up ${r.pushed}\n`);
731
+ return;
732
+ }
733
+ const stop = () => m.stop();
734
+ process.once("SIGINT", stop);
735
+ process.once("SIGTERM", stop);
736
+ process.stderr.write(`keeping ${dir} in step with ${c.url}; stop with Ctrl-C\n`);
737
+ await m.run();
738
+ }
739
+ finally {
740
+ unlock();
741
+ }
742
+ });
743
+ relay
744
+ .command("serve")
745
+ .description("run a relay server (for your own server; machines reach it over HTTPS through a proxy in front)")
746
+ .option("--port <n>", "port to listen on", "8787")
747
+ .option("--host <host>", "address to listen on", "127.0.0.1")
748
+ .option("--data <folder>", "where spaces are kept", "~/.parawatch-relay")
749
+ .action((o) => {
750
+ const invite = process.env.PARAWATCH_RELAY_INVITE;
751
+ const data = o.data.startsWith("~") ? o.data.replace(/^~/, process.env.HOME ?? process.env.USERPROFILE ?? "") : o.data;
752
+ const server = createRelayServer({ dataDir: data, invite, log: (l) => process.stdout.write(`${new Date().toISOString()} ${l}\n`) });
753
+ server.listen(Number(o.port), o.host, () => {
754
+ process.stdout.write(`relay on http://${o.host}:${o.port}, spaces in ${data}${invite ? "" : " (no PARAWATCH_RELAY_INVITE: no new spaces can be made)"}\n`);
755
+ });
756
+ });
651
757
  program
652
758
  .command("hook <runtime>")
653
759
  .description("hook entry point; 'claude-code' reads the hook JSON on stdin")
@@ -132,6 +132,7 @@ export function readChatLines(head, tail, file = "") {
132
132
  const items = new Map();
133
133
  let started = -1;
134
134
  let ended = -1;
135
+ let turnAt;
135
136
  let structured = false;
136
137
  const fallback = [];
137
138
  tail.forEach((r, i) => {
@@ -148,8 +149,10 @@ export function readChatLines(head, tail, file = "") {
148
149
  return;
149
150
  }
150
151
  if (r.type === "event_msg") {
151
- if (p.type === "task_started")
152
+ if (p.type === "task_started") {
152
153
  started = i;
154
+ turnAt = at;
155
+ }
153
156
  else if (p.type === "task_complete")
154
157
  ended = i;
155
158
  else if (p.type === "turn_aborted") {
@@ -186,6 +189,7 @@ export function readChatLines(head, tail, file = "") {
186
189
  ...(firstUser ? { title: firstUser.text.split("\n")[0].slice(0, 80) } : {}),
187
190
  settings,
188
191
  working: started > ended,
192
+ ...(started > ended && turnAt ? { turnStarted: turnAt } : {}),
189
193
  items: all.slice(-MAX_ITEMS),
190
194
  earlier: Math.max(0, all.length - MAX_ITEMS),
191
195
  };
@@ -230,7 +234,7 @@ export function findCodex(env = process.env, platform = process.platform) {
230
234
  const dirs = [
231
235
  ...(env.PATH ?? "").split(path.delimiter),
232
236
  ...(platform === "win32"
233
- ? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
237
+ ? [env.APPDATA && path.join(env.APPDATA, "npm"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "OpenAI", "Codex", "bin"), env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "codex")]
234
238
  : ["/opt/homebrew/bin", "/usr/local/bin", path.join(home, ".local", "bin"), path.join(home, ".npm-global", "bin"), path.join(home, ".volta", "bin"), path.join(home, ".bun", "bin")]),
235
239
  ].filter((d) => !!d);
236
240
  for (const d of dirs) {
@@ -1,3 +1,4 @@
1
+ import { settingsLine } from "./model.js";
1
2
  export function ago(iso, now = Date.now()) {
2
3
  if (!iso)
3
4
  return "?";
@@ -374,7 +375,7 @@ export function runsTree(runs, v = PLAIN, now = Date.now(), indent = "") {
374
375
  const age = paint(v, ago(r.lastActivity, now).padStart(4), "dim");
375
376
  const dev = devW ? " " + paint(v, pad(fit(r.device ?? "", devW), devW), "cyan") : "";
376
377
  const src = (r.projectSource && SOURCE_NOTE[r.projectSource]) || "";
377
- const wide = v.wide ? " " + paint(v, pad(fit(`${r.kind === "subagent" ? r.model ?? "subagent" : r.runtime} ${r.id.slice(0, 8)}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
378
+ const wide = v.wide ? " " + paint(v, pad(fit(`${r.runtime} ${r.id.slice(0, 8)}${settingsLine(r) ? " " + settingsLine(r) : ""}${src ? " " + src : ""}`, wideW - 2), wideW - 2), "dim") : "";
378
379
  const restW = v.width - nameW - 2 - 4 - devCol - wideW - 2;
379
380
  const whatPaint = r.error || r.state === "stalled" || r.state === "failed" || r.asks?.blocked ? ["red"] : r.asks ? ["yellow"] : ended ? ["dim"] : [];
380
381
  const what = restW >= 8 ? " " + paint(v, fit(whatText(r), restW), ...whatPaint) : "";
@@ -320,6 +320,11 @@ export function waitingOn(p) {
320
320
  return { what: "no reviewer", who: author };
321
321
  return { what: "merge", who: author };
322
322
  }
323
+ /** A reading of a PR from before anything happened to it: open, CI running, its reviews and comments as now. */
324
+ function unseen(p) {
325
+ // a PR already finished reports only that, not the CI it had
326
+ return { ...p, state: "open", ci: p.state === "open" ? "PENDING" : p.ci, mergeable: p.state === "open" ? undefined : p.mergeable, queue: p.queue };
327
+ }
323
328
  const REVIEW_CHANGE = { APPROVED: "approved", CHANGES_REQUESTED: "changes_requested", COMMENTED: "review", DISMISSED: "review" };
324
329
  /** The events between two readings of the same PR. */
325
330
  export function changesBetween(old, now) {
@@ -348,13 +353,18 @@ export function changesBetween(old, now) {
348
353
  out.push({ change: "closed" });
349
354
  return out;
350
355
  }
351
- /** Next cache and the events it implies, given the previous cache and a fresh reading. */
352
- export function nextCache(prev, fresh, hosts, at) {
356
+ /**
357
+ * Next cache and the events it implies, given the previous cache and a fresh reading. `newlyLinked`
358
+ * names PRs a session linked since the previous reading: one first seen with its CI already done,
359
+ * or already merged or closed, reports that, since no earlier reading can show it changing (a CI
360
+ * of a minute ends before the first reading).
361
+ */
362
+ export function nextCache(prev, fresh, hosts, at, newlyLinked = new Set()) {
353
363
  const old = new Map(prev.prs.map((p) => [p.key, p]));
354
364
  const events = [];
355
365
  const since = {};
356
366
  for (const p of fresh) {
357
- const was = old.get(p.key);
367
+ const was = old.get(p.key) ?? (newlyLinked.has(p.key) ? unseen(p) : undefined);
358
368
  if (was) {
359
369
  for (const c of changesBetween(was, p)) {
360
370
  events.push({ type: "pr.change", pr: prRef(p.host, p.repo, p.number, p.url), change: c.change, title: p.title, by: c.by, source: "github" });
@@ -460,10 +470,14 @@ export async function syncGithub(opts) {
460
470
  });
461
471
  }
462
472
  failedHere = 0;
463
- // PRs linked to sessions, and ones seen before, are read by number so merges and closes show
473
+ // PRs linked to sessions, and ones seen before, are read by number so merges and closes show.
474
+ // The newest links first, leaving out the ones already seen finished: the number read is capped.
464
475
  const extra = new Map();
465
- for (const l of opts.state.prLinks.values())
466
- extra.set(l.pr.key, l.pr);
476
+ const finished = new Set(prev.finished ?? []);
477
+ const links = [...opts.state.prLinks.values()].reverse().sort((a, b) => b.at.localeCompare(a.at));
478
+ for (const l of links)
479
+ if (!finished.has(l.pr.key))
480
+ extra.set(l.pr.key, l.pr);
467
481
  const cutoff = new Date(Date.now() - cfg.maxAgeDays * 86_400_000).toISOString();
468
482
  for (const p of prev.prs)
469
483
  if (p.state === "open" && p.updatedAt >= cutoff)
@@ -489,7 +503,11 @@ export async function syncGithub(opts) {
489
503
  const latest = readCache();
490
504
  if (latest.fetchedAt && latest.fetchedAt > started)
491
505
  return latest; // someone else was faster
492
- const { cache, events } = nextCache(latest, fresh, hosts, started);
506
+ // a first reading of everything reports nothing; after that, links made since the last reading are new
507
+ const newly = new Set(latest.fetchedAt ? links.filter((l) => l.at > latest.fetchedAt).map((l) => l.pr.key) : []);
508
+ const { cache, events } = nextCache(latest, fresh, hosts, started, newly);
509
+ const done = new Set([...(latest.finished ?? []), ...fresh.filter((p) => p.state !== "open").map((p) => p.key)]);
510
+ cache.finished = [...done].filter((k) => opts.state.prLinks.has(k));
493
511
  if (errors.length)
494
512
  cache.error = errors.join("; ");
495
513
  appendLocked((log) => dropReported(events, log, latest.fetchedAt));
@@ -0,0 +1,69 @@
1
+ /** Merge per-device logs, each already in its own order, into one. */
2
+ export function mergeLogs(lists) {
3
+ const at = lists.map(() => 0);
4
+ const out = [];
5
+ for (;;) {
6
+ let pick = -1;
7
+ for (let i = 0; i < lists.length; i++) {
8
+ const ev = lists[i][at[i]];
9
+ if (!ev)
10
+ continue;
11
+ if (pick < 0) {
12
+ pick = i;
13
+ continue;
14
+ }
15
+ const best = lists[pick][at[pick]];
16
+ if (ev.ts < best.ts || (ev.ts === best.ts && (ev.dev ?? "") < (best.dev ?? "")))
17
+ pick = i;
18
+ }
19
+ if (pick < 0)
20
+ return out;
21
+ out.push(lists[pick][at[pick]++]);
22
+ }
23
+ }
24
+ export function parseEventLines(text, device) {
25
+ const out = [];
26
+ for (const line of text.split("\n")) {
27
+ if (!line.trim())
28
+ continue;
29
+ try {
30
+ const ev = JSON.parse(line);
31
+ if (typeof ev.seq !== "number" || typeof ev.type !== "string")
32
+ continue;
33
+ if (device)
34
+ ev.dev = device;
35
+ out.push(ev);
36
+ }
37
+ catch {
38
+ // a torn or hand-edited line; keep going
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+ /** The card an event is about, if any. */
44
+ function cardOf(e) {
45
+ if (e.type === "card.open")
46
+ return e.card.id;
47
+ const c = e.card;
48
+ return typeof c === "string" ? c : undefined;
49
+ }
50
+ /**
51
+ * When a writer may stamp its next event: now, but never before its own last event (each log
52
+ * stays in order) and, for an event about a card, after everything already said about that card.
53
+ * Logs merge by time and then by machine name, so an answer stamped in the same millisecond as the
54
+ * card on another machine, or by a clock running behind, would sort before the card and be lost.
55
+ */
56
+ export function stampFor(events, own, card, now) {
57
+ let t = now;
58
+ for (const e of events) {
59
+ const at = Date.parse(e.ts);
60
+ if (!Number.isFinite(at))
61
+ continue;
62
+ if (own(e) && at > t)
63
+ t = at;
64
+ if (typeof card === "string" && cardOf(e) === card && at >= t)
65
+ t = at + 1;
66
+ }
67
+ return t;
68
+ }
69
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1,18 @@
1
+ const CLAUDE = /^claude-([a-z]+)-(\d+)-(\d+)(?:-\d{8})?(\[1m\])?$/;
2
+ export function shortModel(model) {
3
+ const m = CLAUDE.exec(model);
4
+ if (m)
5
+ return `${m[1]} ${m[2]}.${m[3]}${m[4] ? " 1M" : ""}`;
6
+ return model.replace(/-\d{8}$/, "");
7
+ }
8
+ /** The model and effort in a few words, `opus 5.5 high`; fast mode too, as it changes the speed. */
9
+ export function settingsLine(r) {
10
+ const parts = [r.model ? shortModel(r.model) : undefined, r.effort, r.options?.includes("fast") ? "fast" : undefined].filter(Boolean);
11
+ return parts.length ? parts.join(" ") : undefined;
12
+ }
13
+ /** Everything it runs with, for a tooltip or a detail line: `claude-opus-5-5 · effort high · bypasses permissions`. */
14
+ export function settingsFull(r) {
15
+ const parts = [r.model, r.effort ? `effort ${r.effort}` : undefined, ...(r.options ?? [])].filter(Boolean);
16
+ return parts.length ? parts.join(" · ") : undefined;
17
+ }
18
+ //# sourceMappingURL=model.js.map