@roamcode.ai/server 1.0.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/LICENSE +21 -0
- package/dist/auth.d.ts +63 -0
- package/dist/auth.js +133 -0
- package/dist/claude-auth-service.d.ts +76 -0
- package/dist/claude-auth-service.js +217 -0
- package/dist/claude-latest-service.d.ts +34 -0
- package/dist/claude-latest-service.js +61 -0
- package/dist/config.d.ts +78 -0
- package/dist/config.js +59 -0
- package/dist/data-dir.d.ts +42 -0
- package/dist/data-dir.js +70 -0
- package/dist/diag.d.ts +43 -0
- package/dist/diag.js +83 -0
- package/dist/fs-service.d.ts +90 -0
- package/dist/fs-service.js +290 -0
- package/dist/index.d.ts +82 -0
- package/dist/index.js +44 -0
- package/dist/managed-runtime.d.ts +51 -0
- package/dist/managed-runtime.js +411 -0
- package/dist/managed-update-helper.d.ts +2 -0
- package/dist/managed-update-helper.js +34 -0
- package/dist/mcp-send.d.ts +33 -0
- package/dist/mcp-send.js +107 -0
- package/dist/origin-check.d.ts +37 -0
- package/dist/origin-check.js +101 -0
- package/dist/pane-status.d.ts +61 -0
- package/dist/pane-status.js +145 -0
- package/dist/providers/claude-metadata-service.d.ts +58 -0
- package/dist/providers/claude-metadata-service.js +352 -0
- package/dist/providers/claude-provider.d.ts +11 -0
- package/dist/providers/claude-provider.js +166 -0
- package/dist/providers/codex-activity.d.ts +21 -0
- package/dist/providers/codex-activity.js +122 -0
- package/dist/providers/codex-app-server-client.d.ts +90 -0
- package/dist/providers/codex-app-server-client.js +485 -0
- package/dist/providers/codex-latest-service.d.ts +50 -0
- package/dist/providers/codex-latest-service.js +174 -0
- package/dist/providers/codex-metadata-service.d.ts +161 -0
- package/dist/providers/codex-metadata-service.js +686 -0
- package/dist/providers/codex-profile-client.d.ts +16 -0
- package/dist/providers/codex-profile-client.js +52 -0
- package/dist/providers/codex-profile-security.d.ts +23 -0
- package/dist/providers/codex-profile-security.js +161 -0
- package/dist/providers/codex-provider.d.ts +15 -0
- package/dist/providers/codex-provider.js +174 -0
- package/dist/providers/codex-thread-coordinator.d.ts +18 -0
- package/dist/providers/codex-thread-coordinator.js +93 -0
- package/dist/providers/codex-thread-persistence.d.ts +9 -0
- package/dist/providers/codex-thread-persistence.js +45 -0
- package/dist/providers/codex-thread-resolver.d.ts +59 -0
- package/dist/providers/codex-thread-resolver.js +322 -0
- package/dist/providers/options.d.ts +7 -0
- package/dist/providers/options.js +155 -0
- package/dist/providers/provider-artifacts.d.ts +3 -0
- package/dist/providers/provider-artifacts.js +30 -0
- package/dist/providers/registry.d.ts +7 -0
- package/dist/providers/registry.js +23 -0
- package/dist/providers/types.d.ts +95 -0
- package/dist/providers/types.js +8 -0
- package/dist/push-dispatch.d.ts +81 -0
- package/dist/push-dispatch.js +100 -0
- package/dist/push-store.d.ts +25 -0
- package/dist/push-store.js +79 -0
- package/dist/rate-limit.d.ts +52 -0
- package/dist/rate-limit.js +72 -0
- package/dist/server-config.d.ts +60 -0
- package/dist/server-config.js +77 -0
- package/dist/service-install.d.ts +60 -0
- package/dist/service-install.js +221 -0
- package/dist/session-defaults.d.ts +26 -0
- package/dist/session-defaults.js +60 -0
- package/dist/session-store.d.ts +81 -0
- package/dist/session-store.js +654 -0
- package/dist/start.d.ts +31 -0
- package/dist/start.js +372 -0
- package/dist/static-routes.d.ts +101 -0
- package/dist/static-routes.js +188 -0
- package/dist/terminal-capability.d.ts +5 -0
- package/dist/terminal-capability.js +27 -0
- package/dist/terminal-manager.d.ts +224 -0
- package/dist/terminal-manager.js +917 -0
- package/dist/terminal-process.d.ts +85 -0
- package/dist/terminal-process.js +238 -0
- package/dist/terminal-shared.d.ts +36 -0
- package/dist/terminal-shared.js +43 -0
- package/dist/tmux-list.d.ts +11 -0
- package/dist/tmux-list.js +39 -0
- package/dist/transport.d.ts +123 -0
- package/dist/transport.js +1559 -0
- package/dist/updater.d.ts +161 -0
- package/dist/updater.js +451 -0
- package/dist/usage-service.d.ts +118 -0
- package/dist/usage-service.js +173 -0
- package/dist/vapid.d.ts +17 -0
- package/dist/vapid.js +31 -0
- package/dist/web-push-send.d.ts +20 -0
- package/dist/web-push-send.js +21 -0
- package/dist/ws-ticket.d.ts +47 -0
- package/dist/ws-ticket.js +62 -0
- package/package.json +55 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin / CSWSH (cross-site WebSocket hijacking) guard for the WS upgrade and state-changing HTTP.
|
|
3
|
+
*
|
|
4
|
+
* The token is the only auth boundary, and it can leak into a URL (`?token=` connect link, push deep
|
|
5
|
+
* link, browser history). The WS handshake (and any browser-driven HTTP) carries an `Origin` header the
|
|
6
|
+
* page CANNOT forge — so a malicious cross-origin page that somehow holds the token still can't make the
|
|
7
|
+
* BROWSER attach to our WS / drive our API, because the browser stamps its OWN origin on the request and
|
|
8
|
+
* we reject it. Without this check, a leaked-in-URL token is enough for a foreign page to puppet the host.
|
|
9
|
+
*
|
|
10
|
+
* SAFE DEFAULT (must never reject the real app): a request is allowed when
|
|
11
|
+
* - the `Origin` header is ABSENT (native/non-browser clients, same-origin navigations + most
|
|
12
|
+
* same-origin GETs omit it — the PWA's own fetches are same-origin), OR
|
|
13
|
+
* - the Origin's host:port equals the request `Host` (same-origin — the PWA always is), OR
|
|
14
|
+
* - the Origin matches the configured public URL (ROAMCODE_PUBLIC_URL), OR
|
|
15
|
+
* - the Origin is a loopback/localhost origin (local dev), OR
|
|
16
|
+
* - the Origin is in the explicit ROAMCODE_ALLOWED_ORIGINS allow-list.
|
|
17
|
+
* Only a PRESENT, cross-origin, non-allow-listed Origin is rejected (403).
|
|
18
|
+
*/
|
|
19
|
+
/** Normalize a URL/origin string to a comparable `scheme://host[:port]` origin (lowercased). Returns
|
|
20
|
+
* undefined for anything that isn't a parseable absolute URL (so it can never match by accident). */
|
|
21
|
+
export function normalizeOrigin(value) {
|
|
22
|
+
if (!value)
|
|
23
|
+
return undefined;
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
if (!trimmed || trimmed === "null")
|
|
26
|
+
return undefined; // a sandboxed/opaque origin serializes to "null"
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(trimmed);
|
|
29
|
+
// `URL.origin` is `scheme://host[:port]` with the default port elided — exactly the comparison we want.
|
|
30
|
+
return u.origin.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** True if a host (no scheme) is a loopback/localhost name. Accepts a bare host or host:port. */
|
|
37
|
+
function isLoopbackHost(host) {
|
|
38
|
+
const h = host.toLowerCase().replace(/:\d+$/, ""); // drop a :port suffix
|
|
39
|
+
if (h === "localhost" || h === "::1" || h === "[::1]")
|
|
40
|
+
return true;
|
|
41
|
+
return /^127(?:\.\d{1,3}){3}$/.test(h);
|
|
42
|
+
}
|
|
43
|
+
/** True if a normalized origin (`scheme://host[:port]`) is loopback. */
|
|
44
|
+
function isLoopbackOrigin(origin) {
|
|
45
|
+
try {
|
|
46
|
+
return isLoopbackHost(new URL(origin).host);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Decide whether a request's Origin is allowed. PURE / I/O-free so it is unit-testable; the transport
|
|
54
|
+
* preHandler passes `origin` (the header) and `host` (the request Host header).
|
|
55
|
+
*
|
|
56
|
+
* @param origin the request's `Origin` header (may be undefined)
|
|
57
|
+
* @param host the request's `Host` header (e.g. `remotecode.example.com` or `127.0.0.1:4280`)
|
|
58
|
+
*/
|
|
59
|
+
export function isOriginAllowed(origin, host, opts = {}) {
|
|
60
|
+
// Absent Origin → allow (native clients, same-origin navigations + most same-origin GETs omit it). A
|
|
61
|
+
// present-but-unparseable / opaque ("null") Origin normalizes to undefined and is treated the same way
|
|
62
|
+
// (it cannot be a real cross-origin browser page driving us — those send a concrete origin).
|
|
63
|
+
const reqOrigin = normalizeOrigin(origin);
|
|
64
|
+
if (reqOrigin === undefined)
|
|
65
|
+
return true;
|
|
66
|
+
// Same-origin: the Origin's host:port equals the request Host. The PWA (served same-origin) always is.
|
|
67
|
+
if (host) {
|
|
68
|
+
const sameOriginHost = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return new URL(reqOrigin).host.toLowerCase() === host.trim().toLowerCase();
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
if (sameOriginHost)
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
// Loopback origin (local dev directly against 127.0.0.1/localhost).
|
|
80
|
+
if (isLoopbackOrigin(reqOrigin))
|
|
81
|
+
return true;
|
|
82
|
+
// Configured public URL (the tunnel/user-facing origin the PWA is actually installed under).
|
|
83
|
+
const publicOrigin = normalizeOrigin(opts.publicUrl);
|
|
84
|
+
if (publicOrigin !== undefined && reqOrigin === publicOrigin)
|
|
85
|
+
return true;
|
|
86
|
+
// Explicit allow-list extension.
|
|
87
|
+
for (const extra of opts.allowedOrigins ?? []) {
|
|
88
|
+
if (normalizeOrigin(extra) === reqOrigin)
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/** Parse a comma-separated ROAMCODE_ALLOWED_ORIGINS value into a trimmed, non-empty list. */
|
|
94
|
+
export function parseAllowedOrigins(raw) {
|
|
95
|
+
if (!raw)
|
|
96
|
+
return [];
|
|
97
|
+
return raw
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((s) => s.trim())
|
|
100
|
+
.filter((s) => s.length > 0);
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export type PaneStatus = "working" | "blocked" | "idle";
|
|
2
|
+
/**
|
|
3
|
+
* The newest Claude Code MAJOR.MINOR the classifier's markers below were VERIFIED against on a live box.
|
|
4
|
+
* The whole classifier is tied to Claude Code's ENGLISH TUI strings — the load-bearing markers are:
|
|
5
|
+
* - "Do you want to proceed" / "Would you like to proceed" (blocked — permission/plan prompts)
|
|
6
|
+
* - the gerund ellipsis "…" + "↓ <n>k tokens" live counter (working — main spinner + live agents)
|
|
7
|
+
* - a "…(<n>s"-style parenthesised spinner timer (working — pre-token-flow window)
|
|
8
|
+
* - "Waiting for … to finish" / "esc to interrupt" (working)
|
|
9
|
+
* A NEWER claude may reword any of these and silently degrade every rail status to "idle", so boot logs a
|
|
10
|
+
* one-time warning (see start.ts) when the installed claude's MAJOR.MINOR exceeds this. Bump it after
|
|
11
|
+
* re-verifying the markers against a newer claude.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CLASSIFIER_TESTED_UP_TO = "2.1";
|
|
14
|
+
/**
|
|
15
|
+
* True iff `current`'s MAJOR.MINOR is strictly NEWER than `testedUpTo`'s. Patch versions are ignored — a
|
|
16
|
+
* patch release doesn't reword the TUI. Unparseable input (either side) → false, so a weird version string
|
|
17
|
+
* can never produce a spurious warning. Pure.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isNewerMajorMinor(current: string, testedUpTo: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* The one-line boot warning when the RUNNING claude is newer than the classifier was verified against —
|
|
22
|
+
* or undefined when there is nothing to warn about (older/equal/unknown version). Pure so it's testable;
|
|
23
|
+
* start.ts logs it once via console.warn and NEVER throws (a version bump must not affect boot).
|
|
24
|
+
*/
|
|
25
|
+
export declare function classifierVersionWarning(claudeVersion: string | undefined): string | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Classify a session's live ACTIVITY from its RENDERED tmux pane (`capture-pane -p` — the CURRENT screen, not
|
|
28
|
+
* scrollback). UNIVERSAL: works for any running session regardless of how claude was spawned (no per-session
|
|
29
|
+
* hooks needed), and works while the browser is DETACHED (it reads the tmux session directly). Grounded in
|
|
30
|
+
* Claude Code's real output — see pane-status.test.ts for captured samples.
|
|
31
|
+
*
|
|
32
|
+
* working → something is actively generating. The strongest tell is a LIVE token-flow counter "↓ 216.5k
|
|
33
|
+
* tokens" — it appears on the MAIN spinner ("Schlepping… (1m 17s · ↓ 2.1k tokens)") AND on an
|
|
34
|
+
* ACTIVE background agent ("⏺ general-purpose Listing f… 24m 23s · ↓ 216.5k tokens"), and is GONE
|
|
35
|
+
* the moment a turn finishes ("Baked for 23m 15s"). So a session whose MAIN loop is at the prompt
|
|
36
|
+
* but has agents still developing in the background reads "working" — NOT idle (the user's
|
|
37
|
+
* explicit correction). Also: a spinner's parenthesised timer, "Waiting for … to finish", or
|
|
38
|
+
* "esc to interrupt".
|
|
39
|
+
* blocked → claude is WAITING ON A DECISION FROM YOU: a permission prompt ("Do you want to proceed?"), a
|
|
40
|
+
* plan-mode approval ("Would you like to proceed?"). This is the ONLY state that earns the loud
|
|
41
|
+
* "needs you" — so it must stay RARE + real; that's why it's just the explicit-prompt phrasings.
|
|
42
|
+
* idle → none of the above: claude finished a turn and is sitting at an empty prompt with nothing running
|
|
43
|
+
* and nothing to decide. A calm "your turn whenever" — NOT a loud alert.
|
|
44
|
+
*
|
|
45
|
+
* Order matters: blocked is checked first (a decision prompt overrides any stale spinner text), then working,
|
|
46
|
+
* else idle.
|
|
47
|
+
*/
|
|
48
|
+
export declare function classifyPaneStatus(pane: string): PaneStatus;
|
|
49
|
+
/** How capturePane locates a session's tmux pane. */
|
|
50
|
+
export interface CaptureOptions {
|
|
51
|
+
tmuxBin?: string;
|
|
52
|
+
socket: string;
|
|
53
|
+
sessionName: string;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Capture a tmux session's CURRENT pane as plain text (`capture-pane -p`, no escape sequences). READ-ONLY —
|
|
58
|
+
* it never sends input or resizes, so it can NEVER disturb a live session. Best-effort: resolves "" on any
|
|
59
|
+
* error/timeout and never throws. Async (non-blocking) so the activity monitor doesn't stall the event loop.
|
|
60
|
+
*/
|
|
61
|
+
export declare function capturePane(opts: CaptureOptions): Promise<string>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* The newest Claude Code MAJOR.MINOR the classifier's markers below were VERIFIED against on a live box.
|
|
4
|
+
* The whole classifier is tied to Claude Code's ENGLISH TUI strings — the load-bearing markers are:
|
|
5
|
+
* - "Do you want to proceed" / "Would you like to proceed" (blocked — permission/plan prompts)
|
|
6
|
+
* - the gerund ellipsis "…" + "↓ <n>k tokens" live counter (working — main spinner + live agents)
|
|
7
|
+
* - a "…(<n>s"-style parenthesised spinner timer (working — pre-token-flow window)
|
|
8
|
+
* - "Waiting for … to finish" / "esc to interrupt" (working)
|
|
9
|
+
* A NEWER claude may reword any of these and silently degrade every rail status to "idle", so boot logs a
|
|
10
|
+
* one-time warning (see start.ts) when the installed claude's MAJOR.MINOR exceeds this. Bump it after
|
|
11
|
+
* re-verifying the markers against a newer claude.
|
|
12
|
+
*/
|
|
13
|
+
export const CLASSIFIER_TESTED_UP_TO = "2.1";
|
|
14
|
+
/**
|
|
15
|
+
* True iff `current`'s MAJOR.MINOR is strictly NEWER than `testedUpTo`'s. Patch versions are ignored — a
|
|
16
|
+
* patch release doesn't reword the TUI. Unparseable input (either side) → false, so a weird version string
|
|
17
|
+
* can never produce a spurious warning. Pure.
|
|
18
|
+
*/
|
|
19
|
+
export function isNewerMajorMinor(current, testedUpTo) {
|
|
20
|
+
const parse = (v) => {
|
|
21
|
+
const m = /(\d+)\.(\d+)/.exec(v);
|
|
22
|
+
return m ? [Number(m[1]), Number(m[2])] : undefined;
|
|
23
|
+
};
|
|
24
|
+
const cur = parse(current);
|
|
25
|
+
const tested = parse(testedUpTo);
|
|
26
|
+
if (!cur || !tested)
|
|
27
|
+
return false; // can't decide → never warn spuriously
|
|
28
|
+
if (cur[0] !== tested[0])
|
|
29
|
+
return cur[0] > tested[0];
|
|
30
|
+
return cur[1] > tested[1];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The one-line boot warning when the RUNNING claude is newer than the classifier was verified against —
|
|
34
|
+
* or undefined when there is nothing to warn about (older/equal/unknown version). Pure so it's testable;
|
|
35
|
+
* start.ts logs it once via console.warn and NEVER throws (a version bump must not affect boot).
|
|
36
|
+
*/
|
|
37
|
+
export function classifierVersionWarning(claudeVersion) {
|
|
38
|
+
if (!claudeVersion || !isNewerMajorMinor(claudeVersion, CLASSIFIER_TESTED_UP_TO))
|
|
39
|
+
return undefined;
|
|
40
|
+
return (`pane-status markers were verified against claude <=${CLASSIFIER_TESTED_UP_TO}; ` +
|
|
41
|
+
`current is ${claudeVersion} — verify rail statuses after this upgrade`);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Classify a session's live ACTIVITY from its RENDERED tmux pane (`capture-pane -p` — the CURRENT screen, not
|
|
45
|
+
* scrollback). UNIVERSAL: works for any running session regardless of how claude was spawned (no per-session
|
|
46
|
+
* hooks needed), and works while the browser is DETACHED (it reads the tmux session directly). Grounded in
|
|
47
|
+
* Claude Code's real output — see pane-status.test.ts for captured samples.
|
|
48
|
+
*
|
|
49
|
+
* working → something is actively generating. The strongest tell is a LIVE token-flow counter "↓ 216.5k
|
|
50
|
+
* tokens" — it appears on the MAIN spinner ("Schlepping… (1m 17s · ↓ 2.1k tokens)") AND on an
|
|
51
|
+
* ACTIVE background agent ("⏺ general-purpose Listing f… 24m 23s · ↓ 216.5k tokens"), and is GONE
|
|
52
|
+
* the moment a turn finishes ("Baked for 23m 15s"). So a session whose MAIN loop is at the prompt
|
|
53
|
+
* but has agents still developing in the background reads "working" — NOT idle (the user's
|
|
54
|
+
* explicit correction). Also: a spinner's parenthesised timer, "Waiting for … to finish", or
|
|
55
|
+
* "esc to interrupt".
|
|
56
|
+
* blocked → claude is WAITING ON A DECISION FROM YOU: a permission prompt ("Do you want to proceed?"), a
|
|
57
|
+
* plan-mode approval ("Would you like to proceed?"). This is the ONLY state that earns the loud
|
|
58
|
+
* "needs you" — so it must stay RARE + real; that's why it's just the explicit-prompt phrasings.
|
|
59
|
+
* idle → none of the above: claude finished a turn and is sitting at an empty prompt with nothing running
|
|
60
|
+
* and nothing to decide. A calm "your turn whenever" — NOT a loud alert.
|
|
61
|
+
*
|
|
62
|
+
* Order matters: blocked is checked first (a decision prompt overrides any stale spinner text), then working,
|
|
63
|
+
* else idle.
|
|
64
|
+
*/
|
|
65
|
+
export function classifyPaneStatus(pane) {
|
|
66
|
+
// Only look at the BOTTOM of the pane. Claude Code pins its live chrome — the spinner, the input box, the
|
|
67
|
+
// status line, and the background-agent block — to the last ~dozen rows; everything above is CONVERSATION
|
|
68
|
+
// SCROLLBACK. Restricting to the tail means a session whose scrollback happens to show these very marker
|
|
69
|
+
// strings (e.g. one editing roamcode's own source, or a diff mentioning "↓ … tokens" / "proceed") can't
|
|
70
|
+
// be misread — only claude's actual status chrome is classified.
|
|
71
|
+
const tail = pane.split("\n").slice(-22).join("\n");
|
|
72
|
+
// BLOCKED — an explicit decision prompt. Kept to Claude Code's exact permission/plan phrasings so the loud
|
|
73
|
+
// "needs you" it drives stays rare + trustworthy.
|
|
74
|
+
if (/\bDo you want to proceed\b/i.test(tail))
|
|
75
|
+
return "blocked";
|
|
76
|
+
if (/\bWould you like to proceed\b/i.test(tail))
|
|
77
|
+
return "blocked";
|
|
78
|
+
// WORKING — a LIVE token-flow counter "↓ <n>k tokens" is the reliable "actively generating" signal, present
|
|
79
|
+
// for BOTH the main spinner ("Schlepping… (1m 17s · ↓ 2.1k tokens)") and an active background agent
|
|
80
|
+
// ("⏺ general-purpose Listing f… 24m 23s · ↓ 216.5k tokens"). We require a GERUND ellipsis "…" on the SAME
|
|
81
|
+
// line so a FINISHED thing's past-tense summary ("Done · ↓ 12k tokens") can't read as working — an active
|
|
82
|
+
// worker always shows "<Gerund>… <timer> · ↓ tokens".
|
|
83
|
+
if (/…[^\n]*↓\s*[\d.]+\s*[kKmM]?\s*tokens?\b/.test(tail))
|
|
84
|
+
return "working";
|
|
85
|
+
// A spinner's parenthesised elapsed timer (covers the brief window at a turn's start before tokens flow).
|
|
86
|
+
if (/…\s*\(\s*\d+\s*[ms]\b/.test(tail))
|
|
87
|
+
return "working";
|
|
88
|
+
// Main loop blocked on a foreground agent/tool.
|
|
89
|
+
if (/\bWaiting for\b[\s\S]{0,80}?\bto finish\b/i.test(tail))
|
|
90
|
+
return "working";
|
|
91
|
+
// Interruptible generation (may be truncated to "e…" on a narrow phone pane, so it's a bonus, not the only tell).
|
|
92
|
+
if (/\besc to interrupt\b/i.test(tail))
|
|
93
|
+
return "working";
|
|
94
|
+
return "idle";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Capture a tmux session's CURRENT pane as plain text (`capture-pane -p`, no escape sequences). READ-ONLY —
|
|
98
|
+
* it never sends input or resizes, so it can NEVER disturb a live session. Best-effort: resolves "" on any
|
|
99
|
+
* error/timeout and never throws. Async (non-blocking) so the activity monitor doesn't stall the event loop.
|
|
100
|
+
*/
|
|
101
|
+
export function capturePane(opts) {
|
|
102
|
+
return new Promise((resolve) => {
|
|
103
|
+
let out = "";
|
|
104
|
+
let done = false;
|
|
105
|
+
const finish = (s) => {
|
|
106
|
+
if (done)
|
|
107
|
+
return;
|
|
108
|
+
done = true;
|
|
109
|
+
resolve(s);
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
const p = spawn(opts.tmuxBin ?? "tmux", ["-L", opts.socket, "capture-pane", "-p", "-t", opts.sessionName], {
|
|
113
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
114
|
+
});
|
|
115
|
+
p.stdout?.on("data", (d) => {
|
|
116
|
+
out += d.toString("utf8");
|
|
117
|
+
if (out.length > 65536) {
|
|
118
|
+
try {
|
|
119
|
+
p.kill();
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* already gone */
|
|
123
|
+
}
|
|
124
|
+
finish(out);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
p.on("error", () => finish("")); // tmux missing / spawn failed → treat as "no data"
|
|
128
|
+
p.on("close", () => finish(out));
|
|
129
|
+
const t = setTimeout(() => {
|
|
130
|
+
try {
|
|
131
|
+
p.kill();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
finish(out);
|
|
137
|
+
}, opts.timeoutMs ?? 2000);
|
|
138
|
+
if (typeof t.unref === "function")
|
|
139
|
+
t.unref();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
finish("");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type SpawnOptions } from "node:child_process";
|
|
2
|
+
export interface ClaudeModelCatalogItem {
|
|
3
|
+
value: string;
|
|
4
|
+
displayName: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
supportedEffortLevels: string[];
|
|
7
|
+
isDefault: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ClaudeMetadataRunner {
|
|
10
|
+
run(): Promise<unknown>;
|
|
11
|
+
/** Claude 2.1.207+ no longer includes models in initialize; `/model` is the catalog fallback. */
|
|
12
|
+
runModelList?(): Promise<unknown>;
|
|
13
|
+
dispose?(): void | Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
interface ClaudeMetadataChildProcess {
|
|
16
|
+
readonly stdin: {
|
|
17
|
+
end(chunk: string): unknown;
|
|
18
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
19
|
+
off(event: "error", listener: (error: Error) => void): unknown;
|
|
20
|
+
};
|
|
21
|
+
readonly stdout: NodeJS.ReadableStream;
|
|
22
|
+
readonly stderr: NodeJS.ReadableStream;
|
|
23
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
24
|
+
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
25
|
+
off(event: "error", listener: (error: Error) => void): this;
|
|
26
|
+
off(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
27
|
+
kill(): boolean;
|
|
28
|
+
}
|
|
29
|
+
type ClaudeMetadataSpawnProcess = (command: string, args: readonly string[], options: SpawnOptions) => ClaudeMetadataChildProcess;
|
|
30
|
+
interface CreateClaudeMetadataRunnerOptions {
|
|
31
|
+
claudeBin: string;
|
|
32
|
+
cwd: string;
|
|
33
|
+
env: NodeJS.ProcessEnv;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
maxOutputBytes?: number;
|
|
36
|
+
/** Process boundary injected by lifecycle tests. */
|
|
37
|
+
spawnProcess?: ClaudeMetadataSpawnProcess;
|
|
38
|
+
/** `/model` process boundary injected by fallback tests. */
|
|
39
|
+
modelListRun?: () => Promise<unknown>;
|
|
40
|
+
}
|
|
41
|
+
export declare class ClaudeMetadataService {
|
|
42
|
+
private readonly runner;
|
|
43
|
+
private readonly now;
|
|
44
|
+
private readonly ttlMs;
|
|
45
|
+
private cache?;
|
|
46
|
+
private inFlight?;
|
|
47
|
+
private disposed;
|
|
48
|
+
constructor(runner: ClaudeMetadataRunner, options?: {
|
|
49
|
+
now?: () => number;
|
|
50
|
+
ttlMs?: number;
|
|
51
|
+
});
|
|
52
|
+
getModels(force?: boolean): Promise<ClaudeModelCatalogItem[]>;
|
|
53
|
+
validateModelSelection(model: string, effort?: string): Promise<void>;
|
|
54
|
+
dispose(): void | Promise<void>;
|
|
55
|
+
private loadModels;
|
|
56
|
+
}
|
|
57
|
+
export declare function createClaudeMetadataRunner(options: CreateClaudeMetadataRunnerOptions): ClaudeMetadataRunner;
|
|
58
|
+
export {};
|