agent-yes 1.204.0 → 1.206.0
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 +2 -0
- package/dist/SUPPORTED_CLIS-CQ6e94ln.js +9 -0
- package/dist/{SUPPORTED_CLIS-DqsPHFU9.js → SUPPORTED_CLIS-DQSgURGW.js} +2 -2
- package/dist/{agentShare-TJXTia4N.js → agentShare-k8224qiZ.js} +3 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-DAD9Dt9H.js → notifyDaemon-BbVnNvlo.js} +2 -2
- package/dist/{rustBinary-DMx8ljD0.js → rustBinary-BjRvvEs8.js} +2 -2
- package/dist/{schedule-DGxbss_D.js → schedule-DJ-v4yVa.js} +4 -4
- package/dist/{serve-eqRnhz8Z.js → serve-crLypY_c.js} +328 -42
- package/dist/{setup-BgD5RFXj.js → setup-BPNkeSmY.js} +2 -2
- package/dist/{share-Ciw1mWVN.js → share-_QYjPN7q.js} +1 -1
- package/dist/{share-BfIU8t_h.js → share-vchIyVd8.js} +98 -5
- package/dist/{subcommands-3GIzND1f.js → subcommands-DCGZjr4t.js} +1 -1
- package/dist/{subcommands-B7NvTmdk.js → subcommands-DkrMRVNh.js} +8 -8
- package/dist/{ts-D9LzuGpU.js → ts-DnqRreF4.js} +2 -2
- package/dist/{versionChecker-C8ScoMqV.js → versionChecker-d5c6_vgS.js} +2 -2
- package/dist/{ws-BfqEcKyO.js → ws-BmHpWaG_.js} +2 -2
- package/lab/ui/console-logic.js +3 -1
- package/lab/ui/index.html +39 -5
- package/package.json +1 -1
- package/ts/serve.spec.ts +13 -1
- package/ts/serve.ts +200 -29
- package/ts/serveLock.spec.ts +219 -0
- package/ts/serveLock.ts +172 -0
- package/ts/share.ts +80 -1
- package/ts/statusText.spec.ts +17 -0
- package/ts/statusText.ts +19 -0
- package/dist/SUPPORTED_CLIS-BUxPt2CD.js +0 -9
package/ts/serveLock.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile, rename } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
4
|
+
import { isTransientLockMkdirError } from "./notifyStore.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Singleton lock for the WebRTC host role: one `ay serve` host per
|
|
8
|
+
* ~/.agent-yes. Two hosts sharing the persisted room (~/.agent-yes/.share-room)
|
|
9
|
+
* fight over every viewer connection — the exact outage seen live: an orphaned
|
|
10
|
+
* `ay serve --webrtc` from a previous manager era plus a manual run left the
|
|
11
|
+
* managed daemon crash-looping (12 watchdog restarts) and the share link
|
|
12
|
+
* unloadable. The lock makes the loser fail FAST with a pointer to the owner
|
|
13
|
+
* instead of silently contending.
|
|
14
|
+
*
|
|
15
|
+
* mkdir-based (atomic on every platform), owner JSON alongside, heartbeat
|
|
16
|
+
* refresh while held. A stale owner — dead pid, or a heartbeat older than
|
|
17
|
+
* SERVE_LOCK_STALE_MS (a SIGKILLed host can't clean up) — is stolen, so a
|
|
18
|
+
* crashed host never wedges the next start.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const SERVE_LOCK_STALE_MS = 20_000;
|
|
22
|
+
export const SERVE_LOCK_BEAT_MS = 5_000;
|
|
23
|
+
// Riding out a manager roll-forward: stop-old/start-new overlap briefly, so a
|
|
24
|
+
// new host retries for a grace window before declaring the lock busy.
|
|
25
|
+
export const SERVE_LOCK_GRACE_MS = 12_000;
|
|
26
|
+
|
|
27
|
+
export type ServeLockOwner = { pid: number; started_at: number; beat_at: number };
|
|
28
|
+
|
|
29
|
+
export type ServeLockResult =
|
|
30
|
+
| { ok: true; release: () => Promise<void> }
|
|
31
|
+
| { ok: false; owner: ServeLockOwner | null };
|
|
32
|
+
|
|
33
|
+
function lockDir(): string {
|
|
34
|
+
return path.join(agentYesHome(), "webrtc-host.lock");
|
|
35
|
+
}
|
|
36
|
+
function ownerPath(): string {
|
|
37
|
+
return path.join(lockDir(), "owner.json");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pidAlive(pid: number): boolean {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// EPERM = alive but not ours; only ESRCH means gone.
|
|
46
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readOwner(): Promise<ServeLockOwner | null> {
|
|
51
|
+
try {
|
|
52
|
+
const o = JSON.parse(await readFile(ownerPath(), "utf-8")) as ServeLockOwner;
|
|
53
|
+
return typeof o?.pid === "number" ? o : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Pure staleness decision, exported for tests. */
|
|
60
|
+
export function isOwnerStale(
|
|
61
|
+
owner: ServeLockOwner | null,
|
|
62
|
+
now: number,
|
|
63
|
+
alive: (pid: number) => boolean,
|
|
64
|
+
staleMs = SERVE_LOCK_STALE_MS,
|
|
65
|
+
): boolean {
|
|
66
|
+
if (!owner) return true; // torn/absent owner: mkdir won but write died
|
|
67
|
+
if (!alive(owner.pid)) return true;
|
|
68
|
+
return now - owner.beat_at > staleMs;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Atomic owner stamp (temp + rename) so a concurrent reader never parses a
|
|
72
|
+
// torn file as "no owner" while we in fact hold the lock.
|
|
73
|
+
async function stampOwner(startedAt: number): Promise<boolean> {
|
|
74
|
+
const tmp = `${ownerPath()}.${process.pid}.tmp`;
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(
|
|
77
|
+
tmp,
|
|
78
|
+
JSON.stringify({ pid: process.pid, started_at: startedAt, beat_at: Date.now() }),
|
|
79
|
+
);
|
|
80
|
+
await rename(tmp, ownerPath());
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Acquire the host lock, retrying for up to `graceMs`. Returns fast on a live
|
|
90
|
+
* owner once the grace expires (never blocks a daemon boot forever). With
|
|
91
|
+
* `takeover`, a live owner is SIGTERM'd (then SIGKILL'd) and the lock stolen.
|
|
92
|
+
*/
|
|
93
|
+
export async function acquireWebrtcHostLock(opts?: {
|
|
94
|
+
takeover?: boolean;
|
|
95
|
+
graceMs?: number;
|
|
96
|
+
staleMs?: number;
|
|
97
|
+
/** Heartbeat cadence override — tests only. */
|
|
98
|
+
beatMs?: number;
|
|
99
|
+
/** SIGTERM→SIGKILL escalation wait for --takeover — tests only. */
|
|
100
|
+
takeoverWaitMs?: number;
|
|
101
|
+
}): Promise<ServeLockResult> {
|
|
102
|
+
const graceMs = opts?.graceMs ?? SERVE_LOCK_GRACE_MS;
|
|
103
|
+
const staleMs = opts?.staleMs ?? SERVE_LOCK_STALE_MS;
|
|
104
|
+
const beatMs = opts?.beatMs ?? SERVE_LOCK_BEAT_MS;
|
|
105
|
+
const startedAt = Date.now();
|
|
106
|
+
const deadline = startedAt + graceMs;
|
|
107
|
+
let tookOver = false;
|
|
108
|
+
for (;;) {
|
|
109
|
+
try {
|
|
110
|
+
await mkdir(lockDir(), { recursive: false });
|
|
111
|
+
if (!(await stampOwner(startedAt))) {
|
|
112
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
113
|
+
return { ok: false, owner: null };
|
|
114
|
+
}
|
|
115
|
+
// Heartbeat while held; stops itself if a thief replaced our owner file.
|
|
116
|
+
const beat = setInterval(() => {
|
|
117
|
+
void (async () => {
|
|
118
|
+
const o = await readOwner();
|
|
119
|
+
if (o?.pid !== process.pid) {
|
|
120
|
+
clearInterval(beat);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await stampOwner(startedAt);
|
|
124
|
+
})();
|
|
125
|
+
}, beatMs);
|
|
126
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
release: async () => {
|
|
130
|
+
clearInterval(beat);
|
|
131
|
+
if ((await readOwner())?.pid === process.pid)
|
|
132
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
137
|
+
if (!isTransientLockMkdirError(code)) throw e;
|
|
138
|
+
if (code !== "EEXIST") {
|
|
139
|
+
// win32 mkdir-vs-rm race: dir mid-delete, just retry.
|
|
140
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const owner = await readOwner();
|
|
144
|
+
if (isOwnerStale(owner, Date.now(), pidAlive, staleMs)) {
|
|
145
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
146
|
+
continue; // re-prove via mkdir — two stealers can't both win
|
|
147
|
+
}
|
|
148
|
+
if (opts?.takeover && owner && !tookOver) {
|
|
149
|
+
tookOver = true; // one shot: never kill a second, newer owner
|
|
150
|
+
try {
|
|
151
|
+
process.kill(owner.pid, "SIGTERM");
|
|
152
|
+
} catch {
|
|
153
|
+
/* already gone */
|
|
154
|
+
}
|
|
155
|
+
// Give it a moment to shut down cleanly (it releases the lock), then
|
|
156
|
+
// escalate; the loop's stale check mops up whatever remains.
|
|
157
|
+
await new Promise((r) => setTimeout(r, opts?.takeoverWaitMs ?? 2_000));
|
|
158
|
+
if (pidAlive(owner.pid)) {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(owner.pid, "SIGKILL");
|
|
161
|
+
} catch {
|
|
162
|
+
/* gone */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
await rm(lockDir(), { recursive: true, force: true }).catch(() => {});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (Date.now() >= deadline) return { ok: false, owner };
|
|
169
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/ts/share.ts
CHANGED
|
@@ -66,10 +66,28 @@ const MAX_PEER_JOIN_QUEUE = 50;
|
|
|
66
66
|
const IDLE_RESTART_UPTIME_MS = 25 * 60_000;
|
|
67
67
|
const HARD_RESTART_UPTIME_MS = 45 * 60_000;
|
|
68
68
|
const IDLE_RESTART_CHECK_MS = 60_000;
|
|
69
|
+
const PERF_SLOW_MS = Number(process.env.AGENT_YES_WEBRTC_PERF_SLOW_MS) || 750;
|
|
70
|
+
const PERF_BUFFERED_BYTES = Number(process.env.AGENT_YES_WEBRTC_PERF_BUFFERED_BYTES) || 1_000_000;
|
|
71
|
+
const PERF_VERBOSE = process.env.AGENT_YES_WEBRTC_PERF === "1";
|
|
69
72
|
|
|
70
73
|
type IceServer = { urls: string | string[]; username?: string; credential?: string };
|
|
71
74
|
const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
|
|
72
75
|
|
|
76
|
+
function perfLog(event: string, data: Record<string, unknown>, force = false) {
|
|
77
|
+
if (!force && !PERF_VERBOSE) return;
|
|
78
|
+
process.stderr.write(`[share:perf] ${JSON.stringify({ t: Date.now(), event, ...data })}\n`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function maybeSlow(event: string, startedAt: number, data: Record<string, unknown> = {}) {
|
|
82
|
+
const ms = Math.round(performance.now() - startedAt);
|
|
83
|
+
perfLog(event, { ms, ...data }, ms >= PERF_SLOW_MS);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dcBufferedAmount(dc: any): number {
|
|
87
|
+
const n = Number(dc?.bufferedAmount ?? 0);
|
|
88
|
+
return Number.isFinite(n) ? n : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
// Short-lived Cloudflare TURN credentials, minted from a long-term TURN key, so
|
|
74
92
|
// browsers can RELAY when a direct P2P path is impossible (symmetric NAT /
|
|
75
93
|
// CGNAT — the main cause of "rooms offline"). Set CF_TURN_KEY_ID +
|
|
@@ -428,6 +446,7 @@ export async function startShare(
|
|
|
428
446
|
if (closed) return; // a reconnect timer queued before close() must not revive it
|
|
429
447
|
const ws = new WebSocket(`${wsScheme}://${host}/${room}`, [SUB]);
|
|
430
448
|
currentWs = ws;
|
|
449
|
+
const signalStartedAt = performance.now();
|
|
431
450
|
let ready = false;
|
|
432
451
|
let lastRecv = Date.now();
|
|
433
452
|
let hb: ReturnType<typeof setInterval> | undefined;
|
|
@@ -443,6 +462,7 @@ export async function startShare(
|
|
|
443
462
|
}
|
|
444
463
|
};
|
|
445
464
|
ws.onopen = () => {
|
|
465
|
+
maybeSlow("signal.open", signalStartedAt, { room, host });
|
|
446
466
|
ws.send(JSON.stringify({ type: "hello", role: "host", v: 2, token: authToken }));
|
|
447
467
|
ready = true;
|
|
448
468
|
lastRecv = Date.now();
|
|
@@ -480,6 +500,12 @@ export async function startShare(
|
|
|
480
500
|
const m = JSON.parse(ev.data as string);
|
|
481
501
|
if (m.type === "pong") return; // heartbeat ack — liveness already recorded
|
|
482
502
|
if (m.type === "peer-join") {
|
|
503
|
+
perfLog("peer.join", {
|
|
504
|
+
room,
|
|
505
|
+
peer: String(m.peer),
|
|
506
|
+
queue: peerJoinQueue.length,
|
|
507
|
+
peers: peers.size,
|
|
508
|
+
});
|
|
483
509
|
// Serialized in drainPeerJoins() to avoid a storm. Skip dupes (already
|
|
484
510
|
// queued or already an active peer) and cap the queue so a pathological
|
|
485
511
|
// burst can't grow it unboundedly — dropped joins retry via the browser.
|
|
@@ -554,6 +580,7 @@ export async function startShare(
|
|
|
554
580
|
};
|
|
555
581
|
|
|
556
582
|
async function startPeer(ws: WebSocket, peerId: string) {
|
|
583
|
+
const startedAt = performance.now();
|
|
557
584
|
const iceServers = await getIceServers();
|
|
558
585
|
const pc = new RTCPeerConnection({ iceServers });
|
|
559
586
|
let resolveKeys!: () => void;
|
|
@@ -584,6 +611,11 @@ export async function startShare(
|
|
|
584
611
|
dc.binaryType = "arraybuffer";
|
|
585
612
|
dc.onopen = async () => {
|
|
586
613
|
try {
|
|
614
|
+
maybeSlow("peer.dc_open", startedAt, {
|
|
615
|
+
room,
|
|
616
|
+
peer: peerId,
|
|
617
|
+
buffered: dcBufferedAmount(dc),
|
|
618
|
+
});
|
|
587
619
|
await peer.keysReady; // keys derived in the answer handler
|
|
588
620
|
// Open the mandatory bidirectional key-confirmation handshake. Nothing
|
|
589
621
|
// the peer sends is acted on until BOTH directions confirm (see onFrame).
|
|
@@ -624,6 +656,7 @@ export async function startShare(
|
|
|
624
656
|
ws.send(
|
|
625
657
|
JSON.stringify({ type: "offer", to: peerId, sdp: pc.localDescription.sdp, iceServers }),
|
|
626
658
|
);
|
|
659
|
+
maybeSlow("peer.offer", startedAt, { room, peer: peerId, iceServers: iceServers.length });
|
|
627
660
|
}
|
|
628
661
|
|
|
629
662
|
function closePeer(peerId: string) {
|
|
@@ -642,8 +675,10 @@ export async function startShare(
|
|
|
642
675
|
// Seal an envelope and send it, serialized per peer so the wire order matches
|
|
643
676
|
// the nonce-counter order (so the receiver's monotonic check never trips).
|
|
644
677
|
function enqueueSeal(peerId: string, dc: any, peer: Peer, flags: number, obj: object) {
|
|
678
|
+
const queuedAt = performance.now();
|
|
645
679
|
peer.sendChain = peer.sendChain.then(async () => {
|
|
646
680
|
if (dc.readyState !== "open" || !peer.keyH2C || !peer.th) return;
|
|
681
|
+
const queuedMs = Math.round(performance.now() - queuedAt);
|
|
647
682
|
let frame: ArrayBuffer;
|
|
648
683
|
try {
|
|
649
684
|
frame = await e2eSeal(peer.keyH2C, peer.send, flags, peer.th, packEnvelope(obj));
|
|
@@ -653,6 +688,19 @@ export async function startShare(
|
|
|
653
688
|
}
|
|
654
689
|
try {
|
|
655
690
|
dc.send(frame);
|
|
691
|
+
const buffered = dcBufferedAmount(dc);
|
|
692
|
+
perfLog(
|
|
693
|
+
"send",
|
|
694
|
+
{
|
|
695
|
+
room,
|
|
696
|
+
peer: peerId,
|
|
697
|
+
kind: (obj as any)?.t,
|
|
698
|
+
queuedMs,
|
|
699
|
+
buffered,
|
|
700
|
+
bytes: frame.byteLength,
|
|
701
|
+
},
|
|
702
|
+
queuedMs >= PERF_SLOW_MS || buffered >= PERF_BUFFERED_BYTES,
|
|
703
|
+
);
|
|
656
704
|
} catch {
|
|
657
705
|
/* peer vanished mid-send; dropping the frame is correct */
|
|
658
706
|
}
|
|
@@ -703,6 +751,8 @@ export async function startShare(
|
|
|
703
751
|
}
|
|
704
752
|
if (req.t !== "req") return;
|
|
705
753
|
const { id, method, path: p, body } = req;
|
|
754
|
+
const startedAt = performance.now();
|
|
755
|
+
perfLog("req.start", { room, peer: peerId, id, method, path: p });
|
|
706
756
|
const ac = new AbortController();
|
|
707
757
|
peer.aborts.set(id, ac);
|
|
708
758
|
try {
|
|
@@ -718,6 +768,14 @@ export async function startShare(
|
|
|
718
768
|
signal: ac.signal,
|
|
719
769
|
}),
|
|
720
770
|
);
|
|
771
|
+
maybeSlow("req.head", startedAt, {
|
|
772
|
+
room,
|
|
773
|
+
peer: peerId,
|
|
774
|
+
id,
|
|
775
|
+
method,
|
|
776
|
+
path: p,
|
|
777
|
+
status: res.status,
|
|
778
|
+
});
|
|
721
779
|
enqueueSeal(peerId, dc, peer, 0, {
|
|
722
780
|
t: "res",
|
|
723
781
|
id,
|
|
@@ -727,9 +785,11 @@ export async function startShare(
|
|
|
727
785
|
const reader = res.body!.getReader();
|
|
728
786
|
const dec = new TextDecoder();
|
|
729
787
|
let seq = 0;
|
|
788
|
+
let bytes = 0;
|
|
730
789
|
for (;;) {
|
|
731
790
|
const { done, value } = await reader.read();
|
|
732
791
|
if (done) break;
|
|
792
|
+
bytes += value.byteLength;
|
|
733
793
|
const text = dec.decode(value, { stream: true });
|
|
734
794
|
// Slice on UTF-16 boundaries: JSON round-trips lone surrogates as \uXXXX,
|
|
735
795
|
// so the receiver reassembles the exact text by concatenating in seq order.
|
|
@@ -742,13 +802,32 @@ export async function startShare(
|
|
|
742
802
|
});
|
|
743
803
|
}
|
|
744
804
|
enqueueSeal(peerId, dc, peer, 0, { t: "end", id, seq });
|
|
805
|
+
maybeSlow("req.end", startedAt, {
|
|
806
|
+
room,
|
|
807
|
+
peer: peerId,
|
|
808
|
+
id,
|
|
809
|
+
method,
|
|
810
|
+
path: p,
|
|
811
|
+
status: res.status,
|
|
812
|
+
chunks: seq,
|
|
813
|
+
bytes,
|
|
814
|
+
});
|
|
745
815
|
} catch (e) {
|
|
746
|
-
if ((e as Error).name !== "AbortError")
|
|
816
|
+
if ((e as Error).name !== "AbortError") {
|
|
817
|
+
maybeSlow("req.error", startedAt, {
|
|
818
|
+
room,
|
|
819
|
+
peer: peerId,
|
|
820
|
+
id,
|
|
821
|
+
method,
|
|
822
|
+
path: p,
|
|
823
|
+
error: String((e as Error).message ?? e),
|
|
824
|
+
});
|
|
747
825
|
enqueueSeal(peerId, dc, peer, 0, {
|
|
748
826
|
t: "end",
|
|
749
827
|
id,
|
|
750
828
|
error: String((e as Error).message ?? e),
|
|
751
829
|
});
|
|
830
|
+
}
|
|
752
831
|
} finally {
|
|
753
832
|
peer.aborts.delete(id);
|
|
754
833
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseStatusText } from "./statusText.ts";
|
|
3
|
+
|
|
4
|
+
describe("parseStatusText", () => {
|
|
5
|
+
it("returns the latest Claude spinner/status line", () => {
|
|
6
|
+
expect(
|
|
7
|
+
parseStatusText([
|
|
8
|
+
"older output",
|
|
9
|
+
"✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)",
|
|
10
|
+
]),
|
|
11
|
+
).toBe("✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("ignores non-status terminal prose", () => {
|
|
15
|
+
expect(parseStatusText(["hello", "Done", ""])).toBe(null);
|
|
16
|
+
});
|
|
17
|
+
});
|
package/ts/statusText.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a short "what is the agent doing right now?" line from the rendered
|
|
3
|
+
* terminal screen. Claude Code paints this as a spinner/status line, e.g.
|
|
4
|
+
* "✶ Verifying calendar meetings with real data… (6m 30s · ↓ 19.5k tokens)".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const SPINNER_PREFIX = /^[\u2800-\u28ff✶✻✢✳✽✦✧✩✷✸✹✺✼·•●◐◓◒◑]\s+/u;
|
|
8
|
+
const CONTROL = /[\x00-\x1f\x7f-\x9f]/g;
|
|
9
|
+
|
|
10
|
+
export function parseStatusText(lines: string[]): string | null {
|
|
11
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
12
|
+
const line = lines[i]?.replace(CONTROL, "").trim();
|
|
13
|
+
if (!line || line.length < 3) continue;
|
|
14
|
+
if (!SPINNER_PREFIX.test(line)) continue;
|
|
15
|
+
if (/^(?:[•·]\s*)?(?:esc|ctrl|enter|return|shift|tab)\b/i.test(line)) continue;
|
|
16
|
+
return line.slice(0, 220).trim();
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import "./ts-D9LzuGpU.js";
|
|
2
|
-
import "./logger-CDIsZ-Pp.js";
|
|
3
|
-
import "./versionChecker-C8ScoMqV.js";
|
|
4
|
-
import "./pidStore-BIvsBQ8X.js";
|
|
5
|
-
import "./globalPidIndex-CoNr7tS8.js";
|
|
6
|
-
import "./messageLog-CxrKJj77.js";
|
|
7
|
-
import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DqsPHFU9.js";
|
|
8
|
-
|
|
9
|
-
export { SUPPORTED_CLIS };
|