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 +4 -0
- package/dist/adapters/claudeCode.js +19 -1
- package/dist/adapters/codex.js +18 -0
- package/dist/bin/cli.js +108 -2
- package/dist/core/codexChat.js +6 -2
- package/dist/core/format.js +2 -1
- package/dist/core/github.js +25 -7
- package/dist/core/log.js +69 -0
- package/dist/core/model.js +18 -0
- package/dist/core/relay.js +474 -0
- package/dist/core/remoteChat.js +212 -0
- package/dist/core/store.js +8 -44
- package/dist/core/sync.js +2 -0
- package/dist/relay/server.js +234 -0
- package/package.json +2 -2
- package/skills/parawatch/SKILL.md +1 -0
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
|
-
|
|
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,
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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")
|
package/dist/core/codexChat.js
CHANGED
|
@@ -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) {
|
package/dist/core/format.js
CHANGED
|
@@ -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.
|
|
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) : "";
|
package/dist/core/github.js
CHANGED
|
@@ -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
|
-
/**
|
|
352
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
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));
|
package/dist/core/log.js
ADDED
|
@@ -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
|